Initial commit
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
# SmartThings-Local Bridge config.
|
||||
# Copy to `.env` and fill in. Never commit `.env`.
|
||||
|
||||
# =============================================================
|
||||
# Appliances — one process supervises N appliances over DTLS.
|
||||
# =============================================================
|
||||
# APPLIANCE_COUNT defines how many entries to read. Per-appliance
|
||||
# keys are 1-indexed (APPLIANCE_1_*, APPLIANCE_2_*, …).
|
||||
APPLIANCE_COUNT=1
|
||||
|
||||
# Appliance 1 — Samsung dryer
|
||||
APPLIANCE_1_CLASS=dryer
|
||||
APPLIANCE_1_IP=192.168.1.100
|
||||
# Leave OCF_PORT blank to inherit the descriptor's default
|
||||
# (dryer=49155, oven=49154).
|
||||
APPLIANCE_1_OCF_PORT=
|
||||
APPLIANCE_1_TOPIC=samsung_dryer
|
||||
APPLIANCE_1_NAME=Samsung Dryer
|
||||
|
||||
# Future:
|
||||
# APPLIANCE_2_CLASS=oven
|
||||
# APPLIANCE_2_IP=192.168.1.101
|
||||
# APPLIANCE_2_OCF_PORT=
|
||||
# APPLIANCE_2_TOPIC=samsung_oven
|
||||
# APPLIANCE_2_NAME=Samsung Oven
|
||||
# (Don't forget to bump APPLIANCE_COUNT=2.)
|
||||
|
||||
# --- Cert paths ---
|
||||
# Defaults work for Docker (mount as /config) and bare-metal (drop
|
||||
# into ./certs). The ab0b0ac4 admin-override cert + key are built by
|
||||
# local-tools/setup_samsung_cloud_cert.py.
|
||||
# CERT_PATH=./certs/ab0b0ac4_fullchain.pem
|
||||
# KEY_PATH=./certs/ab0b0ac4.key
|
||||
|
||||
# --- MQTT broker (HA Mosquitto add-on or any broker) ---
|
||||
MQTT_BROKER=192.168.1.5
|
||||
MQTT_PORT=1883
|
||||
MQTT_USER=samsung_bridge
|
||||
MQTT_PASS=
|
||||
|
||||
# HA discovery prefix — must match the MQTT integration's setting in HA
|
||||
# (default `homeassistant`).
|
||||
HA_DISCOVERY_PREFIX=homeassistant
|
||||
|
||||
# Bridge timers (seconds).
|
||||
# HEALTH_INTERVAL_S — how often <prefix>/bridge/health republishes.
|
||||
# HEARTBEAT_INTERVAL_S — periodic full /device/0 re-seed. Refreshes
|
||||
# every resource (observed too) — useful for
|
||||
# appliances like the oven that don't reliably
|
||||
# push OBSERVE on /mode/vs/0 option changes. 0
|
||||
# disables.
|
||||
HEALTH_INTERVAL_S=60
|
||||
HEARTBEAT_INTERVAL_S=600
|
||||
|
||||
# Container TZ.
|
||||
TZ=Europe/London
|
||||
|
||||
|
||||
# --- Deploy (deploy.sh — tar + ssh docker compose) ---
|
||||
SSH_HOST=user@your-server
|
||||
REMOTE_DIR=/mnt/user/compose/smartthings-local
|
||||
APPDATA_DIR=/mnt/user/appdata/smartthings-local
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Secrets and per-instance config — never commit
|
||||
.env
|
||||
.DS_Store
|
||||
|
||||
# Certificates and keys — per-instance, never push
|
||||
certs/
|
||||
*.pem
|
||||
*.crt
|
||||
*.p12
|
||||
*.pfx
|
||||
*.key
|
||||
token.txt
|
||||
|
||||
# Python artifacts
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Bridge runtime
|
||||
bridge.log
|
||||
course_mapper.log
|
||||
|
||||
# Capture / dump files
|
||||
*.pcap
|
||||
*.har
|
||||
*.mitm
|
||||
capture/
|
||||
flows/
|
||||
|
||||
# Local-only research tools (not part of the bridge runtime)
|
||||
local-tools/
|
||||
|
||||
# Claude Code per-project state (permissions allowlist, etc.)
|
||||
.claude/
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Python deps first so layer cache survives code changes
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Application code
|
||||
COPY main.py .
|
||||
COPY samsung_appliance/ ./samsung_appliance/
|
||||
|
||||
# /config holds the ab0b0ac4 client cert + key. Mount from the host so
|
||||
# secrets aren't baked into the image.
|
||||
RUN mkdir -p /config
|
||||
|
||||
# Unbuffered stdout so docker logs is live
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Defaults — override in .env or `docker run -e …`. Per-appliance
|
||||
# keys (APPLIANCE_COUNT, APPLIANCE_<n>_*) have no universal default
|
||||
# and must be set in .env.
|
||||
ENV CERT_PATH=/config/ab0b0ac4_fullchain.pem \
|
||||
KEY_PATH=/config/ab0b0ac4.key \
|
||||
HA_DISCOVERY_PREFIX=homeassistant \
|
||||
HEALTH_INTERVAL_S=60 \
|
||||
HEARTBEAT_INTERVAL_S=600
|
||||
|
||||
# No port — bridge is outbound-only (DTLS UDP to appliance, MQTT to broker).
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
@@ -0,0 +1,387 @@
|
||||
# SmartThings-Local
|
||||
|
||||
**Local-first Home Assistant integration for newer-generation Samsung connected appliances.** One process supervises multiple appliances (dryer + oven currently), each over its own CoAP-DTLS session, publishing state + writes through MQTT with HA auto-discovery — no SmartThings cloud round-trip for any of it.
|
||||
|
||||
> ### Proof of concept — collaborators wanted
|
||||
>
|
||||
> This is working code running in my home and I rely on it daily, but it's a **proof of concept**, not a polished product. No unit tests; one person's hardware as the validation set (one dryer model, one oven model); hand-rolled MQTT-based integration instead of a proper HA custom component; "wired-but-untested" comments scattered through the oven descriptor; brittle to per-firmware quirks (the "oven doesn't push OBSERVE on options writes" finding is the kind of thing that needs ongoing care).
|
||||
>
|
||||
> **I would love for someone to take this further and build a proper HA integration out of it.** All the protocol research is done — DTLS auth via Samsung's published cloud identity, token-stable Block2 reads, OBSERVE-then-fetchback notifications, write semantics, the optimistic-publish-then-verify pattern, brick-avoiding resource boundaries — and the descriptor pattern is the seed of a clean per-appliance abstraction. The HA-side polish that's missing is custom-component shape: config flow, native entity classes, async-Python DTLS instead of MQTT round-trips, error surfacing into HA's notification system, support across more firmware versions, and someone who actually lives in the HA codebase.
|
||||
>
|
||||
> If you're that person, get in touch — happy to co-author, hand off, or hand over entirely.
|
||||
|
||||
### What you get
|
||||
|
||||
- **Multi-appliance, one container.** Single Docker service holds N DTLS sessions in parallel, one per appliance, sharing one MQTT client. Adding an appliance class is ~150 lines and one descriptor file.
|
||||
- **Sub-second push for state changes.** Cycle starts, pauses, ends, course changes, door opens, lamp toggles — Home Assistant reflects it within ~1 second on appliances that push OBSERVE notifications, or after the 3-second post-POST verify on appliances that don't.
|
||||
- **Writes that work**: dryer Start/Pause/Stop, course selection, wrinkle prevent; oven lamp (light entity), sound, fast preheat, setpoint slider, mode select, stop.
|
||||
- **Optimistic publish + verify**: HA sees the new value the instant the device 2.04-confirms the write; a Block2 fetch-back 3 seconds later corrects if the device silently coerced or rejected.
|
||||
- **HA Energy Dashboard ready** (dryer): live watts + cumulative kWh as `total_increasing`.
|
||||
- **Bridge logs tagged per-appliance** with `<class>.<serial>` once each device's serial is read on connect — `dryer.<serial>` vs `oven.<serial>` interleaved in the same log stream, easy to grep.
|
||||
- **Zero HA YAML.** Every entity is auto-discovered via MQTT discovery.
|
||||
- **Your state stays on your LAN.** Bridge → broker → HA. Samsung's cloud sees nothing from HA. *(The appliance still maintains its own TLS session to Samsung — appliance design, not ours.)*
|
||||
|
||||
### Under the hood
|
||||
|
||||
Each appliance runs an independent push-mode bridge: one sustained DTLS session, CoAP OBSERVE (RFC 7641) on ~11 of the appliance's `/<x>/vs/0` resources, token-stable Block2 (RFC 7959) for the multi-block reads, optimistic state publish + Block2 fetch-back verification after every write. Reconnect with exponential backoff on session errors.
|
||||
|
||||
Authentication uses **Samsung's publicly-published cloud-bridge identity** (UUID `ab0b0ac4-…`), present in every Samsung Tizen/RT-OCF appliance's factory ACL with `perm=31` (full CRUDN) on `href=*`. One cert chain works across the whole fleet. Setup is one Python script.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Is your appliance compatible?
|
||||
|
||||
Check before anything else; if it's older firmware, this project doesn't target it.
|
||||
|
||||
```sh
|
||||
# UDP scan for DTLS-CoAP ports
|
||||
nmap -Pn -sU -p 49152-49160 "$APPLIANCE_IP"
|
||||
```
|
||||
|
||||
Read the result:
|
||||
|
||||
- **`49154/udp` (or similar 4915x) open|filtered with a DTLS handshake responding** → newer firmware (Tizen RT 3.x with DAWIT 3.0). This is what the bridge talks to.
|
||||
- **Only `8888/tcp` open (token-based HTTPS)** → older firmware (~2018–2022). **Not supported here.**
|
||||
|
||||
### Tested combinations
|
||||
|
||||
| Appliance class | Model family | Confirmed |
|
||||
|---|---|---|
|
||||
| Dryer | DV5000T (`DA_WM_TP2_20_COMMON`, `mnid=0AJT`) | All entities, sub-second OBSERVE push |
|
||||
| Oven | NV7000BS-class (`TP1X_DA-KS-OVEN-0107X`, `mnid=0AJT`) | All entities; OBSERVE-push lazy on options-array writes (see "Per-appliance notes" below) |
|
||||
|
||||
Other appliances on the same firmware family (washers, dishwashers, AC units) almost certainly speak the same protocol — the auth path and read primitives are common. You'd write one new descriptor in `samsung_appliance/appliances/`.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Auth: get the cloud-identity cert
|
||||
|
||||
The bridge authenticates with a **client cert** signed by `AC14K_M` (Samsung's leaked diagnostic intermediate CA — used inside Samsung tooling and still trusted by current firmware). The cert's Subject DN contains the cloud-bridge UUID Samsung publishes on its wildcard cloud TLS cert at `*.samsungiotcloud.com`.
|
||||
|
||||
You can verify the UUID yourself with one OpenSSL command:
|
||||
|
||||
```sh
|
||||
openssl s_client -connect connect-v2.samsungiotcloud.com:443 \
|
||||
-servername connect-v2.samsungiotcloud.com \
|
||||
-showcerts < /dev/null 2>/dev/null \
|
||||
| openssl x509 -noout -subject
|
||||
# subject=C=KR, O=Samsung Electronics, OU=uuid:<UUID>, CN=*.samsungiotcloud.com
|
||||
```
|
||||
|
||||
The UUID lives in `OU=uuid:<UUID>`. Samsung's cert is valid through **2035-04-09**.
|
||||
|
||||
This README deliberately doesn't pin the literal UUID — the setup script extracts it live each run, so it self-updates if Samsung ever rotates.
|
||||
|
||||
### Why this works
|
||||
|
||||
- Every Samsung Tizen/RT-OCF appliance has a **factory-baked ACE** in `/oic/sec/acl` granting this UUID `perm=31` on `href=*`. It's the identity Samsung's own cloud-bridge daemon uses when forwarding cloud-issued commands to the on-device OCF stack.
|
||||
- TizenRT iotivity derives peerId from `memmem(subject_dn, "uuid:")` — RDN-agnostic. A cert with the UUID in CN authenticates the same as one with it in OU.
|
||||
- We don't have Samsung's matching private key (HSM-bound on their cloud) but we don't need it — we mint our own key and have `AC14K_M` sign our leaf. Different key, same identity, same access.
|
||||
|
||||
### One-command setup
|
||||
|
||||
You need `AC14K_M.pem`, its key, and the three upstream chain certs (`cert_1.pem`…`cert_4.pem`). These are published in [cicciovo/homebridge-samsung-airconditioner](https://github.com/cicciovo/homebridge-samsung-airconditioner). Drop them into `./certs/`.
|
||||
|
||||
```sh
|
||||
AC14K_M_CERT=./certs/ac14k_m.pem \
|
||||
AC14K_M_KEY=./certs/ac14k_m.key \
|
||||
CHAIN_DIR=./certs/ \
|
||||
OUT_DIR=./certs/ \
|
||||
TARGET_IP=$APPLIANCE_IP TARGET_PORT=49154 \
|
||||
python local-tools/setup_samsung_cloud_cert.py --test
|
||||
```
|
||||
|
||||
What it does:
|
||||
|
||||
1. **Live-fetches** Samsung's wildcard cloud cert and extracts the current cloud-bridge UUID.
|
||||
2. Generates a fresh RSA-2048 key pair you own.
|
||||
3. Builds a CSR with the UUID in OU + CN + SAN, signs it with `AC14K_M` (SHA-1).
|
||||
4. Concatenates `leaf + AC14K_M + 3 upstream CAs` into `fullchain.pem`.
|
||||
5. With `--test`: opens a DTLS handshake against `$TARGET_IP:$TARGET_PORT` and GETs `/oic/sec/acl` — a `2.05` reply proves the cert authenticated as the cloud-identity peer (anonymous peers get `4.01` on that resource).
|
||||
|
||||
Output: `ab0b0ac4_fullchain.pem` + `ab0b0ac4.key` (filename matches the UUID prefix as a convention; the actual UUID is whatever was published live). Drop them in `./certs/`.
|
||||
|
||||
The UUID is **not hardcoded** anywhere in the script or this README. If the live fetch fails (restricted network), `UUID=<uuid> python setup_samsung_cloud_cert.py …` lets you supply it manually; the docstring documents the openssl-extract one-liner.
|
||||
|
||||
### How durable is this?
|
||||
|
||||
Rotating the cloud-bridge UUID is roughly equivalent to Samsung re-issuing TLS certs across their entire IoT cloud AND pushing new ACLs to every device in the field AND updating the on-device cloud-bridge daemon's identity — a multi-quarter project with a months-long backwards-compat window. The `AC14K_M` signing CA has been publicly leaked for years and still appears in 2026 firmware trust stores. Our access is roughly as durable as SmartThings cloud control of these appliances.
|
||||
|
||||
> **Legacy path:** earlier versions of this project used a per-hub-UUID cert via an anonymous `/oic/sec/doxm` read escalation. That still works on the dryer-family firmware but isn't necessary — the ab0b0ac4 cert is one identity that authenticates against every appliance, factory ACL, and survives device resets. `bootstrap.py` in the repo automates the legacy path if you'd rather; otherwise ignore it.
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — Configure your appliances
|
||||
|
||||
Copy `.env.example` to `.env` and fill in.
|
||||
|
||||
### Layered envs
|
||||
|
||||
The bridge config splits into:
|
||||
|
||||
- **Shared keys** (one per process): MQTT broker + creds, HA discovery prefix, cert paths, timer intervals.
|
||||
- **Per-appliance keys** (one block per appliance) under `APPLIANCE_<n>_*` (1-indexed).
|
||||
|
||||
`APPLIANCE_COUNT` tells the bridge how many indexed blocks to read. Bump it as you add appliances.
|
||||
|
||||
```bash
|
||||
APPLIANCE_COUNT=2
|
||||
|
||||
# Appliance 1 — dryer
|
||||
APPLIANCE_1_CLASS=dryer
|
||||
APPLIANCE_1_IP=192.168.1.100
|
||||
APPLIANCE_1_OCF_PORT= # blank → descriptor default (49155 for dryer)
|
||||
APPLIANCE_1_TOPIC=samsung_dryer
|
||||
APPLIANCE_1_NAME=Samsung Dryer
|
||||
|
||||
# Appliance 2 — oven
|
||||
APPLIANCE_2_CLASS=oven
|
||||
APPLIANCE_2_IP=192.168.1.101
|
||||
APPLIANCE_2_OCF_PORT= # blank → descriptor default (49154 for oven)
|
||||
APPLIANCE_2_TOPIC=samsung_oven
|
||||
APPLIANCE_2_NAME=Samsung Oven
|
||||
```
|
||||
|
||||
Each `APPLIANCE_<n>_CLASS` must match a descriptor key in `samsung_appliance/appliances/__init__.py::DESCRIPTORS` — currently `dryer` and `oven`.
|
||||
|
||||
---
|
||||
|
||||
## Part 4 — Run it
|
||||
|
||||
### Docker (the real deployment)
|
||||
|
||||
```sh
|
||||
docker compose up -d --build
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
Container name `smartthings-local`. Outbound-only — no ports exposed. Needs egress to each appliance's IP/port (UDP) and to your MQTT broker. The certs in `./certs/` (or whatever `APPDATA_DIR` points to via the volume mount) are read-only mounted at `/config`.
|
||||
|
||||
### Deploying to a remote Linux host (Unraid, etc.)
|
||||
|
||||
```sh
|
||||
# Once: upload the cert + key onto the remote.
|
||||
ssh "$SSH_HOST" mkdir -p "$APPDATA_DIR"
|
||||
scp certs/ab0b0ac4_fullchain.pem certs/ab0b0ac4.key "$SSH_HOST:$APPDATA_DIR/"
|
||||
|
||||
# Each deploy: ship source + .env, rebuild container on the host.
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
Set `SSH_HOST`, `REMOTE_DIR`, `APPDATA_DIR` in `.env`. `deploy.sh` extracts those three keys via `grep` rather than `source .env`, so values containing spaces (like `APPLIANCE_1_NAME=Samsung Dryer`) don't break it.
|
||||
|
||||
### Bare metal (first test / debugging)
|
||||
|
||||
```sh
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
.venv/bin/python main.py
|
||||
```
|
||||
|
||||
### Expected first-run logs
|
||||
|
||||
```
|
||||
14:08:42 INFO samsung_appliance SmartThings-Local Bridge starting (2 appliances)
|
||||
14:08:42 INFO samsung_appliance broker = <broker-ip>:1883 (user=<mqtt-user>)
|
||||
14:08:42 INFO samsung_appliance [1] dryer @ <dryer-ip>:49155 (DTLS) → topic samsung_dryer/*
|
||||
14:08:42 INFO samsung_appliance [2] oven @ <oven-ip>:49154 (DTLS) → topic samsung_oven/*
|
||||
14:08:42 INFO samsung_appliance MQTT connected → <broker-ip>:1883
|
||||
14:08:43 INFO dryer DTLS connected — subscribing 11 paths
|
||||
14:08:44 INFO dryer.<dryer-serial> identified — serial=…
|
||||
14:08:44 INFO dryer.<dryer-serial> seeded → 25 links; sensors live
|
||||
14:08:44 INFO oven DTLS connected — subscribing 11 paths
|
||||
14:08:46 INFO oven.<oven-serial> identified — serial=…
|
||||
14:08:46 INFO oven.<oven-serial> seeded → 16 links; sensors live
|
||||
```
|
||||
|
||||
In HA: **Settings → Devices & Services → MQTT** should show both devices populated.
|
||||
|
||||
---
|
||||
|
||||
## Per-appliance notes
|
||||
|
||||
### Dryer
|
||||
|
||||
| Capability | Works? | Notes |
|
||||
|---|---|---|
|
||||
| Read all state | ✅ | Machine state, job state, energy (W + kWh), course, dry level, completion time, remote control, child lock, alarms |
|
||||
| Wrinkle Prevent toggle | ✅ | Persists |
|
||||
| Start / Pause / Stop | ✅ | Via `/operational/state/vs/0`; needs Remote Control on |
|
||||
| Change course | ✅ | Via `/st/dryercourse/vs/0`; needs Remote Control on. **Not exposed by the SmartThings cloud HA integration.** |
|
||||
| Power on/off | ❌ | Accepted (2.04) but reverts within seconds — hardware-mirrored |
|
||||
| Child Lock / Remote Control toggle | ❌ | Same — hardware-mirrored physical buttons |
|
||||
|
||||
The dryer pushes OBSERVE notifications on every state-changing write within ~100ms. State propagation is sub-second.
|
||||
|
||||
### Oven
|
||||
|
||||
| Capability | Works? | Notes |
|
||||
|---|---|---|
|
||||
| Read state | ✅ | Cavity state, current/target temp, door, mode, alarms, firmware-update-available |
|
||||
| Lamp (light entity) | ✅ | Binary On/Off only — High/Low/Dim values are accepted (2.04) but silently coerced back. Works regardless of Remote Control. |
|
||||
| Sound, Fast preheat | ⚠️ | Wired but untested write-side; RC-gated as a safety. |
|
||||
| Setpoint slider | ⚠️ | Wired but untested mid-cook behaviour. RC-gated. |
|
||||
| Mode select | ⚠️ | Wired but untested mid-cook behaviour. RC-gated. |
|
||||
| Stop button | ⚠️ | Wired but untested. **Not** RC-gated (the SmartThings app stops without Remote Control on, so we don't gate either). |
|
||||
| Power on/off as a switch | ❌ | Not exposed as a writeable entity — cold-start panel is a physical action. Read-only sensor only. |
|
||||
| **Kitchen timer (`⏲` icon)** | ❌ | **The oven's panel kitchen timer is not exposed via CoAP at all.** Confirmed by full `/device/0` dump — `UpperTimer*` fields in `/mode/vs/0` only populate when set via the API, not from the panel. |
|
||||
|
||||
**The oven doesn't push OBSERVE on `/mode/vs/0` writes** (the dryer does). The bridge defends with:
|
||||
1. **Optimistic publish** — the moment a POST returns 2.04, the bridge merges the write body into the local state and publishes to MQTT. HA reflects the new value instantly.
|
||||
2. **Fetch-back verification** — 3 seconds later, the bridge does a token-stable Block2 GET of the just-written resource. If the device's actual state differs from optimistic (silently coerced), the corrected state is republished and HA reverts.
|
||||
3. **Periodic heartbeat** — every `HEARTBEAT_INTERVAL_S` (default 600s), the bridge re-fetches `/device/0` and refreshes ALL resources (including observed ones), bounding worst-case drift.
|
||||
|
||||
---
|
||||
|
||||
## Reference
|
||||
|
||||
### Config keys
|
||||
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `APPLIANCE_COUNT` | Number of `APPLIANCE_<n>_*` blocks to read (1-indexed) |
|
||||
| `APPLIANCE_<n>_CLASS` | Descriptor name: `dryer`, `oven` |
|
||||
| `APPLIANCE_<n>_IP` | LAN IP of the appliance |
|
||||
| `APPLIANCE_<n>_OCF_PORT` | Optional override (blank → descriptor default: dryer=49155, oven=49154) |
|
||||
| `APPLIANCE_<n>_TOPIC` | MQTT topic prefix (also the HA device identifier — changing it re-keys the device) |
|
||||
| `APPLIANCE_<n>_NAME` | Friendly name on the HA device card |
|
||||
| `MQTT_BROKER` / `MQTT_PORT` / `MQTT_USER` / `MQTT_PASS` | Broker config |
|
||||
| `HA_DISCOVERY_PREFIX` | HA discovery topic root (default `homeassistant`) |
|
||||
| `CERT_PATH` / `KEY_PATH` | Override cert lookup (auto-detects `/config/` then `./certs/`) |
|
||||
| `HEALTH_INTERVAL_S` | Seconds between `<prefix>/bridge/health` publishes (default 60) |
|
||||
| `HEARTBEAT_INTERVAL_S` | Seconds between full `/device/0` re-seeds; `0` disables (default 600) |
|
||||
| `SSH_HOST` / `REMOTE_DIR` / `APPDATA_DIR` | Used by `deploy.sh` only |
|
||||
|
||||
### MQTT topics — outgoing (bridge → broker)
|
||||
|
||||
Per appliance, where `<prefix>` is its `APPLIANCE_<n>_TOPIC`.
|
||||
|
||||
| Topic | Retain | When |
|
||||
|---|---|---|
|
||||
| `<prefix>/availability` | ✓ | `online` after seed; `offline` on disconnect (LWT for appliance #1) |
|
||||
| `<prefix>/remote_available` | ✓ | `online` iff bridge is up AND Remote Control on the appliance is on. Gates the control entities. |
|
||||
| `<prefix>/state` | ✓ | JSON sensor dict; published only when sensors actually diff |
|
||||
| `<prefix>/bridge/health` | ✓ | Every `HEALTH_INTERVAL_S` — connect_count, error_count, notif_count, last_change_age_s, session_age_s, serial |
|
||||
| `<ha_prefix>/{sensor,binary_sensor,switch,light,number,select,button}/<prefix>/.../config` | ✓ | HA MQTT discovery, republished on every MQTT (re)connect |
|
||||
|
||||
### MQTT topics — incoming (bridge subscribes)
|
||||
|
||||
`<prefix>/cmd/#`. **The MQTT user must have READ permission on this subtree** — without it the broker silently drops the TCP connection shortly after SUBSCRIBE. Check broker logs if writes never land.
|
||||
|
||||
Dryer:
|
||||
|
||||
| Suffix | Payloads | Effect |
|
||||
|---|---|---|
|
||||
| `cmd/wrinkle_prevent` | `On`, `Off` | POST `/washer/vs/0` |
|
||||
| `cmd/operational_state` | `Run`, `Pause`, `Ready` | POST `/operational/state/vs/0` — requires RC |
|
||||
| `cmd/dryer_mode` | Course name (e.g. `Cotton`) | Translated to `Course_HH` then POST `/st/dryercourse/vs/0` — requires RC |
|
||||
|
||||
Oven:
|
||||
|
||||
| Suffix | Payloads | Effect |
|
||||
|---|---|---|
|
||||
| `cmd/lamp` | `On`, `Off` | RMW of `/mode/vs/0 .options[UpperLamp_*]` |
|
||||
| `cmd/sound` | `On`, `Off` | RMW of `/mode/vs/0 .options[Sound_*]` |
|
||||
| `cmd/fastpreheat` | `On`, `Off` | RMW of `/mode/vs/0 .options[fastpreheat_*]` |
|
||||
| `cmd/setpoint` | Integer °C (30–270, step 5) | RMW of `/temperatures/vs/0 .items[0].desired` — requires RC |
|
||||
| `cmd/mode` | Mode name (e.g. `Convection`, `LargeGrill`) | POST `/mode/vs/0 {modes: [<name>]}` — requires RC |
|
||||
| `cmd/stop` | (button press) | POST `/operational/state/vs/0 {state: Ready}` |
|
||||
|
||||
### Entity counts (approximate, per appliance)
|
||||
|
||||
| Type | Dryer | Oven |
|
||||
|---|---|---|
|
||||
| `sensor` | 17 | 17 |
|
||||
| `binary_sensor` | 4 | 7 |
|
||||
| `switch` | 1 (wrinkle) | 2 (sound, fastpreheat) |
|
||||
| `light` | — | 1 (lamp) |
|
||||
| `number` | — | 1 (setpoint slider) |
|
||||
| `select` | 1 (course) | 1 (mode) |
|
||||
| `button` | 3 (start/pause/stop) | 1 (stop) |
|
||||
|
||||
Gated control entities use HA's `availability_mode: all` against `<prefix>/availability` AND `<prefix>/remote_available`. Flip Remote Control on the appliance's front panel and those entities un-grey in HA.
|
||||
|
||||
### Repo layout
|
||||
|
||||
```
|
||||
main.py Entry point — loads config, spawns one PushBridge per appliance
|
||||
samsung_appliance/ The bridge package
|
||||
__init__.py
|
||||
config.py SharedConfig + ApplianceConfig dataclasses
|
||||
logger.py Tagged logger helpers
|
||||
bridge.py PushBridge — one DTLS session per appliance, descriptor-driven
|
||||
coap_dtls.py DTLS-CoAP session: handshake, token-stable Block2 GET, POST, OBSERVE
|
||||
sensors.py /device/0 link-dict indexer (shared util)
|
||||
appliances/
|
||||
__init__.py DESCRIPTORS registry + get_descriptor()
|
||||
base.py ApplianceDescriptor dataclass + HA discovery helpers
|
||||
dryer.py Dryer descriptor (paths, flatten, discovery, commands)
|
||||
oven.py Oven descriptor
|
||||
Dockerfile Container build (python:3.11-slim + 3 deps)
|
||||
docker-compose.yml One service: smartthings-local
|
||||
deploy.sh tar + ssh + docker compose up --build
|
||||
.env.example Template — copy to .env, fill in
|
||||
local-tools/ Research/probes — gitignored
|
||||
setup_samsung_cloud_cert.py One-shot cert minting script
|
||||
probe_oven_*.py DTLS probes for the oven (lamp, OBSERVE, full /device/0 fetch)
|
||||
comparisons/ Per-appliance /device/0 dumps + diff
|
||||
```
|
||||
|
||||
`certs/` is gitignored. Drop the privileged client cert + key there; the container mounts that directory read-only at `/config`.
|
||||
|
||||
---
|
||||
|
||||
## Adding a new appliance class
|
||||
|
||||
The bridge is appliance-agnostic. Adding e.g. a washer is mechanical:
|
||||
|
||||
1. Capture the appliance's `/device/0` to see what resources/fields it exposes. The setup script's `--test` mode is a good start; for the full dump use `local-tools/probe_oven_full_fetch.py` as a template.
|
||||
2. Create `samsung_appliance/appliances/washer.py` with:
|
||||
- `OBSERVE_PATHS` — list of `[seg, …]` paths to subscribe to (only `/<x>/vs/0` resources push; the OCF-standard `/<x>/0` siblings register but never fire)
|
||||
- `flatten(links) -> dict` — map link dict to the flat sensor dict that goes on MQTT
|
||||
- `build_discovery(prefix, ha_prefix, name) -> [(topic, payload), …]` — HA discovery configs
|
||||
- `command_handlers() -> {suffix: fn(payload, links)}` — MQTT commands → `(path_segs, body_dict)`
|
||||
- A module-level `WASHER = ApplianceDescriptor(name='washer', default_observe_port=…, …)`
|
||||
3. Add `WASHER` to `DESCRIPTORS` in `samsung_appliance/appliances/__init__.py`.
|
||||
4. Add `APPLIANCE_<n>_CLASS=washer` to `.env`, bump `APPLIANCE_COUNT`, redeploy.
|
||||
|
||||
The descriptor pattern handles everything else — DTLS, MQTT, HA discovery, optimistic+verify writes, Block2 reads, OBSERVE notifications, reconnect, periodic heartbeat.
|
||||
|
||||
---
|
||||
|
||||
## Traps to avoid
|
||||
|
||||
These each looked like obvious improvements at some point. Each one broke something.
|
||||
|
||||
- **Don't add OBSERVE subscriptions on OCF-standard `/<x>/0` paths.** They register successfully but never push. Use the Samsung `/<x>/vs/0` siblings (which do).
|
||||
- **Don't half-block the cloud.** Either let the appliance reach Samsung normally (rock-solid local session, sub-second push) or fully block it (the local session tears down every ~30s; bridge reconnects). Don't sinkhole DNS while letting IPs resolve to unreachable hosts — the appliance holds a stable local session but stops emitting OBSERVE pushes entirely. Worst of both worlds.
|
||||
- **Don't touch `/oic/sec/*` (doxm, pstat, cred, acl).** The bridge doesn't, and you shouldn't from helper scripts either — those resources have wedge/brick risk on Samsung's RT-OCF security stack. The bridge surfaces are strictly `/<x>/vs/0` and `/device/0`.
|
||||
- **Don't run two clients against the same appliance simultaneously.** Samsung's RT-OCF DTLS allows one active session per peer; a second handshake will get the device to drop the new socket. If HA seems to flap, check whether you've got `main.py` running locally AND the Docker container up.
|
||||
- **Don't expect parity from every write surface.** Samsung's firmware accepts a lot of writes with `2.04 Changed` but only some of them stick — power, child-lock, and remote-control writes are accepted-then-reverted because they're hardware-mirrored. The bridge's optimistic-publish-then-verify pattern handles this transparently: HA briefly shows the new value, the 3s fetch-back republishes the actual value, HA reverts.
|
||||
|
||||
---
|
||||
|
||||
## Known DTLS flakiness
|
||||
|
||||
Samsung's RT-OCF DTLS stack occasionally closes sessions actively — usually right after a Block2 GET or in the seconds after a POST. The bridge handles this with exponential reconnect (1s → 30s) and a re-seed on each new session. From HA's perspective the entity briefly goes offline then comes back; from the bridge's perspective you'll see lines like:
|
||||
|
||||
```
|
||||
oven.… DTLS recv: Unexpected EOF
|
||||
oven.… reconnect in 1s
|
||||
oven.… DTLS connected — subscribing 11 paths
|
||||
oven.… seeded → 16 links; sensors live
|
||||
```
|
||||
|
||||
If reconnects become persistent (e.g. >10 in a minute) something's actually wrong — check the appliance's Wi-Fi link first, then look for a competing DTLS client on the LAN.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Patches welcome — especially:
|
||||
|
||||
- New appliance descriptors (washer, dishwasher, AC, fridge, etc.) on the same Tizen RT 3.x firmware family.
|
||||
- Confirmation/refutation on additional dryer or oven models. `nmap` + `/device/0` dump + `/oic/d` GET is enough to know if you're on the same firmware family.
|
||||
- A proper HA custom component wrapping the bridge so there's a config flow instead of YAML/env editing.
|
||||
|
||||
If you submit a PR, please don't include real device UUIDs, MACs, serials, IPs, or bearer tokens — use the placeholders from `.env.example`.
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Interactive setup for samsung-appliance-local.
|
||||
|
||||
Run this once before `main.py`. It will:
|
||||
|
||||
1. Ask for your dryer's IP and OCF port; verify the port is reachable.
|
||||
2. Locate Samsung's AC14K_M intermediate CA cert + key on disk
|
||||
(you have to fetch these yourself — see the README link).
|
||||
3. Try to discover your SmartThings hub UUID anonymously from the
|
||||
dryer's /oic/sec/acl. If that fails, ask you for it.
|
||||
4. Generate a leaf cert (SHA-1 RSA, Samsung iot-Identity + role OIDs,
|
||||
Subject CN=urn:uuid:<HUB>) signed by AC14K_M, and write
|
||||
certs/mega.key + certs/mega_chain.pem.
|
||||
5. Offer to populate .env from .env.example with the IP/port.
|
||||
|
||||
This script is setup-only — `cryptography` is not a runtime dep. Install
|
||||
into a venv:
|
||||
|
||||
python -m venv .venv
|
||||
.venv/bin/pip install -r requirements-bootstrap.txt
|
||||
.venv/bin/python bootstrap.py
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import cbor2
|
||||
except ImportError:
|
||||
sys.exit("cbor2 not installed — pip install -r requirements-bootstrap.txt")
|
||||
|
||||
from samsung_dryer.coap import (
|
||||
URI_PATH, CSM, enc_opts, enc_tcp, read_tcp, fmt_code,
|
||||
)
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent
|
||||
CERTS_DIR = REPO_ROOT / 'certs'
|
||||
|
||||
# Samsung-specific OIDs the dryer firmware looks for in the leaf.
|
||||
SAMSUNG_IOT_IDENTITY_OID = '1.3.6.1.4.1.51414.0.1.2'
|
||||
SAMSUNG_ROLE_OID = '1.3.6.1.4.1.51414.1.3'
|
||||
|
||||
# AC14K_M cert link — used in user-facing error messages so the recipe
|
||||
# is self-contained.
|
||||
AC14K_M_SOURCE = (
|
||||
'https://github.com/cicciovo/homebridge-samsung-airconditioner '
|
||||
'(see ac14k_m.pem and the matching key)'
|
||||
)
|
||||
|
||||
|
||||
# ---------- tiny UX helpers ------------------------------------------------
|
||||
|
||||
BOLD = '\033[1m'
|
||||
DIM = '\033[2m'
|
||||
GREEN = '\033[32m'
|
||||
RED = '\033[31m'
|
||||
YEL = '\033[33m'
|
||||
END = '\033[0m'
|
||||
|
||||
def _tty():
|
||||
return sys.stdout.isatty()
|
||||
|
||||
def info(msg): print(f"{BOLD}»{END} {msg}" if _tty() else f"» {msg}")
|
||||
def ok(msg): print(f"{GREEN}✓{END} {msg}" if _tty() else f"OK {msg}")
|
||||
def warn(msg): print(f"{YEL}!{END} {msg}" if _tty() else f"! {msg}")
|
||||
def fail(msg): print(f"{RED}✗{END} {msg}" if _tty() else f"FAIL {msg}")
|
||||
def dim(msg): print(f"{DIM}{msg}{END}" if _tty() else msg)
|
||||
|
||||
def prompt(question, default=None):
|
||||
suffix = f" [{default}]" if default is not None else ""
|
||||
while True:
|
||||
try:
|
||||
ans = input(f" {question}{suffix}: ").strip()
|
||||
except EOFError:
|
||||
print(); sys.exit(130)
|
||||
if ans:
|
||||
return ans
|
||||
if default is not None:
|
||||
return default
|
||||
|
||||
def confirm(question, default=True):
|
||||
suffix = ' [Y/n]' if default else ' [y/N]'
|
||||
while True:
|
||||
try:
|
||||
ans = input(f" {question}{suffix}: ").strip().lower()
|
||||
except EOFError:
|
||||
print(); sys.exit(130)
|
||||
if not ans:
|
||||
return default
|
||||
if ans in ('y', 'yes'): return True
|
||||
if ans in ('n', 'no'): return False
|
||||
|
||||
|
||||
# ---------- step 1: AC14K_M discovery -------------------------------------
|
||||
# Note: we deliberately do NOT do a bare TCP reachability probe before
|
||||
# the real TLS handshake. The dryer's OCF stack treats a plain
|
||||
# TCP-open-then-close (no TLS) as anomalous and enters a defensive state
|
||||
# that closes subsequent handshakes' sockets immediately after CSM.
|
||||
# Empirically observed; see commit history. Reachability is checked
|
||||
# implicitly when we open TLS in step 3.
|
||||
|
||||
def find_ac14km():
|
||||
"""Look in ./certs/ for the AC14K_M cert + key under any of the
|
||||
common filenames. Returns (cert_path, key_path) or (None, None)."""
|
||||
cert_candidates = ['ac14k_m.pem', 'AC14K_M.pem', 'cert_1.pem']
|
||||
key_candidates = ['ac14k_m.key', 'AC14K_M.key', 'key.pem', 'ac14k_m_key.pem']
|
||||
cert = next((CERTS_DIR / n for n in cert_candidates if (CERTS_DIR / n).exists()), None)
|
||||
key = next((CERTS_DIR / n for n in key_candidates if (CERTS_DIR / n).exists()), None)
|
||||
return cert, key
|
||||
|
||||
|
||||
def check_openssl():
|
||||
"""Bootstrap shells out to openssl for cert generation — SHA-1 signing
|
||||
was removed from python-cryptography in v43, and openssl is ubiquitous
|
||||
enough that requiring it is reasonable."""
|
||||
if shutil.which('openssl') is None:
|
||||
fail("openssl not found in PATH — required for cert generation")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _run(cmd, **kw):
|
||||
"""Wrapper that surfaces stderr on failure."""
|
||||
res = subprocess.run(cmd, capture_output=True, text=True, **kw)
|
||||
if res.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"`{' '.join(cmd)}` failed:\n{res.stderr.strip() or res.stdout.strip()}"
|
||||
)
|
||||
return res
|
||||
|
||||
|
||||
def _openssl_config(common_name, hub_uuid=None, include_samsung_role=True):
|
||||
"""Return an OpenSSL config snippet matching the proven canonical recipe
|
||||
used to generate the original working `mega_chain.pem` for this project
|
||||
(see spoof/mega_ext.cnf). All four SAN entries and the `clientAuth,
|
||||
serverAuth` EKU values are defensive — the dryer's `memmem` scan only
|
||||
cares about the Subject DN, but adjacent tooling reads the rest."""
|
||||
v3_lines = [
|
||||
"basicConstraints = CA:FALSE",
|
||||
"keyUsage = digitalSignature, keyEncipherment",
|
||||
f"extendedKeyUsage = clientAuth, serverAuth, {SAMSUNG_IOT_IDENTITY_OID}",
|
||||
]
|
||||
if hub_uuid:
|
||||
v3_lines.append("subjectAltName = @alt_names")
|
||||
if include_samsung_role:
|
||||
v3_lines.append(
|
||||
f"{SAMSUNG_ROLE_OID} = ASN1:UTF8String:samsung.role.hub")
|
||||
sections = [
|
||||
"[ req ]",
|
||||
"distinguished_name = dn",
|
||||
"prompt = no",
|
||||
"req_extensions = v3",
|
||||
"",
|
||||
"[ dn ]",
|
||||
f"CN = {common_name}",
|
||||
"O = Samsung Electronics",
|
||||
"C = KR",
|
||||
"",
|
||||
"[ v3 ]",
|
||||
*v3_lines,
|
||||
]
|
||||
if hub_uuid:
|
||||
# Belt-and-braces SAN entries — three URI forms and a DNS name.
|
||||
# Matches the canonical mega_ext.cnf exactly so the leaf is
|
||||
# bit-for-bit equivalent to the cert known to authenticate.
|
||||
sections += [
|
||||
"",
|
||||
"[ alt_names ]",
|
||||
f"URI.1 = urn:uuid:{hub_uuid}",
|
||||
f"URI.2 = uri:uuid:{hub_uuid}",
|
||||
f"URI.3 = uuid:{hub_uuid}",
|
||||
f"DNS.1 = {hub_uuid}",
|
||||
]
|
||||
return "\n".join(sections) + "\n"
|
||||
|
||||
|
||||
def _generate_signed_cert(*, common_name, hub_uuid, include_samsung_role,
|
||||
ca_cert, ca_key, out_key, out_cert, days):
|
||||
"""Generate an RSA-2048 key + SHA-1 signed cert via openssl."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
tdp = Path(td)
|
||||
conf = tdp / 'leaf.cnf'
|
||||
csr = tdp / 'leaf.csr'
|
||||
conf.write_text(_openssl_config(common_name, hub_uuid,
|
||||
include_samsung_role))
|
||||
# 1) key + CSR with extensions baked into req_extensions
|
||||
_run(['openssl', 'req', '-new', '-newkey', 'rsa:2048', '-nodes',
|
||||
'-keyout', str(out_key), '-out', str(csr), '-config', str(conf)])
|
||||
# 2) sign with AC14K_M, SHA-1, copy the v3 extensions through
|
||||
_run(['openssl', 'x509', '-req', '-in', str(csr),
|
||||
'-CA', str(ca_cert), '-CAkey', str(ca_key),
|
||||
'-CAcreateserial', '-out', str(out_cert),
|
||||
'-days', str(days), '-sha1',
|
||||
'-extfile', str(conf), '-extensions', 'v3'])
|
||||
os.chmod(out_key, 0o600)
|
||||
|
||||
|
||||
def generate_leaf(hub_uuid, ca_cert, ca_key, out_dir):
|
||||
"""The real leaf — Subject CN contains `urn:uuid:<HUB_UUID>` so the
|
||||
dryer's `memmem` scan recognises us as the SmartThings hub. Writes
|
||||
mega.key and mega_chain.pem (leaf || AC14K_M)."""
|
||||
subject_uri = f"urn:uuid:{hub_uuid}"
|
||||
out_key = out_dir / 'mega.key'
|
||||
out_leaf = out_dir / 'mega_leaf.pem'
|
||||
out_chain = out_dir / 'mega_chain.pem'
|
||||
_generate_signed_cert(
|
||||
common_name=subject_uri,
|
||||
hub_uuid=hub_uuid,
|
||||
include_samsung_role=True,
|
||||
ca_cert=ca_cert, ca_key=ca_key,
|
||||
out_key=out_key, out_cert=out_leaf,
|
||||
days=365 * 5,
|
||||
)
|
||||
# Concatenate leaf || AC14K_M for the bridge's load_cert_chain.
|
||||
out_chain.write_bytes(out_leaf.read_bytes() + Path(ca_cert).read_bytes())
|
||||
out_leaf.unlink()
|
||||
return out_key, out_chain
|
||||
|
||||
|
||||
def generate_probe(ca_cert, ca_key, tmp_dir):
|
||||
"""Throwaway leaf with NO `uuid:` in the Subject DN — the dryer treats
|
||||
us as an anonymous-but-CA-trusted peer. Used once to attempt the
|
||||
anonymous ACL read; never written to disk outside tmp_dir."""
|
||||
out_key = tmp_dir / 'probe.key'
|
||||
out_leaf = tmp_dir / 'probe.pem'
|
||||
out_chain = tmp_dir / 'probe_chain.pem'
|
||||
_generate_signed_cert(
|
||||
common_name='samsung-local-bootstrap-probe',
|
||||
hub_uuid=None,
|
||||
include_samsung_role=False,
|
||||
ca_cert=ca_cert, ca_key=ca_key,
|
||||
out_key=out_key, out_cert=out_leaf,
|
||||
days=30,
|
||||
)
|
||||
out_chain.write_bytes(out_leaf.read_bytes() + Path(ca_cert).read_bytes())
|
||||
out_leaf.unlink()
|
||||
return out_key, out_chain
|
||||
|
||||
|
||||
# ---------- step 4: anonymous ACL read ------------------------------------
|
||||
|
||||
def open_tls(host, port, cert_path, key_path, timeout=8):
|
||||
"""Same pattern as samsung_dryer.bridge._open_tls — drop OpenSSL 3.x
|
||||
security level so SHA-1 leaves are accepted."""
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
try:
|
||||
ctx.set_ciphers('DEFAULT:@SECLEVEL=0')
|
||||
except ssl.SSLError:
|
||||
pass
|
||||
ctx.load_cert_chain(certfile=str(cert_path), keyfile=str(key_path))
|
||||
raw = socket.create_connection((host, port), timeout=timeout)
|
||||
sock = ctx.wrap_socket(raw)
|
||||
sock.send(CSM)
|
||||
sock.settimeout(2)
|
||||
try: read_tcp(sock)
|
||||
except (socket.timeout, ConnectionError): pass
|
||||
sock.settimeout(timeout)
|
||||
return sock
|
||||
|
||||
|
||||
def coap_get(sock, path_segs, token=b'\x01\x02\x03\x04'):
|
||||
opts = [(URI_PATH, s.encode()) for s in path_segs]
|
||||
sock.send(enc_tcp(0x01, token=token, opts_b=enc_opts(opts)))
|
||||
code, _tok, _opts, pl = read_tcp(sock)
|
||||
return code, pl
|
||||
|
||||
|
||||
def extract_hub_uuid_from_doxm(doxm_payload):
|
||||
"""Parse the CBOR-encoded /oic/sec/doxm response and return the hub
|
||||
UUID. On this firmware, `devowneruuid` and `rowneruuid` both carry
|
||||
the SmartThings hub's UUID — they're the same value in practice and
|
||||
we prefer devowneruuid (the OCF spec field for the device's owner)."""
|
||||
try:
|
||||
doc = cbor2.loads(doxm_payload)
|
||||
except Exception as e:
|
||||
warn(f"doxm CBOR decode failed: {e}")
|
||||
return None
|
||||
if not isinstance(doc, dict):
|
||||
warn(f"doxm decoded to {type(doc).__name__}, expected dict")
|
||||
return None
|
||||
for key in ('devowneruuid', 'rowneruuid'):
|
||||
val = doc.get(key)
|
||||
if isinstance(val, str) and looks_like_uuid(val):
|
||||
return val
|
||||
warn(f"doxm payload had no devowneruuid/rowneruuid (keys: "
|
||||
f"{list(doc.keys())})")
|
||||
return None
|
||||
|
||||
|
||||
def try_anonymous_doxm_read(host, port, ca_cert, ca_key):
|
||||
"""Discover the hub UUID by reading /oic/sec/doxm anonymously.
|
||||
|
||||
Mechanism: the dryer's baseline ACL contains a wildcard ACE
|
||||
(`subjectuuid=*` perm=2) granting any authenticated peer read access
|
||||
to /oic/sec/doxm. We don't need to be the hub — we just need to
|
||||
complete a chain-valid TLS handshake. doxm.devowneruuid is the
|
||||
SmartThings hub's UUID."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
tdp = Path(td)
|
||||
try:
|
||||
key_path, chain_path = generate_probe(ca_cert, ca_key, tdp)
|
||||
except RuntimeError as e:
|
||||
warn(f"probe cert generation failed: {e}")
|
||||
return None
|
||||
try:
|
||||
sock = open_tls(host, port, chain_path, key_path)
|
||||
except ConnectionRefusedError:
|
||||
fail(f"connection refused at {host}:{port} — wrong port, or "
|
||||
f"the dryer isn't on the LAN.")
|
||||
return None
|
||||
except (ssl.SSLError, OSError) as e:
|
||||
warn(f"anonymous TLS handshake failed: {e}")
|
||||
return None
|
||||
try:
|
||||
code, pl = coap_get(sock, ['oic', 'sec', 'doxm'])
|
||||
except ConnectionError as e:
|
||||
warn(f"dryer closed the CoAP session immediately after CSM: {e}")
|
||||
dim(" This usually means the dryer's OCF stack is in a "
|
||||
"defensive cooldown — typically caused by a concurrent "
|
||||
"TLS session (the bridge running) or rapid recent probes. "
|
||||
"Stop main.py / the bridge container, wait ~60s, then re-run.")
|
||||
return None
|
||||
finally:
|
||||
try: sock.close()
|
||||
except Exception: pass
|
||||
if code != 0x45:
|
||||
warn(f"GET /oic/sec/doxm → {fmt_code(code)} (expected 2.05) "
|
||||
f"— switching to manual entry")
|
||||
return None
|
||||
return extract_hub_uuid_from_doxm(pl)
|
||||
|
||||
|
||||
# ---------- step 5: .env ---------------------------------------------------
|
||||
|
||||
def maybe_write_env(appliance_ip, appliance_port):
|
||||
env_path = REPO_ROOT / '.env'
|
||||
example = REPO_ROOT / '.env.example'
|
||||
if not example.exists():
|
||||
warn(".env.example missing — skipping .env generation")
|
||||
return
|
||||
if env_path.exists():
|
||||
if not confirm("Overwrite existing .env with new IP/port? (other "
|
||||
"values preserved)", default=False):
|
||||
dim(" leaving .env untouched")
|
||||
return
|
||||
text = example.read_text()
|
||||
text = _replace_kv(text, 'APPLIANCE_IP', appliance_ip)
|
||||
text = _replace_kv(text, 'APPLIANCE_OCF_PORT', str(appliance_port))
|
||||
env_path.write_text(text)
|
||||
ok(f"wrote {env_path} — fill in MQTT_BROKER / MQTT_USER / MQTT_PASS before running main.py")
|
||||
|
||||
|
||||
def _replace_kv(text, key, value):
|
||||
out = []
|
||||
for line in text.splitlines():
|
||||
if line.startswith(f"{key}="):
|
||||
out.append(f"{key}={value}")
|
||||
else:
|
||||
out.append(line)
|
||||
return '\n'.join(out) + ('\n' if text.endswith('\n') else '')
|
||||
|
||||
|
||||
# ---------- step 6: hub UUID validation -----------------------------------
|
||||
|
||||
def looks_like_uuid(s):
|
||||
import re
|
||||
return bool(re.fullmatch(
|
||||
r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-'
|
||||
r'[0-9a-fA-F]{4}-[0-9a-fA-F]{12}', s.strip()))
|
||||
|
||||
|
||||
# ---------- main ----------------------------------------------------------
|
||||
|
||||
def main():
|
||||
print()
|
||||
print(f"{BOLD}samsung-appliance-local — bootstrap{END}" if _tty()
|
||||
else "samsung-appliance-local — bootstrap")
|
||||
print(f"{DIM}This will discover your dryer, locate your CA cert, and "
|
||||
f"generate the leaf used to authenticate as the SmartThings hub.{END}"
|
||||
if _tty() else
|
||||
"This will discover your dryer, locate your CA cert, and generate "
|
||||
"the leaf used to authenticate as the SmartThings hub.")
|
||||
print()
|
||||
|
||||
# --- 1. dryer location ---
|
||||
# Reachability is verified implicitly by the TLS handshake in step 3.
|
||||
# We can't do a bare TCP probe here — that knocks the dryer's OCF
|
||||
# session into a defensive state and breaks the subsequent TLS attempt.
|
||||
info("Step 1 — dryer location")
|
||||
appliance_ip = prompt("Dryer IP on your LAN", default=None)
|
||||
appliance_port = int(prompt("OCF port (newer firmware uses 49154)",
|
||||
default='49154'))
|
||||
dim(f" Will connect to {appliance_ip}:{appliance_port} once we have "
|
||||
f"a probe cert.")
|
||||
print()
|
||||
|
||||
# --- 2. AC14K_M ---
|
||||
info("Step 2 — locate Samsung's AC14K_M intermediate CA")
|
||||
CERTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cert_path, key_path = find_ac14km()
|
||||
if cert_path is None or key_path is None:
|
||||
fail(f"AC14K_M cert + key not found in {CERTS_DIR}/")
|
||||
dim(f" Fetch them from: {AC14K_M_SOURCE}")
|
||||
dim(f" Place as: {CERTS_DIR}/ac14k_m.pem and "
|
||||
f"{CERTS_DIR}/ac14k_m.key (other common names accepted)")
|
||||
return 2
|
||||
ok(f"found CA cert: {cert_path.name}")
|
||||
ok(f"found CA key: {key_path.name}")
|
||||
if not check_openssl():
|
||||
return 2
|
||||
print()
|
||||
|
||||
# --- 3. hub UUID ---
|
||||
info("Step 3 — discover your SmartThings hub UUID")
|
||||
dim(" Reading /oic/sec/doxm anonymously — the dryer's baseline ACL")
|
||||
dim(" allows any authenticated peer to read it (wildcard ACE).")
|
||||
hub_uuid = try_anonymous_doxm_read(appliance_ip, appliance_port,
|
||||
cert_path, key_path)
|
||||
if hub_uuid:
|
||||
ok(f"discovered hub UUID from /oic/sec/doxm: {hub_uuid}")
|
||||
if not confirm("Use this UUID?", default=True):
|
||||
hub_uuid = None
|
||||
if not hub_uuid:
|
||||
warn("Falling back to manual entry. Options B/C in the README "
|
||||
"describe how to obtain it.")
|
||||
while True:
|
||||
hub_uuid = prompt("Hub UUID (8-4-4-4-12 hex)", default=None)
|
||||
if looks_like_uuid(hub_uuid):
|
||||
hub_uuid = hub_uuid.strip().lower()
|
||||
break
|
||||
warn("That doesn't look like a UUID. Format: "
|
||||
"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx")
|
||||
print()
|
||||
|
||||
# --- 4. leaf ---
|
||||
info("Step 4 — generate the leaf cert (mega.key + mega_chain.pem)")
|
||||
mega_key = CERTS_DIR / 'mega.key'
|
||||
mega_chain = CERTS_DIR / 'mega_chain.pem'
|
||||
if mega_key.exists() or mega_chain.exists():
|
||||
warn(f"existing leaf cert detected in {CERTS_DIR}/")
|
||||
if not confirm("Overwrite?", default=False):
|
||||
dim(" leaving existing leaf in place — skipping generation")
|
||||
print()
|
||||
maybe_write_env(appliance_ip, appliance_port)
|
||||
print()
|
||||
ok("Done.")
|
||||
return 0
|
||||
try:
|
||||
key_out, chain_out = generate_leaf(hub_uuid, cert_path, key_path,
|
||||
CERTS_DIR)
|
||||
except RuntimeError as e:
|
||||
fail(f"leaf cert generation failed: {e}")
|
||||
return 2
|
||||
ok(f"wrote {key_out}")
|
||||
ok(f"wrote {chain_out}")
|
||||
print()
|
||||
|
||||
# --- 5. .env ---
|
||||
info("Step 5 — populate .env")
|
||||
maybe_write_env(appliance_ip, appliance_port)
|
||||
print()
|
||||
|
||||
ok("Done. Next: edit .env to fill in MQTT_BROKER / MQTT_USER / "
|
||||
"MQTT_PASS, then run main.py.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
sys.exit(main())
|
||||
except KeyboardInterrupt:
|
||||
print(); sys.exit(130)
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/bin/bash
|
||||
# Sync source + .env to the remote and rebuild the container.
|
||||
#
|
||||
# Two host paths are used:
|
||||
# REMOTE_DIR — compose project (source code, .env, docker-compose.yml)
|
||||
# Convention: /mnt/user/compose/samsung-bridge/
|
||||
# APPDATA_DIR — bind-mount source for /config inside the container
|
||||
# (ab0b0ac4 client cert + key live here).
|
||||
# Convention: /mnt/user/appdata/samsung-bridge/
|
||||
#
|
||||
# The remote must already have the certs in $APPDATA_DIR. Run once
|
||||
# before the first deploy:
|
||||
#
|
||||
# source .env
|
||||
# ssh "$SSH_HOST" mkdir -p "$APPDATA_DIR"
|
||||
# scp certs/ab0b0ac4_fullchain.pem certs/ab0b0ac4.key \
|
||||
# "$SSH_HOST:$APPDATA_DIR/"
|
||||
#
|
||||
# Subsequent deploys (this script) ship source code + .env only; the
|
||||
# certs in $APPDATA_DIR are preserved.
|
||||
set -e
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "Error: .env file not found. Copy .env.example to .env and configure it."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Pull only the keys deploy.sh actually needs, without sourcing .env.
|
||||
# Sourcing would tokenize unquoted spaces in values (e.g.
|
||||
# `APPLIANCE_1_NAME=Samsung Dryer`) as shell commands.
|
||||
get_env() {
|
||||
grep -E "^${1}=" .env | head -1 | cut -d= -f2-
|
||||
}
|
||||
SSH_HOST=$(get_env SSH_HOST)
|
||||
REMOTE_DIR=$(get_env REMOTE_DIR)
|
||||
APPDATA_DIR=$(get_env APPDATA_DIR)
|
||||
|
||||
: "${SSH_HOST:?SSH_HOST not set in .env}"
|
||||
: "${REMOTE_DIR:?REMOTE_DIR not set in .env}"
|
||||
: "${APPDATA_DIR:?APPDATA_DIR not set in .env}"
|
||||
|
||||
echo "Deploying to ${SSH_HOST}:${REMOTE_DIR}…"
|
||||
ssh "${SSH_HOST}" mkdir -p "${REMOTE_DIR}" "${APPDATA_DIR}"
|
||||
|
||||
# Source code — explicit allowlist instead of an excludelist. Anything
|
||||
# else in the repo (research files, certs, logs, the .git dir) stays
|
||||
# local.
|
||||
COPYFILE_DISABLE=1 tar cz \
|
||||
main.py \
|
||||
samsung_appliance/ \
|
||||
Dockerfile \
|
||||
docker-compose.yml \
|
||||
requirements.txt \
|
||||
deploy.sh \
|
||||
README.md \
|
||||
.env.example \
|
||||
.gitignore \
|
||||
| ssh "${SSH_HOST}" "cd ${REMOTE_DIR} && tar xz && find . -name '._*' -delete"
|
||||
|
||||
# Ship .env separately and lock it down on the remote.
|
||||
scp .env "${SSH_HOST}:${REMOTE_DIR}/.env"
|
||||
ssh "${SSH_HOST}" "chmod 600 ${REMOTE_DIR}/.env"
|
||||
|
||||
# Verify certs are present on the remote — they have to be uploaded
|
||||
# once before the first build.
|
||||
if ! ssh "${SSH_HOST}" "test -s ${APPDATA_DIR}/ab0b0ac4_fullchain.pem && test -s ${APPDATA_DIR}/ab0b0ac4.key"; then
|
||||
echo
|
||||
echo "WARNING: ${APPDATA_DIR}/ab0b0ac4_fullchain.pem and ab0b0ac4.key not"
|
||||
echo "found on the remote. The container will start but fail to"
|
||||
echo "connect to the appliance until you upload them, e.g.:"
|
||||
echo " ssh ${SSH_HOST} mkdir -p ${APPDATA_DIR}"
|
||||
echo " scp certs/ab0b0ac4_fullchain.pem certs/ab0b0ac4.key ${SSH_HOST}:${APPDATA_DIR}/"
|
||||
echo
|
||||
fi
|
||||
|
||||
echo "Rebuilding container…"
|
||||
ssh "${SSH_HOST}" "cd ${REMOTE_DIR} && docker compose up -d --build"
|
||||
|
||||
echo "Done."
|
||||
echo "Logs: ssh ${SSH_HOST} 'cd ${REMOTE_DIR} && docker compose logs -f'"
|
||||
@@ -0,0 +1,21 @@
|
||||
services:
|
||||
smartthings-local:
|
||||
build: .
|
||||
container_name: smartthings-local
|
||||
restart: unless-stopped
|
||||
|
||||
# Bridge is outbound-only (DTLS UDP to each appliance, MQTT to the
|
||||
# broker on 1883). No ports to expose.
|
||||
|
||||
volumes:
|
||||
# Holds the ab0b0ac4 client cert + key. APPDATA_DIR comes from
|
||||
# .env; on Unraid this is typically
|
||||
# /mnt/user/appdata/smartthings-local/. Bare-metal dev falls
|
||||
# back to ./certs alongside this compose file.
|
||||
- ${APPDATA_DIR:-./certs}:/config:ro
|
||||
|
||||
# All runtime config is in .env. env_file passes every variable
|
||||
# straight into the container, so adding a new appliance is a
|
||||
# .env edit only — no compose change.
|
||||
env_file:
|
||||
- .env
|
||||
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SmartThings-Local Bridge — entry point.
|
||||
|
||||
One process supervises N Samsung appliances over their OCF CoAP-DTLS
|
||||
local APIs, publishing state + HA discovery to MQTT. Each appliance
|
||||
runs its own DTLS session in its own thread. MQTT is shared.
|
||||
|
||||
Config is env-var driven:
|
||||
* APPLIANCE_COUNT plus APPLIANCE_<n>_{CLASS,IP,OCF_PORT,TOPIC,NAME}
|
||||
define the appliances to bridge.
|
||||
* Shared keys (MQTT_*, HA_DISCOVERY_PREFIX, CERT_PATH, KEY_PATH,
|
||||
HEALTH_INTERVAL_S, HEARTBEAT_INTERVAL_S) apply to all.
|
||||
|
||||
Reconnects on session errors per-appliance; shuts down cleanly on
|
||||
SIGINT / SIGTERM."""
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
from samsung_appliance.appliances import get_descriptor
|
||||
from samsung_appliance.bridge import PushBridge
|
||||
from samsung_appliance.config import SharedConfig, load_appliances
|
||||
from samsung_appliance.logger import logger
|
||||
|
||||
|
||||
def main():
|
||||
shared = SharedConfig.from_env()
|
||||
|
||||
try:
|
||||
appliances = load_appliances()
|
||||
except ValueError as e:
|
||||
logger.error("config: %s", e)
|
||||
return 2
|
||||
|
||||
if not shared.MQTT_BROKER:
|
||||
logger.error("config: MQTT_BROKER not set")
|
||||
return 2
|
||||
for path in (shared.CERT_PATH, shared.KEY_PATH):
|
||||
if not path.exists():
|
||||
logger.error("client cert/key not found: %s", path)
|
||||
return 2
|
||||
|
||||
# Resolve descriptors up front — bad APPLIANCE_<n>_CLASS should fail
|
||||
# at startup, not 10s into the first DTLS attempt.
|
||||
pairs = []
|
||||
for app in appliances:
|
||||
try:
|
||||
desc = get_descriptor(app.klass)
|
||||
except ValueError as e:
|
||||
logger.error("APPLIANCE_%d_CLASS: %s", app.index, e)
|
||||
return 2
|
||||
pairs.append((app, desc))
|
||||
|
||||
logger.info("SmartThings-Local Bridge starting (%d appliance%s)",
|
||||
len(appliances), '' if len(appliances) == 1 else 's')
|
||||
logger.info(" broker = %s:%d (user=%s)",
|
||||
shared.MQTT_BROKER, shared.MQTT_PORT,
|
||||
shared.MQTT_USER or '<anon>')
|
||||
for app, desc in pairs:
|
||||
port = app.ocf_port or desc.default_observe_port
|
||||
logger.info(" [%d] %s @ %s:%d (DTLS) → topic %s/*",
|
||||
app.index, app.klass, app.ip, port, app.topic_prefix)
|
||||
|
||||
# --- MQTT client (shared) ---
|
||||
cli = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id='smartthings_local_bridge')
|
||||
if shared.MQTT_USER:
|
||||
cli.username_pw_set(shared.MQTT_USER, shared.MQTT_PASS)
|
||||
# Headroom for the on_connect burst across all appliances (each
|
||||
# publishes ~30 retained QoS-1 messages: discovery + state + avail).
|
||||
# 100 + 50*N keeps us comfortably ahead of paho's default 20.
|
||||
cli.max_inflight_messages_set(100 + 50 * len(appliances))
|
||||
|
||||
# Use the FIRST appliance's availability topic for LWT — paho only
|
||||
# supports one will message. If a second appliance is added later,
|
||||
# its availability is managed via explicit publishes on disconnect
|
||||
# rather than LWT. For Phase 1 (dryer only) this is exact.
|
||||
first_app = appliances[0]
|
||||
cli.will_set(f"{first_app.topic_prefix}/availability",
|
||||
payload='offline', qos=1, retain=True)
|
||||
|
||||
# Build bridges. Each builds its own discovery payloads in __init__.
|
||||
bridges: list[PushBridge] = [PushBridge(shared, app, desc, cli)
|
||||
for app, desc in pairs]
|
||||
by_prefix = {b.cmd_topic_prefix.rstrip('/'): b for b in bridges}
|
||||
|
||||
def on_connect(client, userdata, flags, rc, props=None):
|
||||
if rc != 0:
|
||||
logger.warning("MQTT connect rc=%s", rc)
|
||||
return
|
||||
logger.info("MQTT connected → %s:%d",
|
||||
shared.MQTT_BROKER, shared.MQTT_PORT)
|
||||
for b in bridges:
|
||||
for topic, payload in b.discovery_payloads:
|
||||
client.publish(topic, payload, qos=1, retain=True)
|
||||
cmd_wildcard = f"{b.app.topic_prefix}/cmd/#"
|
||||
client.subscribe(cmd_wildcard, qos=1)
|
||||
b.reassert_availability()
|
||||
logger.info("subscribed to %d cmd wildcards", len(bridges))
|
||||
|
||||
def on_disconnect(client, userdata, flags, rc, props=None):
|
||||
logger.warning("MQTT disconnected rc=%s", rc)
|
||||
|
||||
def on_message(client, userdata, msg):
|
||||
# Route by topic prefix. Each appliance owns a distinct
|
||||
# `<prefix>/cmd/*` namespace, so the prefix-match is unambiguous.
|
||||
for prefix, bridge in by_prefix.items():
|
||||
if msg.topic.startswith(prefix + '/'):
|
||||
try:
|
||||
payload = msg.payload.decode('utf-8',
|
||||
errors='replace').strip()
|
||||
except Exception:
|
||||
return
|
||||
bridge.handle_command(msg.topic, payload)
|
||||
return
|
||||
|
||||
cli.on_connect = on_connect
|
||||
cli.on_disconnect = on_disconnect
|
||||
cli.on_message = on_message
|
||||
|
||||
if os.getenv('PAHO_DEBUG'):
|
||||
paho_logger = logging.getLogger('paho.mqtt.client')
|
||||
paho_logger.setLevel(logging.DEBUG)
|
||||
paho_handler = logging.StreamHandler(sys.stdout)
|
||||
paho_handler.setFormatter(logging.Formatter(
|
||||
'%(asctime)s PAHO %(message)s', datefmt='%H:%M:%S'))
|
||||
paho_logger.addHandler(paho_handler)
|
||||
paho_logger.propagate = False
|
||||
cli.enable_logger(paho_logger)
|
||||
|
||||
cli.connect_async(shared.MQTT_BROKER, shared.MQTT_PORT, keepalive=60)
|
||||
cli.loop_start()
|
||||
|
||||
# Per-bridge runner / health / heartbeat threads.
|
||||
threads: list[threading.Thread] = []
|
||||
|
||||
def make_health(b: PushBridge):
|
||||
def loop():
|
||||
while not b.stop.is_set():
|
||||
b.publish_health()
|
||||
if b.stop.wait(shared.HEALTH_INTERVAL_S):
|
||||
break
|
||||
return loop
|
||||
|
||||
def make_heartbeat(b: PushBridge):
|
||||
def loop():
|
||||
while not b.stop.is_set():
|
||||
if b.stop.wait(shared.HEARTBEAT_INTERVAL_S):
|
||||
break
|
||||
b.heartbeat()
|
||||
return loop
|
||||
|
||||
for b in bridges:
|
||||
tag = b.app.klass
|
||||
threads.append(threading.Thread(
|
||||
target=b.run_forever, daemon=True, name=f'{tag}-session'))
|
||||
threads.append(threading.Thread(
|
||||
target=make_health(b), daemon=True, name=f'{tag}-health'))
|
||||
if shared.HEARTBEAT_INTERVAL_S > 0:
|
||||
threads.append(threading.Thread(
|
||||
target=make_heartbeat(b), daemon=True,
|
||||
name=f'{tag}-heartbeat'))
|
||||
|
||||
stopping = threading.Event()
|
||||
|
||||
def shutdown(*_):
|
||||
if stopping.is_set():
|
||||
return
|
||||
stopping.set()
|
||||
logger.info("shutting down…")
|
||||
for b in bridges:
|
||||
b.stop.set()
|
||||
try: b.set_availability(False)
|
||||
except Exception: pass
|
||||
|
||||
signal.signal(signal.SIGINT, shutdown)
|
||||
signal.signal(signal.SIGTERM, shutdown)
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
|
||||
# Wait for the session threads (the only non-daemon-equivalent
|
||||
# loops). They exit when their bridge's stop event is set.
|
||||
try:
|
||||
for t in threads:
|
||||
if t.name.endswith('-session'):
|
||||
t.join()
|
||||
finally:
|
||||
try:
|
||||
cli.loop_stop()
|
||||
cli.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("stopped")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,4 @@
|
||||
# Setup-only deps. bootstrap.py reuses cbor2 to parse the dryer's ACL
|
||||
# response and shells out to `openssl` for cert generation (so SHA-1
|
||||
# signing keeps working independent of python-cryptography's policy).
|
||||
-r requirements.txt
|
||||
@@ -0,0 +1,9 @@
|
||||
cbor2>=5.6
|
||||
paho-mqtt>=2.0
|
||||
# pyOpenSSL 23.x added the set_ciphertext_mtu API the DTLS handshake
|
||||
# needs; older versions fragment the client cert across two UDP
|
||||
# datagrams and TizenRT drops the second one.
|
||||
pyOpenSSL>=23.0
|
||||
# scapy is only needed for the offline analyze_pcap.py helper; keep
|
||||
# optional to avoid pulling it into the bridge container.
|
||||
# scapy>=2.5
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Samsung appliance local-API → MQTT bridge with HA discovery.
|
||||
|
||||
Multi-device: dryer, oven, etc. The device class is selected at startup
|
||||
via the DEVICE_CLASS env var (see samsung_appliance.appliances)."""
|
||||
__version__ = "2.0.0"
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Appliance descriptors registry.
|
||||
|
||||
Adding a new appliance class:
|
||||
1. Write `appliances/<class>.py` with an ApplianceDescriptor named
|
||||
after the class (uppercase, e.g. OVEN).
|
||||
2. Add it to DESCRIPTORS below.
|
||||
3. Set DEVICE_CLASS=<class> in the per-device .env.
|
||||
|
||||
main.py imports get_descriptor(name) to look up the descriptor at
|
||||
startup; the bridge itself stays class-agnostic.
|
||||
"""
|
||||
from .base import ApplianceDescriptor
|
||||
from .dryer import DRYER
|
||||
from .oven import OVEN
|
||||
|
||||
|
||||
DESCRIPTORS: dict[str, ApplianceDescriptor] = {
|
||||
DRYER.name: DRYER,
|
||||
OVEN.name: OVEN,
|
||||
}
|
||||
|
||||
|
||||
def get_descriptor(name: str) -> ApplianceDescriptor:
|
||||
try:
|
||||
return DESCRIPTORS[name]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
f"unknown DEVICE_CLASS={name!r}; "
|
||||
f"available: {sorted(DESCRIPTORS)}") from None
|
||||
|
||||
|
||||
__all__ = ['ApplianceDescriptor', 'DESCRIPTORS', 'get_descriptor']
|
||||
@@ -0,0 +1,103 @@
|
||||
"""ApplianceDescriptor — the per-device-class abstraction.
|
||||
|
||||
The bridge is appliance-class-agnostic: it owns the DTLS session, the
|
||||
OBSERVE-token bookkeeping, and MQTT publish gating. Each appliance
|
||||
class (dryer, oven, …) provides a descriptor that supplies:
|
||||
|
||||
* observe_paths — which CoAP resources to OBSERVE
|
||||
* seed_path — the resource to fetch on connect (usually
|
||||
/device/0) to populate the link dict
|
||||
* flatten(links) — links → flat-dict that lands on MQTT
|
||||
* build_discovery(…) — list of (HA-discovery topic, payload)
|
||||
* command_handlers() — MQTT command-suffix → (path_segs, body_dict)
|
||||
|
||||
Optional hooks let an appliance hold transient state across pushes:
|
||||
|
||||
* on_observation(state, href, rep) — capture anchors, e.g. for
|
||||
time extrapolation
|
||||
* project(state, sensors) — fill in extrapolated fields
|
||||
on publish
|
||||
|
||||
state is a free-form dict the bridge owns and threads into both hooks.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApplianceDescriptor:
|
||||
"""Static description of a Samsung appliance class.
|
||||
|
||||
`name` is the value DEVICE_CLASS resolves to (e.g. 'dryer').
|
||||
`default_observe_port` documents the UDP port this firmware
|
||||
exposes its DTLS-CoAP endpoint on, for the .env templates and
|
||||
docs — config.APPLIANCE_OCF_PORT still wins at runtime."""
|
||||
|
||||
name: str
|
||||
default_observe_port: int
|
||||
|
||||
observe_paths: list[list[str]]
|
||||
seed_path: list[str]
|
||||
|
||||
flatten: Callable[[dict], dict]
|
||||
build_discovery: Callable[[str, str, str], list[tuple[str, bytes]]]
|
||||
# command_handlers() returns {topic_suffix: fn(payload_str, links_snapshot)}
|
||||
# where the fn returns (path_segs, body_dict) | None. Handlers receive
|
||||
# a snapshot of the bridge's link dict so they can do read-modify-write
|
||||
# on resources like `/mode/vs/0` options or `/temperatures/vs/0` items.
|
||||
command_handlers: Callable[[], dict[str, Callable[[str, dict], Optional[tuple]]]]
|
||||
|
||||
# Optional behavioural hooks. state is a mutable dict the bridge
|
||||
# threads in; the descriptor decides what keys to put in it.
|
||||
on_observation: Optional[Callable[[dict, str, dict], None]] = None
|
||||
project: Optional[Callable[[dict, dict], dict]] = None
|
||||
|
||||
# If set, the bridge maintains a second availability topic
|
||||
# `<prefix>/remote_available` derived from sensors[remote_field].
|
||||
# HA entities that the appliance only honours with Remote-Control
|
||||
# enabled gate themselves on it.
|
||||
remote_available_field: Optional[str] = None
|
||||
|
||||
# Optional log-line callback for state-change notifications. Gets
|
||||
# the freshly-projected sensors dict; returns a short string.
|
||||
log_state_change: Optional[Callable[[dict], str]] = None
|
||||
|
||||
|
||||
# --- HA-discovery helpers ----------------------------------------------
|
||||
# Pure builder fns used by descriptor build_discovery() implementations.
|
||||
# Kept here so the per-appliance modules stay focused on their entity
|
||||
# inventory.
|
||||
|
||||
def device_block(topic_prefix: str, device_name: str,
|
||||
model: str) -> dict:
|
||||
return {
|
||||
'identifiers': [topic_prefix],
|
||||
'name': device_name,
|
||||
'manufacturer': 'Samsung',
|
||||
'model': model,
|
||||
}
|
||||
|
||||
|
||||
def avail_base(avail_topic: str) -> list[dict]:
|
||||
return [{'topic': avail_topic,
|
||||
'payload_available': 'online',
|
||||
'payload_not_available': 'offline'}]
|
||||
|
||||
|
||||
def avail_with_remote(avail_topic: str,
|
||||
remote_topic: str) -> list[dict]:
|
||||
return [
|
||||
{'topic': avail_topic,
|
||||
'payload_available': 'online',
|
||||
'payload_not_available': 'offline'},
|
||||
{'topic': remote_topic,
|
||||
'payload_available': 'online',
|
||||
'payload_not_available': 'offline'},
|
||||
]
|
||||
|
||||
|
||||
def encode(cfg: dict) -> bytes:
|
||||
return json.dumps(cfg).encode()
|
||||
@@ -0,0 +1,450 @@
|
||||
"""Dryer descriptor.
|
||||
|
||||
Lifts the dryer-specific OBSERVE paths, sensor flattening, HA discovery
|
||||
inventory, and MQTT command handlers out of the original
|
||||
samsung_dryer/{bridge,sensors,discovery}.py modules into one place.
|
||||
"""
|
||||
import time
|
||||
|
||||
from .base import (
|
||||
ApplianceDescriptor,
|
||||
avail_base,
|
||||
avail_with_remote,
|
||||
device_block,
|
||||
encode,
|
||||
)
|
||||
|
||||
|
||||
# --- OBSERVE paths -----------------------------------------------------
|
||||
# Only Samsung's `/<x>/vs/0` siblings actually push notifications; the
|
||||
# OCF-standard `/<x>/0` paths accept registration silently but never
|
||||
# fire. flatten() derives the OCF-shaped values from the live /vs/0
|
||||
# strings.
|
||||
OBSERVE_PATHS = [
|
||||
['operational', 'state', 'vs', '0'], # state, remainingTime, progress
|
||||
['power', 'vs', '0'], # power on/off
|
||||
['kidslock', 'vs', '0'], # child lock
|
||||
['remotectrl', 'vs', '0'], # remote control enabled
|
||||
['energy', 'consumption', 'vs', '0'],
|
||||
['course', 'vs', '0'],
|
||||
['washer', 'vs', '0'], # dryLevel, dryTime, type
|
||||
['diagnosis', 'vs', '0'],
|
||||
['alarms', 'vs', '0'],
|
||||
['st', 'dryercourse', 'vs', '0'],
|
||||
['wm', 'jobbeginingstatus', 'vs', '0'],
|
||||
]
|
||||
|
||||
|
||||
# --- Course table ------------------------------------------------------
|
||||
# Captured 2026-05-29 by dialing every course on a
|
||||
# DA_WM_TP2_20_COMMON_DV5000T dryer. Other Samsung dryers may report a
|
||||
# different Table_NN; capture a fresh table for them with
|
||||
# local-tools/course_mapper.py.
|
||||
COURSE_NAMES = {
|
||||
'Table_03': {
|
||||
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 table_codes in COURSE_NAMES.values()
|
||||
for code, name in table_codes.items()
|
||||
}
|
||||
|
||||
|
||||
def _decode_course(s):
|
||||
"""`Table_03_Course_16` → `Cotton`. Pass through verbatim if the
|
||||
table or code isn't in our lookup."""
|
||||
if not isinstance(s, str) or '_Course_' not in s:
|
||||
return s
|
||||
table_part, _, code_str = s.partition('_Course_')
|
||||
table = COURSE_NAMES.get(table_part)
|
||||
if not table:
|
||||
return s
|
||||
try:
|
||||
code = int(code_str, 16)
|
||||
except ValueError:
|
||||
return s
|
||||
return table.get(code, s)
|
||||
|
||||
|
||||
def _encode_course(name):
|
||||
"""`Cotton` → `Course_16`. Returns None for unknown names so the
|
||||
caller refuses rather than POST garbage."""
|
||||
code = _COURSE_CODE_BY_NAME.get(name)
|
||||
if code is None:
|
||||
return None
|
||||
return f"Course_{code:02X}"
|
||||
|
||||
|
||||
def _course_options():
|
||||
"""Stable-sorted human course names for the HA select dropdown."""
|
||||
return sorted(_COURSE_CODE_BY_NAME.keys())
|
||||
|
||||
|
||||
# --- Samsung-state → OCF currentMachineState ---------------------------
|
||||
_SAMSUNG_STATE_TO_OCF = {
|
||||
'Ready': 'idle',
|
||||
'Run': 'active',
|
||||
'Running': 'active',
|
||||
'Pause': 'pause',
|
||||
'Paused': 'pause',
|
||||
'End': 'idle',
|
||||
}
|
||||
|
||||
|
||||
def _num(v):
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _int(v):
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# --- flatten -----------------------------------------------------------
|
||||
def flatten(links):
|
||||
"""Map a /device/0 link dict to the flat sensor dict that's
|
||||
published to MQTT. Every field reads from `/<x>/vs/0` paths so push
|
||||
updates immediately drive every entity."""
|
||||
g = lambda href, k, default=None: (links.get(href) or {}).get(k, default)
|
||||
|
||||
inst_w = _num(g('/energy/consumption/vs/0',
|
||||
'x.com.samsung.da.instantaneousPower'))
|
||||
cum_wh = _num(g('/energy/consumption/vs/0',
|
||||
'x.com.samsung.da.cumulativePower'))
|
||||
if inst_w is not None and inst_w < 0:
|
||||
# The dryer reports a phantom -500W when idle; HA energy
|
||||
# dashboard hates negatives.
|
||||
inst_w = 0.0
|
||||
|
||||
sam_state = g('/operational/state/vs/0', 'x.com.samsung.da.state')
|
||||
machine_state = (_SAMSUNG_STATE_TO_OCF.get(sam_state, sam_state)
|
||||
if sam_state is not None
|
||||
else g('/operational/state/0', 'currentMachineState'))
|
||||
|
||||
progress = g('/operational/state/vs/0', 'x.com.samsung.da.progress')
|
||||
job_state = progress or g('/operational/state/0', 'currentJobState')
|
||||
# HA's value_template treats the literal "None" as null (renders as
|
||||
# "Unknown"). Substitute something we can render verbatim.
|
||||
if job_state in (None, 'None'):
|
||||
job_state = 'Idle'
|
||||
if progress in (None, 'None'):
|
||||
progress = 'Idle'
|
||||
|
||||
remaining = (g('/operational/state/vs/0',
|
||||
'x.com.samsung.da.remainingTime')
|
||||
or g('/operational/state/0', 'remainingTime'))
|
||||
rem_min = None
|
||||
if remaining:
|
||||
try:
|
||||
h, m, s = remaining.split(':')
|
||||
rem_min = int(h) * 60 + int(m) + (1 if int(s) > 0 else 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sam_power = g('/power/vs/0', 'x.com.samsung.da.power')
|
||||
sam_kids = g('/kidslock/vs/0', 'x.com.samsung.da.kidsLock')
|
||||
sam_rc = g('/remotectrl/vs/0',
|
||||
'x.com.samsung.da.remoteControlEnabled')
|
||||
power_bin = (sam_power == 'On') if sam_power is not None else None
|
||||
kids_bin = (sam_kids != 'Ready') if sam_kids is not None else None
|
||||
rc_bin = (str(sam_rc).lower() == 'true') if sam_rc is not None else None
|
||||
|
||||
return {
|
||||
'machine_state': machine_state,
|
||||
'job_state': job_state,
|
||||
'progress': progress,
|
||||
'progress_percentage': _int(g('/operational/state/vs/0',
|
||||
'x.com.samsung.da.progressPercentage')
|
||||
or g('/operational/state/0',
|
||||
'progressPercentage')),
|
||||
'completion_time': remaining,
|
||||
'completion_minutes': rem_min,
|
||||
'delay_end_time': g('/operational/state/vs/0',
|
||||
'x.com.samsung.da.delayEndTime'),
|
||||
'power_state': sam_power,
|
||||
'power_state_binary': power_bin,
|
||||
'child_lock': sam_kids,
|
||||
'child_lock_binary': kids_bin,
|
||||
'remote_control': sam_rc,
|
||||
'remote_control_binary': rc_bin,
|
||||
'power_watts': inst_w,
|
||||
'energy_kwh': round(cum_wh / 1000.0, 2)
|
||||
if cum_wh is not None else None,
|
||||
'energy_wh_cumulative': int(cum_wh) if cum_wh is not None else None,
|
||||
'dryer_mode': _decode_course(
|
||||
g('/st/dryercourse/vs/0',
|
||||
'x.com.samsung.da.st.dryerMode')),
|
||||
'dry_level': _int(g('/washer/vs/0',
|
||||
'x.com.samsung.da.dryLevel')),
|
||||
'dry_time': g('/washer/vs/0',
|
||||
'x.com.samsung.da.dryTime'),
|
||||
'dryer_type': g('/washer/vs/0',
|
||||
'x.com.samsung.da.dryerType'),
|
||||
'wrinkle_prevent': g('/washer/vs/0',
|
||||
'x.com.samsung.da.wrinklePrevent'),
|
||||
'diagnosis': g('/diagnosis/vs/0',
|
||||
'x.com.samsung.da.diagnosisStart'),
|
||||
'country_code': g('/configuration/vs/0',
|
||||
'x.com.samsung.da.countryCode'),
|
||||
}
|
||||
|
||||
|
||||
# --- Remaining-time anchor + extrapolation ----------------------------
|
||||
# The dryer pushes /operational/state/vs/0 on state transitions but not
|
||||
# on remainingTime ticks. Anchor = (timestamp, total_seconds) at last
|
||||
# push; project() extrapolates downward while machine_state == 'active'.
|
||||
|
||||
def on_observation(state, href, rep):
|
||||
if href != '/operational/state/vs/0':
|
||||
return
|
||||
rem = rep.get('x.com.samsung.da.remainingTime')
|
||||
if not isinstance(rem, str):
|
||||
return
|
||||
try:
|
||||
h, m, s = rem.split(':')
|
||||
state['remaining_anchor'] = (time.time(),
|
||||
int(h) * 3600 + int(m) * 60 + int(s))
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
|
||||
def project(state, sensors):
|
||||
anchor = state.get('remaining_anchor')
|
||||
if sensors.get('machine_state') != 'active' or anchor is None:
|
||||
return sensors
|
||||
ts, total = anchor
|
||||
remaining = max(0, int(total - (time.time() - ts)))
|
||||
h, rest = divmod(remaining, 3600)
|
||||
m, s = divmod(rest, 60)
|
||||
sensors = dict(sensors)
|
||||
sensors['completion_time'] = f"{h}:{m:02d}:{s:02d}"
|
||||
sensors['completion_minutes'] = h * 60 + m + (1 if s > 0 else 0)
|
||||
return sensors
|
||||
|
||||
|
||||
# --- Log-line ----------------------------------------------------------
|
||||
def log_state_change(sensors):
|
||||
return (f"machine={sensors.get('machine_state')} "
|
||||
f"power={sensors.get('power_watts')}W "
|
||||
f"energy={sensors.get('energy_kwh')}kWh")
|
||||
|
||||
|
||||
# --- HA discovery ------------------------------------------------------
|
||||
MODEL = 'OCF dryer (TizenRT-iotivity)'
|
||||
|
||||
# (key, friendly name, extra-config-dict)
|
||||
_SENSORS = [
|
||||
('machine_state', 'Machine state', {'icon': 'mdi:tumble-dryer'}),
|
||||
('job_state', 'Job state', {}),
|
||||
('progress', 'Progress', {}),
|
||||
('progress_percentage', 'Progress percent',
|
||||
{'unit_of_measurement': '%', 'state_class': 'measurement'}),
|
||||
('completion_time', 'Completion time', {'icon': 'mdi:timer-sand'}),
|
||||
('completion_minutes', 'Remaining minutes',
|
||||
{'unit_of_measurement': 'min', 'device_class': 'duration',
|
||||
'state_class': 'measurement'}),
|
||||
('delay_end_time', 'Delay end time', {'icon': 'mdi:timer'}),
|
||||
('power_state', 'Power state', {}),
|
||||
('power_watts', 'Power',
|
||||
{'unit_of_measurement': 'W', 'device_class': 'power',
|
||||
'state_class': 'measurement'}),
|
||||
('energy_kwh', 'Energy',
|
||||
{'unit_of_measurement': 'kWh', 'device_class': 'energy',
|
||||
'state_class': 'total_increasing'}),
|
||||
('dryer_mode', 'Dryer mode', {}),
|
||||
('dry_level', 'Dry level', {}),
|
||||
('dry_time', 'Dry time', {}),
|
||||
('dryer_type', 'Dryer type', {}),
|
||||
('wrinkle_prevent', 'Wrinkle prevent', {}),
|
||||
('diagnosis', 'Diagnosis', {}),
|
||||
('country_code', 'Country code', {}),
|
||||
]
|
||||
|
||||
# (key, friendly name, value_template, device_class)
|
||||
_BINARY_SENSORS = [
|
||||
('running', 'Running',
|
||||
"{{ 'ON' if value_json.machine_state == 'active' else 'OFF' }}",
|
||||
'running'),
|
||||
('power_switch', 'Power switch',
|
||||
"{{ 'ON' if value_json.power_state_binary else 'OFF' }}",
|
||||
'power'),
|
||||
('child_lock_active', 'Child lock',
|
||||
"{{ 'ON' if value_json.child_lock_binary else 'OFF' }}",
|
||||
'lock'),
|
||||
('remote_control_enabled', 'Remote control',
|
||||
"{{ 'ON' if value_json.remote_control_binary else 'OFF' }}",
|
||||
'connectivity'),
|
||||
]
|
||||
|
||||
# MQTT command-topic suffixes. The bridge subscribes to <prefix>/cmd/#
|
||||
# and dispatches by suffix.
|
||||
CMD_WRINKLE_PREVENT = 'cmd/wrinkle_prevent'
|
||||
CMD_OPERATIONAL = 'cmd/operational_state'
|
||||
CMD_DRYER_MODE = 'cmd/dryer_mode'
|
||||
|
||||
|
||||
def build_discovery(topic_prefix, ha_prefix, device_name):
|
||||
"""Return list of (discovery_topic, payload_bytes) tuples ready to
|
||||
publish (retained) on MQTT connect."""
|
||||
state_topic = f"{topic_prefix}/state"
|
||||
avail_topic = f"{topic_prefix}/availability"
|
||||
remote_topic = f"{topic_prefix}/remote_available"
|
||||
dev = device_block(topic_prefix, device_name, MODEL)
|
||||
out = []
|
||||
|
||||
# read-only sensors
|
||||
for key, name, extra in _SENSORS:
|
||||
cfg = {
|
||||
'name': name,
|
||||
'unique_id': f"{topic_prefix}_{key}",
|
||||
'object_id': f"{topic_prefix}_{key}",
|
||||
'state_topic': state_topic,
|
||||
'value_template': f"{{{{ value_json.{key} }}}}",
|
||||
'availability': avail_base(avail_topic),
|
||||
'device': dev,
|
||||
}
|
||||
cfg.update(extra)
|
||||
out.append((f"{ha_prefix}/sensor/{topic_prefix}/{key}/config",
|
||||
encode(cfg)))
|
||||
|
||||
for key, name, template, dclass in _BINARY_SENSORS:
|
||||
cfg = {
|
||||
'name': name,
|
||||
'unique_id': f"{topic_prefix}_{key}",
|
||||
'object_id': f"{topic_prefix}_{key}",
|
||||
'state_topic': state_topic,
|
||||
'value_template': template,
|
||||
'payload_on': 'ON',
|
||||
'payload_off': 'OFF',
|
||||
'device_class': dclass,
|
||||
'availability': avail_base(avail_topic),
|
||||
'device': dev,
|
||||
}
|
||||
out.append((f"{ha_prefix}/binary_sensor/{topic_prefix}/{key}/config",
|
||||
encode(cfg)))
|
||||
|
||||
# switch: wrinkle prevent (always available)
|
||||
cfg = {
|
||||
'name': 'Wrinkle prevent',
|
||||
'unique_id': f"{topic_prefix}_wrinkle_prevent_switch",
|
||||
'object_id': f"{topic_prefix}_wrinkle_prevent_switch",
|
||||
'state_topic': state_topic,
|
||||
'value_template': '{{ value_json.wrinkle_prevent }}',
|
||||
'state_on': 'On',
|
||||
'state_off': 'Off',
|
||||
'command_topic': f"{topic_prefix}/{CMD_WRINKLE_PREVENT}",
|
||||
'payload_on': 'On',
|
||||
'payload_off': 'Off',
|
||||
'icon': 'mdi:iron',
|
||||
'availability': avail_base(avail_topic),
|
||||
'device': dev,
|
||||
}
|
||||
out.append((f"{ha_prefix}/switch/{topic_prefix}/wrinkle_prevent/config",
|
||||
encode(cfg)))
|
||||
|
||||
# buttons: Start / Pause / Stop (gated on remote control)
|
||||
buttons = [
|
||||
('start', 'Start cycle', 'Run', 'mdi:play'),
|
||||
('pause', 'Pause cycle', 'Pause', 'mdi:pause'),
|
||||
('stop', 'Stop cycle', 'Ready', 'mdi:stop'),
|
||||
]
|
||||
for key, name, payload_press, icon in buttons:
|
||||
cfg = {
|
||||
'name': name,
|
||||
'unique_id': f"{topic_prefix}_{key}",
|
||||
'object_id': f"{topic_prefix}_{key}",
|
||||
'command_topic': f"{topic_prefix}/{CMD_OPERATIONAL}",
|
||||
'payload_press': payload_press,
|
||||
'icon': icon,
|
||||
'availability': avail_with_remote(avail_topic, remote_topic),
|
||||
'availability_mode': 'all',
|
||||
'device': dev,
|
||||
}
|
||||
out.append((f"{ha_prefix}/button/{topic_prefix}/{key}/config",
|
||||
encode(cfg)))
|
||||
|
||||
# select: course (gated on remote control)
|
||||
cfg = {
|
||||
'name': 'Course',
|
||||
'unique_id': f"{topic_prefix}_course_select",
|
||||
'object_id': f"{topic_prefix}_course_select",
|
||||
'state_topic': state_topic,
|
||||
'value_template': '{{ value_json.dryer_mode }}',
|
||||
'command_topic': f"{topic_prefix}/{CMD_DRYER_MODE}",
|
||||
'options': _course_options(),
|
||||
'icon': 'mdi:tumble-dryer',
|
||||
'availability': avail_with_remote(avail_topic, remote_topic),
|
||||
'availability_mode': 'all',
|
||||
'device': dev,
|
||||
}
|
||||
out.append((f"{ha_prefix}/select/{topic_prefix}/course/config",
|
||||
encode(cfg)))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# --- MQTT command handlers --------------------------------------------
|
||||
def command_handlers():
|
||||
"""topic_suffix → fn(payload, links) → (path_segs, body_dict) | None.
|
||||
|
||||
`None` means refuse the command (caller logs & drops). Dryer
|
||||
handlers don't need the links snapshot — they're all single-field
|
||||
writes."""
|
||||
def _wrinkle(p, _links):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p}
|
||||
|
||||
def _operational(p, _links):
|
||||
if p not in ('Run', 'Pause', 'Ready'):
|
||||
return None
|
||||
return ['operational', 'state', 'vs', '0'], {'x.com.samsung.da.state': p}
|
||||
|
||||
def _course(p, _links):
|
||||
code = _encode_course(p)
|
||||
if code is None:
|
||||
return None
|
||||
return ['st', 'dryercourse', 'vs', '0'], {'x.com.samsung.da.st.dryerMode': code}
|
||||
|
||||
return {
|
||||
CMD_WRINKLE_PREVENT: _wrinkle,
|
||||
CMD_OPERATIONAL: _operational,
|
||||
CMD_DRYER_MODE: _course,
|
||||
}
|
||||
|
||||
|
||||
# --- Descriptor --------------------------------------------------------
|
||||
DRYER = ApplianceDescriptor(
|
||||
name='dryer',
|
||||
default_observe_port=49155,
|
||||
observe_paths=OBSERVE_PATHS,
|
||||
seed_path=['device', '0'],
|
||||
flatten=flatten,
|
||||
build_discovery=build_discovery,
|
||||
command_handlers=command_handlers,
|
||||
on_observation=on_observation,
|
||||
project=project,
|
||||
remote_available_field='remote_control_binary',
|
||||
log_state_change=log_state_change,
|
||||
)
|
||||
@@ -0,0 +1,665 @@
|
||||
"""Oven descriptor (Samsung NV7000BS-class).
|
||||
|
||||
Resource map captured 2026-05-31 via DTLS-CoAP with the ab0b0ac4 cert.
|
||||
See `local-tools/comparisons/oven-tree.md` for the full field reference.
|
||||
|
||||
Write surfaces this descriptor exposes:
|
||||
|
||||
proven:
|
||||
* UpperLamp via /mode/vs/0 options RMW (probe_oven_lamp_toggle.py)
|
||||
— works even with Remote Control off.
|
||||
|
||||
unproven (first HA use is also the test):
|
||||
* Sound, FastPreheat — same RMW pattern as lamp.
|
||||
* Setpoint via /temperatures/vs/0 items RMW. Mid-cook write may
|
||||
or may not retune the element (plan §K-U #2).
|
||||
* Mode select via /mode/vs/0 .modes — mid-cook acceptance unknown
|
||||
(plan §K-U #3).
|
||||
* Power on/off via /power/vs/0.
|
||||
* Stop via /operational/state/vs/0 (dryer convention; oven may
|
||||
use a different state value).
|
||||
|
||||
Untested writes are gated behind <prefix>/remote_available so HA
|
||||
disables them in the UI when the oven's Remote Control switch is off."""
|
||||
import time
|
||||
|
||||
from .base import (
|
||||
ApplianceDescriptor,
|
||||
avail_base,
|
||||
avail_with_remote,
|
||||
device_block,
|
||||
encode,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# OBSERVE paths — every push-eligible /<x>/vs/0 resource on the oven.
|
||||
# Same wedge-safety story as the dryer: only `/<x>/vs/0` siblings push;
|
||||
# OCF-standard `/<x>/0` paths accept registration but never fire.
|
||||
# Security paths (/oic/sec/{doxm,pstat,acl,cred}) are deliberately
|
||||
# EXCLUDED — those are the surfaces that nearly bricked the oven in
|
||||
# prior sessions. The bridge has no reason to touch them.
|
||||
# ---------------------------------------------------------------------
|
||||
OBSERVE_PATHS = [
|
||||
['operational', 'state', 'vs', '0'], # state, time, progress
|
||||
['power', 'vs', '0'], # power On/Off
|
||||
['oven', 'vs', '0'], # cavity state (Cooking, Idle, …)
|
||||
['temperatures','vs', '0'], # current + desired temp
|
||||
['doors', 'vs', '0'], # openState
|
||||
['kidslock', 'vs', '0'], # child lock
|
||||
['remotectrl', 'vs', '0'], # remote control enabled
|
||||
['mode', 'vs', '0'], # cooking mode + options array
|
||||
['alarms', 'vs', '0'], # alarm code (OV_E_OFF etc.)
|
||||
['connected', 'vs', '0'], # cloud connectivity status
|
||||
['otninformation', 'vs', '0'], # firmware-update flags
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Mode dropdown — supportedModes minus the explicitly NotSupported one.
|
||||
# Order matches the oven's own supportedModes list so the dropdown
|
||||
# matches the device UI's order.
|
||||
# ---------------------------------------------------------------------
|
||||
SUPPORTED_MODES = [
|
||||
'Autocook',
|
||||
'Convection',
|
||||
'TopHeatPluseConvection',
|
||||
'Conventional',
|
||||
'LargeGrill',
|
||||
'SmallGrill',
|
||||
'BottomHeatPluseConvection',
|
||||
'PlateWarm',
|
||||
'KeepWarm',
|
||||
'Bottom',
|
||||
'EcoConvection',
|
||||
'FanGrill',
|
||||
'Defrost',
|
||||
# 'SteamClean', # control=NotSupported per modeSpec — exclude.
|
||||
]
|
||||
|
||||
|
||||
# Setpoint bounds — union across modeSpec entries on this oven. Per-mode
|
||||
# bounds (e.g. PlateWarm 30–80) tighten this; the firmware will refuse
|
||||
# out-of-range writes for the active mode and the HA UI will surface
|
||||
# the resulting 4.xx in the bridge log.
|
||||
SETPOINT_MIN_C = 30
|
||||
SETPOINT_MAX_C = 270
|
||||
SETPOINT_STEP_C = 5
|
||||
|
||||
|
||||
# Samsung's operational state strings → OCF currentMachineState shape.
|
||||
_SAMSUNG_STATE_TO_OCF = {
|
||||
'Ready': 'idle',
|
||||
'Run': 'active',
|
||||
'Running': 'active',
|
||||
'Pause': 'pause',
|
||||
'Paused': 'pause',
|
||||
'End': 'idle',
|
||||
'Stop': 'idle',
|
||||
}
|
||||
|
||||
|
||||
def _num(v):
|
||||
try: return float(v)
|
||||
except (TypeError, ValueError): return None
|
||||
|
||||
|
||||
def _int(v):
|
||||
try: return int(v)
|
||||
except (TypeError, ValueError): return None
|
||||
|
||||
|
||||
def _option_value(options, prefix, default=None):
|
||||
"""Find `<prefix>_<value>` in an options array and return <value>."""
|
||||
for o in options:
|
||||
if o.startswith(prefix + '_'):
|
||||
return o.split('_', 1)[1]
|
||||
return default
|
||||
|
||||
|
||||
def _replace_in_options(options, prefix, new_value):
|
||||
"""Return a new options array with any `<prefix>_*` entry replaced
|
||||
by `<prefix>_<new_value>`. Caller must verify `options` is the live
|
||||
options array first (Samsung uses replace-not-merge on this field)."""
|
||||
return [f"{prefix}_{new_value}" if o.startswith(prefix + '_') else o
|
||||
for o in options]
|
||||
|
||||
|
||||
def _fmt_hms(seconds):
|
||||
"""Format an integer second count as `H:MM:SS`. Returns None on
|
||||
bad input so callers can leave the field null rather than emitting
|
||||
a misleading `0:00:00`."""
|
||||
try:
|
||||
s = int(seconds)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if s < 0:
|
||||
s = 0
|
||||
h, rest = divmod(s, 3600)
|
||||
m, sec = divmod(rest, 60)
|
||||
return f"{h}:{m:02d}:{sec:02d}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# flatten — Samsung /device/0 links → HA-flavoured sensor dict.
|
||||
# Every field reads from `/<x>/vs/0` paths so push updates immediately
|
||||
# drive every entity. Where a field is settable (lamp, mode, setpoint),
|
||||
# we publish it as a read-side sensor here AND as a writeable entity
|
||||
# in build_discovery; the read side closes the HA UI feedback loop.
|
||||
# ---------------------------------------------------------------------
|
||||
def flatten(links):
|
||||
g = lambda href, k, default=None: (links.get(href) or {}).get(k, default)
|
||||
|
||||
# Operational
|
||||
sam_state = g('/operational/state/vs/0', 'x.com.samsung.da.state')
|
||||
machine_state = (_SAMSUNG_STATE_TO_OCF.get(sam_state, sam_state)
|
||||
if sam_state is not None else None)
|
||||
|
||||
operation_time = g('/operational/state/vs/0',
|
||||
'x.com.samsung.da.operationTime')
|
||||
remaining = g('/operational/state/vs/0',
|
||||
'x.com.samsung.da.remainingTime')
|
||||
rem_min = None
|
||||
if remaining:
|
||||
try:
|
||||
h, m, s = remaining.split(':')
|
||||
rem_min = int(h) * 60 + int(m) + (1 if int(s) > 0 else 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Cavity state — Cooking, Idle, Preheating, …
|
||||
oven_state = g('/oven/vs/0', 'x.com.samsung.da.state')
|
||||
|
||||
# Temperatures
|
||||
temps_items = (g('/temperatures/vs/0',
|
||||
'x.com.samsung.da.items') or [])
|
||||
cur_c = des_c = None
|
||||
if temps_items:
|
||||
cur_c = _int(temps_items[0].get('x.com.samsung.da.current'))
|
||||
des_c = _int(temps_items[0].get('x.com.samsung.da.desired'))
|
||||
|
||||
# Door
|
||||
doors_items = g('/doors/vs/0', 'x.com.samsung.da.items') or []
|
||||
door = doors_items[0].get('x.com.samsung.da.openState') if doors_items else None
|
||||
door_open = (door == 'Open') if door is not None else None
|
||||
|
||||
# Power
|
||||
sam_power = g('/power/vs/0', 'x.com.samsung.da.power')
|
||||
power_bin = (sam_power == 'On') if sam_power is not None else None
|
||||
|
||||
# Kidslock + Remote
|
||||
sam_kids = g('/kidslock/vs/0', 'x.com.samsung.da.kidsLock')
|
||||
kids_bin = (sam_kids != 'Ready') if sam_kids is not None else None
|
||||
sam_rc = g('/remotectrl/vs/0',
|
||||
'x.com.samsung.da.remoteControlEnabled')
|
||||
rc_bin = (str(sam_rc).lower() == 'true') if sam_rc is not None else None
|
||||
|
||||
# Mode + options
|
||||
modes = g('/mode/vs/0', 'x.com.samsung.da.modes') or []
|
||||
current_mode = modes[0] if modes else None
|
||||
options = g('/mode/vs/0', 'x.com.samsung.da.options') or []
|
||||
lamp = _option_value(options, 'UpperLamp') # 'On' / 'Off'
|
||||
sound = _option_value(options, 'Sound') # 'On' / 'Off'
|
||||
fastpreheat = _option_value(options, 'fastpreheat') # 'On' / 'Off'
|
||||
timer_state = _option_value(options, 'UpperTimerState') # 'Ready' / 'Running'
|
||||
# UpperTimerCurrent/UpperTimerSet are integer seconds. Format as
|
||||
# H:MM:SS for HA display so users see "1:10:00", not "4200".
|
||||
timer_current_raw = _option_value(options, 'UpperTimerCurrent')
|
||||
timer_set_raw = _option_value(options, 'UpperTimerSet')
|
||||
timer_current = _fmt_hms(timer_current_raw)
|
||||
timer_set = _fmt_hms(timer_set_raw)
|
||||
timer_current_seconds = _int(timer_current_raw)
|
||||
timer_set_seconds = _int(timer_set_raw)
|
||||
|
||||
# Alarms
|
||||
alarm_items = g('/alarms/vs/0', 'x.com.samsung.da.items') or []
|
||||
alarm_code = (alarm_items[0].get('x.com.samsung.da.code')
|
||||
if alarm_items else None)
|
||||
alarm_time = (alarm_items[0].get('x.com.samsung.da.triggeredTime')
|
||||
if alarm_items else None)
|
||||
# OV_E_OFF appears when the oven is off / no alarm; treat as inactive.
|
||||
alarm_active = bool(alarm_code) and alarm_code != 'OV_E_OFF'
|
||||
|
||||
# Connectivity / firmware
|
||||
sam_connected = g('/connected/vs/0', 'x.com.samsung.da.connected')
|
||||
connected_bin = (sam_connected == 'On') if sam_connected is not None else None
|
||||
fw_update_available = g('/otninformation/vs/0',
|
||||
'x.com.samsung.da.newVersionAvailable')
|
||||
fw_update_bin = (str(fw_update_available).lower() == 'true'
|
||||
if fw_update_available is not None else None)
|
||||
|
||||
return {
|
||||
'machine_state': machine_state,
|
||||
'oven_state': oven_state,
|
||||
'progress_percentage': _int(g('/operational/state/vs/0',
|
||||
'x.com.samsung.da.progressPercentage')),
|
||||
'operation_time': operation_time,
|
||||
'completion_time': remaining,
|
||||
'completion_minutes': rem_min,
|
||||
'current_temp_c': cur_c,
|
||||
'target_temp_c': des_c,
|
||||
'door': door,
|
||||
'door_open': door_open,
|
||||
'power_state': sam_power,
|
||||
'power_state_binary': power_bin,
|
||||
'child_lock': sam_kids,
|
||||
'child_lock_binary': kids_bin,
|
||||
'remote_control': sam_rc,
|
||||
'remote_control_binary': rc_bin,
|
||||
'mode': current_mode,
|
||||
'lamp': lamp,
|
||||
'sound': sound,
|
||||
'fastpreheat': fastpreheat,
|
||||
'timer_state': timer_state,
|
||||
'timer_current': timer_current,
|
||||
'timer_set': timer_set,
|
||||
'timer_current_seconds': timer_current_seconds,
|
||||
'timer_set_seconds': timer_set_seconds,
|
||||
'alarm_code': alarm_code,
|
||||
'alarm_time': alarm_time,
|
||||
'alarm_active': alarm_active,
|
||||
'connected': sam_connected,
|
||||
'connected_binary': connected_bin,
|
||||
'firmware_update_available': fw_update_bin,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Remaining-time anchor + projection. The oven pushes /operational/state
|
||||
# on state transitions but probably not on remainingTime ticks (matches
|
||||
# dryer behaviour). Capture (ts, total_seconds) at each push and
|
||||
# extrapolate downward while machine_state == active.
|
||||
# ---------------------------------------------------------------------
|
||||
def on_observation(state, href, rep):
|
||||
if href != '/operational/state/vs/0':
|
||||
return
|
||||
rem = rep.get('x.com.samsung.da.remainingTime')
|
||||
if not isinstance(rem, str):
|
||||
return
|
||||
try:
|
||||
h, m, s = rem.split(':')
|
||||
state['remaining_anchor'] = (time.time(),
|
||||
int(h) * 3600 + int(m) * 60 + int(s))
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
|
||||
def project(state, sensors):
|
||||
anchor = state.get('remaining_anchor')
|
||||
if sensors.get('machine_state') != 'active' or anchor is None:
|
||||
return sensors
|
||||
ts, total = anchor
|
||||
remaining = max(0, int(total - (time.time() - ts)))
|
||||
h, rest = divmod(remaining, 3600)
|
||||
m, s = divmod(rest, 60)
|
||||
sensors = dict(sensors)
|
||||
sensors['completion_time'] = f"{h}:{m:02d}:{s:02d}"
|
||||
sensors['completion_minutes'] = h * 60 + m + (1 if s > 0 else 0)
|
||||
return sensors
|
||||
|
||||
|
||||
def log_state_change(sensors):
|
||||
return (f"machine={sensors.get('machine_state')} "
|
||||
f"oven={sensors.get('oven_state')} "
|
||||
f"temp={sensors.get('current_temp_c')}/"
|
||||
f"{sensors.get('target_temp_c')}°C "
|
||||
f"mode={sensors.get('mode')}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# HA discovery inventory
|
||||
# ---------------------------------------------------------------------
|
||||
MODEL = 'OCF oven (TizenRT-iotivity, NV7000BS-class)'
|
||||
|
||||
# (key, friendly name, extra config)
|
||||
#
|
||||
# Only read-only sensors live here. Fields that ALSO have an
|
||||
# interactive entity (light, switch, number, select) are removed —
|
||||
# the interactive entity already surfaces the live state, so a
|
||||
# duplicate read-only "Lamp state" / "Fast preheat state" / etc.
|
||||
# sensor would just clutter the device card with the same value
|
||||
# twice.
|
||||
_SENSORS = [
|
||||
('machine_state', 'Machine state', {'icon': 'mdi:stove'}),
|
||||
('oven_state', 'Cavity state', {}),
|
||||
('progress_percentage', 'Progress percent',
|
||||
{'unit_of_measurement': '%', 'state_class': 'measurement'}),
|
||||
('operation_time', 'Elapsed time', {'icon': 'mdi:timer'}),
|
||||
('completion_time', 'Completion time', {'icon': 'mdi:timer-sand'}),
|
||||
('completion_minutes', 'Remaining minutes',
|
||||
{'unit_of_measurement': 'min', 'device_class': 'duration',
|
||||
'state_class': 'measurement'}),
|
||||
('current_temp_c', 'Temperature',
|
||||
{'unit_of_measurement': '°C', 'device_class': 'temperature',
|
||||
'state_class': 'measurement'}),
|
||||
# target_temp_c is also exposed as a Number entity for editing,
|
||||
# but the Number is RC-gated. The sensor stays always-visible so
|
||||
# the user can see the current setpoint even with Remote Control
|
||||
# off at the oven.
|
||||
('target_temp_c', 'Setpoint',
|
||||
{'unit_of_measurement': '°C', 'device_class': 'temperature',
|
||||
'state_class': 'measurement', 'icon': 'mdi:thermometer-chevron-up'}),
|
||||
# power_state: read-only. The oven doesn't expose a meaningful
|
||||
# POST /power/vs/0 from cold — turning the unit on at the panel
|
||||
# is a physical action — so we don't ship a Power switch entity.
|
||||
('power_state', 'Power state', {'icon': 'mdi:power'}),
|
||||
('door', 'Door state', {}),
|
||||
('child_lock', 'Child lock state', {}),
|
||||
('remote_control', 'Remote control state', {}),
|
||||
('timer_state', 'Timer state', {}),
|
||||
('timer_current', 'Timer remaining', {'icon': 'mdi:timer-sand'}),
|
||||
('timer_set', 'Timer set', {'icon': 'mdi:timer'}),
|
||||
('alarm_code', 'Alarm code',
|
||||
{'icon': 'mdi:alert', 'entity_category': 'diagnostic'}),
|
||||
('alarm_time', 'Alarm time',
|
||||
{'icon': 'mdi:clock-alert', 'entity_category': 'diagnostic'}),
|
||||
('connected', 'Cloud connectivity',
|
||||
{'entity_category': 'diagnostic'}),
|
||||
]
|
||||
|
||||
# (key, friendly, value_template, device_class, extras)
|
||||
_BINARY_SENSORS = [
|
||||
('running', 'Running',
|
||||
"{{ 'ON' if value_json.machine_state == 'active' else 'OFF' }}",
|
||||
'running', {}),
|
||||
('door_open', 'Door',
|
||||
"{{ 'ON' if value_json.door_open else 'OFF' }}",
|
||||
'door', {}),
|
||||
# `power_switch` binary_sensor would duplicate the Power switch
|
||||
# entity below; the switch already shows on/off state.
|
||||
('child_lock_active', 'Child lock',
|
||||
"{{ 'ON' if value_json.child_lock_binary else 'OFF' }}",
|
||||
'lock', {}),
|
||||
('remote_control_enabled', 'Remote control',
|
||||
"{{ 'ON' if value_json.remote_control_binary else 'OFF' }}",
|
||||
'connectivity', {}),
|
||||
('alarm_active', 'Alarm active',
|
||||
"{{ 'ON' if value_json.alarm_active else 'OFF' }}",
|
||||
'problem', {}),
|
||||
('connected_bin', 'Connected',
|
||||
"{{ 'ON' if value_json.connected_binary else 'OFF' }}",
|
||||
'connectivity', {'entity_category': 'diagnostic'}),
|
||||
('firmware_update_available', 'Firmware update available',
|
||||
"{{ 'ON' if value_json.firmware_update_available else 'OFF' }}",
|
||||
'update', {'entity_category': 'diagnostic'}),
|
||||
]
|
||||
|
||||
|
||||
# MQTT command-topic suffixes (under <prefix>/cmd/…)
|
||||
CMD_LAMP = 'cmd/lamp'
|
||||
CMD_SOUND = 'cmd/sound'
|
||||
CMD_FASTPREHEAT = 'cmd/fastpreheat'
|
||||
CMD_POWER = 'cmd/power'
|
||||
CMD_STOP = 'cmd/stop'
|
||||
CMD_MODE = 'cmd/mode'
|
||||
CMD_SETPOINT = 'cmd/setpoint'
|
||||
|
||||
|
||||
def build_discovery(topic_prefix, ha_prefix, device_name):
|
||||
state_topic = f"{topic_prefix}/state"
|
||||
avail_topic = f"{topic_prefix}/availability"
|
||||
remote_topic = f"{topic_prefix}/remote_available"
|
||||
dev = device_block(topic_prefix, device_name, MODEL)
|
||||
out = []
|
||||
|
||||
# --- read-only sensors -------------------------------------------
|
||||
for key, name, extra in _SENSORS:
|
||||
cfg = {
|
||||
'name': name,
|
||||
'unique_id': f"{topic_prefix}_{key}",
|
||||
'object_id': f"{topic_prefix}_{key}",
|
||||
'state_topic': state_topic,
|
||||
'value_template': f"{{{{ value_json.{key} }}}}",
|
||||
'availability': avail_base(avail_topic),
|
||||
'device': dev,
|
||||
}
|
||||
cfg.update(extra)
|
||||
out.append((f"{ha_prefix}/sensor/{topic_prefix}/{key}/config",
|
||||
encode(cfg)))
|
||||
|
||||
for key, name, template, dclass, extra in _BINARY_SENSORS:
|
||||
cfg = {
|
||||
'name': name,
|
||||
'unique_id': f"{topic_prefix}_{key}",
|
||||
'object_id': f"{topic_prefix}_{key}",
|
||||
'state_topic': state_topic,
|
||||
'value_template': template,
|
||||
'payload_on': 'ON',
|
||||
'payload_off': 'OFF',
|
||||
'device_class': dclass,
|
||||
'availability': avail_base(avail_topic),
|
||||
'device': dev,
|
||||
}
|
||||
cfg.update(extra)
|
||||
out.append((f"{ha_prefix}/binary_sensor/{topic_prefix}/{key}/config",
|
||||
encode(cfg)))
|
||||
|
||||
# --- light: oven lamp (proven via probe_oven_lamp_states.py;
|
||||
# binary On/Off only — High/Low/Dim coerce back to previous
|
||||
# state. Works regardless of Remote Control switch, so we only
|
||||
# gate on base availability). For the MQTT light default schema,
|
||||
# state_value_template's output must match payload_on/payload_off
|
||||
# exactly (case-sensitive) for HA to recognise the state.
|
||||
cfg = {
|
||||
'name': 'Lamp',
|
||||
'unique_id': f"{topic_prefix}_lamp_light",
|
||||
'object_id': f"{topic_prefix}_lamp_light",
|
||||
'state_topic': state_topic,
|
||||
'state_value_template': "{{ value_json.lamp }}",
|
||||
'command_topic': f"{topic_prefix}/{CMD_LAMP}",
|
||||
'payload_on': 'On',
|
||||
'payload_off': 'Off',
|
||||
'icon': 'mdi:track-light',
|
||||
'availability': avail_base(avail_topic),
|
||||
'device': dev,
|
||||
}
|
||||
out.append((f"{ha_prefix}/light/{topic_prefix}/lamp/config",
|
||||
encode(cfg)))
|
||||
|
||||
# --- switches (RC-gated; untested mid-cook). Sound + fastpreheat
|
||||
# are options-array writes (same RMW path as lamp); Power is a
|
||||
# /power/vs/0 single-field write. --------------------------------
|
||||
untested_switches = [
|
||||
('sound', 'Sound', '{{ value_json.sound }}', CMD_SOUND, 'mdi:volume-high'),
|
||||
('fastpreheat', 'Fast preheat', '{{ value_json.fastpreheat }}', CMD_FASTPREHEAT, 'mdi:fire'),
|
||||
# Power deliberately omitted: turning the oven on at the
|
||||
# cold-start panel is a physical action; the read-only
|
||||
# power_state sensor (above) reflects its state.
|
||||
]
|
||||
for key, name, tpl, cmd, icon in untested_switches:
|
||||
cfg = {
|
||||
'name': name,
|
||||
'unique_id': f"{topic_prefix}_{key}_switch",
|
||||
'object_id': f"{topic_prefix}_{key}_switch",
|
||||
'state_topic': state_topic,
|
||||
'value_template': tpl,
|
||||
'state_on': 'On',
|
||||
'state_off': 'Off',
|
||||
'command_topic': f"{topic_prefix}/{cmd}",
|
||||
'payload_on': 'On',
|
||||
'payload_off': 'Off',
|
||||
'icon': icon,
|
||||
'availability': avail_with_remote(avail_topic, remote_topic),
|
||||
'availability_mode': 'all',
|
||||
'device': dev,
|
||||
}
|
||||
out.append((f"{ha_prefix}/switch/{topic_prefix}/{key}/config",
|
||||
encode(cfg)))
|
||||
|
||||
# --- number: setpoint (RC-gated, slider input) ------------------
|
||||
cfg = {
|
||||
'name': 'Setpoint',
|
||||
'unique_id': f"{topic_prefix}_setpoint",
|
||||
'object_id': f"{topic_prefix}_setpoint",
|
||||
'state_topic': state_topic,
|
||||
'value_template': '{{ value_json.target_temp_c }}',
|
||||
'command_topic': f"{topic_prefix}/{CMD_SETPOINT}",
|
||||
'min': SETPOINT_MIN_C,
|
||||
'max': SETPOINT_MAX_C,
|
||||
'step': SETPOINT_STEP_C,
|
||||
'unit_of_measurement': '°C',
|
||||
'device_class': 'temperature',
|
||||
'mode': 'slider',
|
||||
'icon': 'mdi:thermometer-chevron-up',
|
||||
'availability': avail_with_remote(avail_topic, remote_topic),
|
||||
'availability_mode': 'all',
|
||||
'device': dev,
|
||||
}
|
||||
out.append((f"{ha_prefix}/number/{topic_prefix}/setpoint/config",
|
||||
encode(cfg)))
|
||||
|
||||
# --- select: mode (RC-gated) ------------------------------------
|
||||
cfg = {
|
||||
'name': 'Cooking mode',
|
||||
'unique_id': f"{topic_prefix}_mode_select",
|
||||
'object_id': f"{topic_prefix}_mode_select",
|
||||
'state_topic': state_topic,
|
||||
'value_template': '{{ value_json.mode }}',
|
||||
'command_topic': f"{topic_prefix}/{CMD_MODE}",
|
||||
'options': SUPPORTED_MODES,
|
||||
'icon': 'mdi:tune',
|
||||
'availability': avail_with_remote(avail_topic, remote_topic),
|
||||
'availability_mode': 'all',
|
||||
'device': dev,
|
||||
}
|
||||
out.append((f"{ha_prefix}/select/{topic_prefix}/mode/config",
|
||||
encode(cfg)))
|
||||
|
||||
# --- button: Stop cycle ----------------------------------------
|
||||
# NOT gated on remote_available: the SmartThings app stops the
|
||||
# oven regardless of the Remote Control switch state, so the
|
||||
# device clearly honours Stop without that gate. Only requires
|
||||
# the bridge itself to be online.
|
||||
cfg = {
|
||||
'name': 'Stop cycle',
|
||||
'unique_id': f"{topic_prefix}_stop",
|
||||
'object_id': f"{topic_prefix}_stop",
|
||||
'command_topic': f"{topic_prefix}/{CMD_STOP}",
|
||||
'payload_press': 'Stop',
|
||||
'icon': 'mdi:stop',
|
||||
'availability': avail_base(avail_topic),
|
||||
'device': dev,
|
||||
}
|
||||
out.append((f"{ha_prefix}/button/{topic_prefix}/stop/config",
|
||||
encode(cfg)))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Command handlers — fn(payload, links) → (path_segs, body_dict) | None.
|
||||
# Read-modify-write handlers (lamp/sound/fastpreheat) snapshot the
|
||||
# `/mode/vs/0` options array and replace just their slot. /temperatures
|
||||
# is also RMW because Samsung's write semantics on the items array are
|
||||
# replace-not-merge.
|
||||
# ---------------------------------------------------------------------
|
||||
def _mode_options(links):
|
||||
"""Return the live `/mode/vs/0` options array (a copy), or None
|
||||
if /mode/vs/0 isn't seeded yet."""
|
||||
rep = links.get('/mode/vs/0') or {}
|
||||
opts = rep.get('x.com.samsung.da.options')
|
||||
if not opts:
|
||||
return None
|
||||
return list(opts)
|
||||
|
||||
|
||||
def _temps_items(links):
|
||||
"""Return a deep-ish copy of the /temperatures/vs/0 items array."""
|
||||
rep = links.get('/temperatures/vs/0') or {}
|
||||
items = rep.get('x.com.samsung.da.items') or []
|
||||
return [dict(it) for it in items] if items else None
|
||||
|
||||
|
||||
def command_handlers():
|
||||
def _lamp(p, links):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
opts = _mode_options(links)
|
||||
if opts is None:
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _replace_in_options(opts, 'UpperLamp', p),
|
||||
}
|
||||
|
||||
def _sound(p, links):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
opts = _mode_options(links)
|
||||
if opts is None:
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _replace_in_options(opts, 'Sound', p),
|
||||
}
|
||||
|
||||
def _fastpreheat(p, links):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
opts = _mode_options(links)
|
||||
if opts is None:
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _replace_in_options(
|
||||
opts, 'fastpreheat', p),
|
||||
}
|
||||
|
||||
def _power(p, _links):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
return ['power', 'vs', '0'], {'x.com.samsung.da.power': p}
|
||||
|
||||
def _stop(_p, _links):
|
||||
# Untested for the oven. Dryer convention is state='Ready' to
|
||||
# leave the cycle in idle. If this turns out to be wrong, the
|
||||
# bridge will log the 4.xx but the oven won't be harmed —
|
||||
# /operational/state/vs/0 isn't a wedge-trigger surface.
|
||||
return ['operational', 'state', 'vs', '0'], {
|
||||
'x.com.samsung.da.state': 'Ready',
|
||||
}
|
||||
|
||||
def _mode(p, _links):
|
||||
if p not in SUPPORTED_MODES:
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]}
|
||||
|
||||
def _setpoint(p, links):
|
||||
try:
|
||||
temp = float(p)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
# Snap to step and bounds.
|
||||
temp_i = int(round(temp / SETPOINT_STEP_C) * SETPOINT_STEP_C)
|
||||
if not (SETPOINT_MIN_C <= temp_i <= SETPOINT_MAX_C):
|
||||
return None
|
||||
items = _temps_items(links)
|
||||
if items is None:
|
||||
return None
|
||||
items[0]['x.com.samsung.da.desired'] = str(temp_i)
|
||||
return ['temperatures', 'vs', '0'], {
|
||||
'x.com.samsung.da.items': items,
|
||||
}
|
||||
|
||||
return {
|
||||
CMD_LAMP: _lamp,
|
||||
CMD_SOUND: _sound,
|
||||
CMD_FASTPREHEAT: _fastpreheat,
|
||||
CMD_POWER: _power,
|
||||
CMD_STOP: _stop,
|
||||
CMD_MODE: _mode,
|
||||
CMD_SETPOINT: _setpoint,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
OVEN = ApplianceDescriptor(
|
||||
name='oven',
|
||||
default_observe_port=49154,
|
||||
observe_paths=OBSERVE_PATHS,
|
||||
seed_path=['device', '0'],
|
||||
flatten=flatten,
|
||||
build_discovery=build_discovery,
|
||||
command_handlers=command_handlers,
|
||||
on_observation=on_observation,
|
||||
project=project,
|
||||
remote_available_field='remote_control_binary',
|
||||
log_state_change=log_state_change,
|
||||
)
|
||||
@@ -0,0 +1,502 @@
|
||||
"""Push-mode bridge: OCF CoAP-DTLS Observe → MQTT.
|
||||
|
||||
Appliance ──CoAP OBSERVE notifications──► PushBridge
|
||||
│
|
||||
▼
|
||||
MQTT broker
|
||||
│
|
||||
▼
|
||||
Home Assistant
|
||||
|
||||
State changes push from the appliance over a sustained DTLS session.
|
||||
The bridge updates an in-memory link dict, recomputes flat sensors via
|
||||
the appliance descriptor, and publishes to MQTT ONLY when the flat-
|
||||
sensor dict actually changes.
|
||||
|
||||
The bridge is appliance-class-agnostic — it delegates every
|
||||
appliance-specific decision to an ApplianceDescriptor.
|
||||
|
||||
Multiple PushBridges run concurrently in a single process — see
|
||||
main.py. They share one MQTT client; each owns one DTLS session.
|
||||
"""
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
import cbor2
|
||||
|
||||
from .appliances.base import ApplianceDescriptor
|
||||
from .coap_dtls import DtlsCoapSession, fmt_code
|
||||
from .config import ApplianceConfig, SharedConfig
|
||||
from .logger import bridge_logger, logger as module_logger
|
||||
from .sensors import index_links
|
||||
|
||||
|
||||
def _href_to_segs(href: str) -> list[str]:
|
||||
"""`/mode/vs/0` → `['mode', 'vs', '0']`. Used to translate an
|
||||
OBSERVE-notification href back into the path-segs the Block2 GET
|
||||
needs."""
|
||||
return [s for s in href.split('/') if s]
|
||||
|
||||
|
||||
# Samsung's `/information/vs/0` resource carries a unique serial number.
|
||||
# Verified on both dryer (DV5000T) and oven (NV7000BS); we use the
|
||||
# value to tag per-bridge log lines once the seed completes.
|
||||
SERIAL_PATH = '/information/vs/0'
|
||||
SERIAL_FIELD = 'x.com.samsung.da.serialNum'
|
||||
|
||||
|
||||
class PushBridge:
|
||||
"""Single sustained DTLS-CoAP session to one appliance.
|
||||
|
||||
Reconnects with exponential backoff on session errors. Publishes
|
||||
availability=offline when the appliance is unreachable so HA marks
|
||||
entities unavailable instead of trusting stale state."""
|
||||
|
||||
def __init__(self,
|
||||
shared: SharedConfig,
|
||||
app: ApplianceConfig,
|
||||
descriptor: ApplianceDescriptor,
|
||||
mqtt_client):
|
||||
self.shared = shared
|
||||
self.app = app
|
||||
self.descriptor = descriptor
|
||||
self.mqtt = mqtt_client
|
||||
|
||||
# Bridge-scoped logger; retagged with serial after first seed.
|
||||
self.log = bridge_logger(app.klass)
|
||||
self._serial: str | None = None
|
||||
|
||||
# Resolve port (descriptor default if unset in env).
|
||||
self.port = app.ocf_port or descriptor.default_observe_port
|
||||
|
||||
self.session: DtlsCoapSession | None = None
|
||||
self.links: dict[str, dict] = {} # href → rep
|
||||
self.descriptor_state: dict = {} # descriptor scratch space
|
||||
|
||||
self.last_state_pub = None
|
||||
self.last_remote_pub = None
|
||||
self.stop = threading.Event()
|
||||
self.started_ts = time.time()
|
||||
self.session_started_ts = None
|
||||
self.last_change_ts = None
|
||||
self.last_seed_ts = None
|
||||
self.notif_count = 0
|
||||
self.connect_count = 0
|
||||
self.error_count = 0
|
||||
self._publish_gate = False
|
||||
|
||||
# Per-href fetchback generation counter. Every new schedule
|
||||
# bumps the gen; a fetchback aborts on wake (and again after
|
||||
# its GET completes) if its captured gen is no longer the
|
||||
# latest. This coalesces bursts: rapid lamp toggles or slider
|
||||
# drags result in many scheduled fetchbacks but only the
|
||||
# latest one actually publishes. Lock guards the dict mutation
|
||||
# and the gen comparison.
|
||||
self._fetch_gen: dict[str, int] = {}
|
||||
self._fetch_lock = threading.Lock()
|
||||
|
||||
p = app.topic_prefix
|
||||
self.state_topic = f"{p}/state"
|
||||
self.avail_topic = f"{p}/availability"
|
||||
self.remote_topic = f"{p}/remote_available"
|
||||
self.health_topic = f"{p}/bridge/health"
|
||||
self.cmd_handlers = descriptor.command_handlers()
|
||||
self.cmd_topic_prefix = f"{p}/cmd/"
|
||||
|
||||
# Pre-built HA discovery payloads. Republished on every MQTT
|
||||
# (re)connect by main.py.
|
||||
self.discovery_payloads = descriptor.build_discovery(
|
||||
app.topic_prefix, shared.HA_DISCOVERY_PREFIX, app.device_name)
|
||||
|
||||
# ---- DTLS session helpers ---------------------------------------
|
||||
|
||||
def _on_notification(self, href, payload_bytes):
|
||||
"""Invoked by the DTLS reader thread for OBSERVE notifications.
|
||||
|
||||
Resources larger than one CoAP block (notably the oven's
|
||||
`/mode/vs/0` at ~9KB) arrive truncated: Samsung sends only
|
||||
block 0 with Block2.M=1 and expects the client to fetch the
|
||||
rest via Block2 GET. We use cbor decode failure as the
|
||||
robust "this notification is partial" signal, then spawn a
|
||||
worker thread to fetch the full resource."""
|
||||
if not payload_bytes:
|
||||
# Empty payload — almost certainly a Block2 announcement.
|
||||
self._schedule_fetchback(href)
|
||||
return
|
||||
try:
|
||||
rep = cbor2.loads(payload_bytes)
|
||||
except Exception:
|
||||
self._schedule_fetchback(href)
|
||||
return
|
||||
if not isinstance(rep, dict):
|
||||
return
|
||||
self._apply_rep(href, rep)
|
||||
|
||||
def _apply_rep(self, href, rep):
|
||||
"""Update self.links + fire descriptor hooks + maybe publish.
|
||||
Shared between the OBSERVE path and the Block2 fetch-back path."""
|
||||
self.links[href] = rep
|
||||
hook = self.descriptor.on_observation
|
||||
if hook is not None:
|
||||
try:
|
||||
hook(self.descriptor_state, href, rep)
|
||||
except Exception as e:
|
||||
self.log.warning("on_observation %s: %s", href, e)
|
||||
self.notif_count += 1
|
||||
self.last_change_ts = time.time()
|
||||
self.maybe_publish_state()
|
||||
|
||||
def _apply_optimistic(self, href, body):
|
||||
"""Optimistically merge a just-POSTed body into the link dict
|
||||
and republish state. Samsung accepts (2.04) writes whose
|
||||
bodies are field-replacements — we mirror that semantics here:
|
||||
each top-level key in `body` overwrites the corresponding key
|
||||
in the existing rep. The fetchback that follows republishes
|
||||
the device's real state, which corrects any field where the
|
||||
write was silently coerced or rejected."""
|
||||
if not isinstance(body, dict):
|
||||
return
|
||||
rep = dict(self.links.get(href) or {})
|
||||
rep.update(body)
|
||||
self._apply_rep(href, rep)
|
||||
|
||||
def _schedule_fetchback(self, href, delay_s: float = 0.0):
|
||||
"""Spawn a worker thread to fetch the full payload of `href`
|
||||
via Block2 GET. Each schedule bumps a per-href generation
|
||||
counter — if a newer fetchback is scheduled before this one
|
||||
fires, this one aborts (so rapid commands coalesce into a
|
||||
single verification read of the FINAL state)."""
|
||||
with self._fetch_lock:
|
||||
gen = self._fetch_gen.get(href, 0) + 1
|
||||
self._fetch_gen[href] = gen
|
||||
threading.Thread(
|
||||
target=self._fetch_back,
|
||||
args=(href, delay_s, gen),
|
||||
daemon=True,
|
||||
name=f'fetch{href}',
|
||||
).start()
|
||||
|
||||
def _fetch_back(self, href, delay_s: float, gen: int):
|
||||
if delay_s > 0:
|
||||
# Allow the device's read-side to propagate a recent
|
||||
# write. The oven needs ~1s after a /mode/vs/0 POST;
|
||||
# the dryer is faster but the delay is harmless there.
|
||||
if self.stop.wait(delay_s):
|
||||
return
|
||||
# Has a newer fetchback been scheduled during our delay?
|
||||
# If so, abort — our read would publish stale state relative
|
||||
# to the user's most recent intent.
|
||||
with self._fetch_lock:
|
||||
if self._fetch_gen.get(href) != gen:
|
||||
return
|
||||
sess = self.session
|
||||
if sess is None:
|
||||
return
|
||||
segs = _href_to_segs(href)
|
||||
try:
|
||||
code, payload = sess.get(segs, timeout=15.0)
|
||||
except Exception as e:
|
||||
self.log.warning("fetchback %s: %s", href, e)
|
||||
return
|
||||
# Re-check generation after the GET — a new write may have
|
||||
# come in during the Block2 round-trip, in which case our
|
||||
# payload is also superseded.
|
||||
with self._fetch_lock:
|
||||
if self._fetch_gen.get(href) != gen:
|
||||
return
|
||||
if code != 0x45:
|
||||
self.log.warning("fetchback %s: %s",
|
||||
href, fmt_code(code))
|
||||
return
|
||||
try:
|
||||
rep = cbor2.loads(payload) if payload else {}
|
||||
except Exception as e:
|
||||
self.log.warning("fetchback %s cbor: %s", href, e)
|
||||
return
|
||||
if not isinstance(rep, dict):
|
||||
return
|
||||
self._apply_rep(href, rep)
|
||||
|
||||
def _retag_logger_with_serial(self):
|
||||
"""Look up the appliance's serial in the seeded link dict and
|
||||
retarget self.log to a serial-tagged child. Idempotent."""
|
||||
if self._serial is not None:
|
||||
return
|
||||
info = self.links.get(SERIAL_PATH) or {}
|
||||
serial = info.get(SERIAL_FIELD)
|
||||
if not serial:
|
||||
return
|
||||
self._serial = serial
|
||||
self.log = bridge_logger(self.app.klass, serial)
|
||||
self.log.info("identified — serial=%s", serial)
|
||||
|
||||
# ---- session lifecycle ------------------------------------------
|
||||
|
||||
def session_once(self):
|
||||
"""Run one DTLS session end-to-end. Raises on error; the outer
|
||||
run_forever wraps this with reconnect/backoff."""
|
||||
sess = DtlsCoapSession(
|
||||
self.app.ip, self.port,
|
||||
cert_path=self.shared.CERT_PATH,
|
||||
key_path=self.shared.KEY_PATH,
|
||||
on_notification=self._on_notification,
|
||||
)
|
||||
sess.connect()
|
||||
self.session = sess
|
||||
self.session_started_ts = time.time()
|
||||
self.connect_count += 1
|
||||
self.descriptor_state = {}
|
||||
self._publish_gate = False
|
||||
|
||||
self.log.info("DTLS connected — subscribing %d paths",
|
||||
len(self.descriptor.observe_paths))
|
||||
|
||||
sess.start_reader()
|
||||
|
||||
for path in self.descriptor.observe_paths:
|
||||
sess.subscribe(path)
|
||||
time.sleep(0.05)
|
||||
|
||||
code, pl = sess.get(self.descriptor.seed_path, timeout=15.0)
|
||||
if code != 0x45:
|
||||
raise RuntimeError(
|
||||
f"/{'/'.join(self.descriptor.seed_path)} -> {fmt_code(code)}")
|
||||
try:
|
||||
body = cbor2.loads(pl)
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
f"/{'/'.join(self.descriptor.seed_path)} cbor decode: {e}"
|
||||
) from e
|
||||
for href, rep in index_links(body).items():
|
||||
self.links.setdefault(href, rep)
|
||||
|
||||
# Once the seed is in, we know the appliance's serial — retag
|
||||
# the logger so the remaining log lines this session emits are
|
||||
# serial-tagged.
|
||||
self._retag_logger_with_serial()
|
||||
|
||||
hook = self.descriptor.on_observation
|
||||
if hook is not None:
|
||||
for href, rep in self.links.items():
|
||||
try:
|
||||
hook(self.descriptor_state, href, rep)
|
||||
except Exception as e:
|
||||
self.log.warning("seed on_observation %s: %s", href, e)
|
||||
self.last_seed_ts = time.time()
|
||||
|
||||
self._publish_gate = True
|
||||
self.maybe_publish_state(force=True)
|
||||
self.set_availability(True)
|
||||
self.log.info("seeded → %d links; sensors live", len(self.links))
|
||||
|
||||
sess.join()
|
||||
|
||||
def heartbeat(self):
|
||||
sess = self.session
|
||||
if sess is None:
|
||||
return
|
||||
try:
|
||||
code, pl = sess.get(self.descriptor.seed_path, timeout=15.0)
|
||||
except Exception as e:
|
||||
self.log.warning("heartbeat seed: %s", e)
|
||||
return
|
||||
if code != 0x45:
|
||||
self.log.warning("heartbeat seed: %s", fmt_code(code))
|
||||
return
|
||||
try:
|
||||
body = cbor2.loads(pl)
|
||||
except Exception as e:
|
||||
self.log.warning("heartbeat seed cbor: %s", e)
|
||||
return
|
||||
# Refresh ALL resources, not just non-observed ones. We used
|
||||
# to skip observed resources on the assumption OBSERVE kept
|
||||
# them fresh, but the oven doesn't reliably push OBSERVE on
|
||||
# /mode/vs/0 option changes (timer / lamp / sound), so we'd
|
||||
# be stuck with stale values until the next user-driven POST
|
||||
# triggered a fetchback. Refreshing everything bounds HA's
|
||||
# divergence to HEARTBEAT_INTERVAL_S in the worst case.
|
||||
hook = self.descriptor.on_observation
|
||||
for href, rep in index_links(body).items():
|
||||
self.links[href] = rep
|
||||
if hook is not None:
|
||||
try:
|
||||
hook(self.descriptor_state, href, rep)
|
||||
except Exception as e:
|
||||
self.log.warning("heartbeat hook %s: %s", href, e)
|
||||
self.last_seed_ts = time.time()
|
||||
self.maybe_publish_state()
|
||||
|
||||
# ---- MQTT publishing --------------------------------------------
|
||||
|
||||
def maybe_publish_state(self, force=False):
|
||||
if not force and not self._publish_gate:
|
||||
return
|
||||
sensors = self.descriptor.flatten(self.links)
|
||||
project = self.descriptor.project
|
||||
if project is not None:
|
||||
sensors = project(self.descriptor_state, sensors)
|
||||
if not force and sensors == self.last_state_pub:
|
||||
return
|
||||
self.last_state_pub = sensors
|
||||
self.mqtt.publish(self.state_topic,
|
||||
json.dumps(sensors).encode(),
|
||||
qos=1, retain=True)
|
||||
field = self.descriptor.remote_available_field
|
||||
if field is not None:
|
||||
self.publish_remote_available(sensors.get(field))
|
||||
if not force:
|
||||
log_fn = self.descriptor.log_state_change
|
||||
extra = log_fn(sensors) if log_fn is not None else ''
|
||||
self.log.info("state changed (%s notif#%d)",
|
||||
extra or 'descriptor-no-log', self.notif_count)
|
||||
|
||||
def publish_remote_available(self, remote_on, force=False):
|
||||
value = 'online' if remote_on else 'offline'
|
||||
if not force and value == self.last_remote_pub:
|
||||
return
|
||||
self.last_remote_pub = value
|
||||
try:
|
||||
self.mqtt.publish(self.remote_topic, value, qos=1, retain=True)
|
||||
self.log.info("remote_available → %s", value)
|
||||
except Exception as e:
|
||||
self.log.warning("remote_available publish: %s", e)
|
||||
|
||||
def reassert_availability(self):
|
||||
if self.session is None or self.last_state_pub is None:
|
||||
return
|
||||
self.set_availability(True)
|
||||
field = self.descriptor.remote_available_field
|
||||
if field is not None:
|
||||
self.publish_remote_available(
|
||||
self.last_state_pub.get(field), force=True)
|
||||
|
||||
def set_availability(self, online):
|
||||
try:
|
||||
self.mqtt.publish(self.avail_topic,
|
||||
'online' if online else 'offline',
|
||||
qos=1, retain=True)
|
||||
except Exception as e:
|
||||
self.log.warning("avail publish: %s", e)
|
||||
if not online and self.descriptor.remote_available_field is not None:
|
||||
self.last_remote_pub = None
|
||||
try:
|
||||
self.mqtt.publish(self.remote_topic, 'offline',
|
||||
qos=1, retain=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---- MQTT command handling --------------------------------------
|
||||
|
||||
def handle_command(self, topic, payload):
|
||||
if not topic.startswith(self.cmd_topic_prefix):
|
||||
return
|
||||
suffix = topic[len(self.cmd_topic_prefix) - len('cmd/'):]
|
||||
handler = self.cmd_handlers.get(suffix)
|
||||
if handler is None:
|
||||
self.log.warning("unknown command topic: %s", topic)
|
||||
return
|
||||
# Shallow-snapshot self.links so the handler sees a consistent
|
||||
# view across the read-modify-write it may need to perform
|
||||
# (e.g. oven lamp / sound / fastpreheat all RMW /mode/vs/0
|
||||
# options). Inner reps are mutated only by the OBSERVE reader
|
||||
# thread; handlers that mutate items must deep-copy themselves.
|
||||
result = handler(payload, dict(self.links))
|
||||
if result is None:
|
||||
self.log.warning("rejected command %s payload=%r",
|
||||
topic, payload)
|
||||
return
|
||||
path_segs, body = result
|
||||
sess = self.session
|
||||
if sess is None:
|
||||
self.log.warning("command %s: no DTLS session", topic)
|
||||
return
|
||||
try:
|
||||
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=8.0)
|
||||
except Exception as e:
|
||||
self.log.warning("command %s POST failed: %s", topic, e)
|
||||
return
|
||||
self.log.info("command %s payload=%r → %s",
|
||||
suffix, payload, fmt_code(code))
|
||||
# Defensive re-read of the just-written resource. The dryer
|
||||
# pushes an OBSERVE notification within ~100ms of a 2.xx write
|
||||
# and we'd see the new state anyway, but the oven doesn't
|
||||
# push on options-only writes (lamp/sound/fastpreheat). Without
|
||||
# this fetchback, HA would only see the new state on the next
|
||||
# heartbeat (10 min by default).
|
||||
if code >> 5 == 2:
|
||||
href = '/' + '/'.join(path_segs)
|
||||
# Optimistic publish: apply the write to our local state
|
||||
# and republish the MQTT state immediately. HA sees the
|
||||
# new value with no UI flash. The fetchback that follows
|
||||
# acts as verification — if the appliance didn't actually
|
||||
# honour the write (silent coerce), the fetchback's
|
||||
# publish will revert HA to the device's true state.
|
||||
self._apply_optimistic(href, body)
|
||||
# 3s settling window before the verification read.
|
||||
# 1.5s was sometimes too short — the oven's read-side
|
||||
# propagation lags more than that, and an early Block2
|
||||
# GET on /mode/vs/0 right after a POST appears to be one
|
||||
# of the triggers for the oven actively closing DTLS.
|
||||
# The fetchback runs in its own worker thread, so this
|
||||
# delay is non-blocking; the optimistic publish has
|
||||
# already given HA the new state.
|
||||
self._schedule_fetchback(href, delay_s=3.0)
|
||||
|
||||
def publish_health(self):
|
||||
now = time.time()
|
||||
h = {
|
||||
'mode': 'push',
|
||||
'device_class': self.descriptor.name,
|
||||
'serial': self._serial,
|
||||
'connect_count': self.connect_count,
|
||||
'error_count': self.error_count,
|
||||
'notif_count': self.notif_count,
|
||||
'last_change_age_s': (round(now - self.last_change_ts, 1)
|
||||
if self.last_change_ts else None),
|
||||
'last_seed_age_s': (round(now - self.last_seed_ts, 1)
|
||||
if self.last_seed_ts else None),
|
||||
'session_age_s': (round(now - self.session_started_ts, 1)
|
||||
if self.session_started_ts else None),
|
||||
'uptime_seconds': round(now - self.started_ts, 0),
|
||||
}
|
||||
try:
|
||||
self.mqtt.publish(self.health_topic, json.dumps(h).encode(),
|
||||
qos=0, retain=True)
|
||||
except Exception as e:
|
||||
self.log.warning("health publish: %s", e)
|
||||
|
||||
# ---- top-level loop ---------------------------------------------
|
||||
|
||||
def _publish_tick_loop(self):
|
||||
while not self.stop.wait(15.0):
|
||||
try:
|
||||
self.maybe_publish_state()
|
||||
except Exception as e:
|
||||
self.log.warning("publish tick: %s", e)
|
||||
|
||||
def run_forever(self):
|
||||
threading.Thread(target=self._publish_tick_loop, daemon=True,
|
||||
name=f'{self.app.klass}-tick').start()
|
||||
backoff = 1.0
|
||||
while not self.stop.is_set():
|
||||
try:
|
||||
self.session_once()
|
||||
backoff = 1.0
|
||||
except Exception as e:
|
||||
self.error_count += 1
|
||||
self.log.warning("session error: %s", e)
|
||||
sess = self.session
|
||||
self.session = None
|
||||
if sess is not None:
|
||||
try: sess.close()
|
||||
except Exception: pass
|
||||
self.set_availability(False)
|
||||
self.session_started_ts = None
|
||||
if self.stop.is_set():
|
||||
break
|
||||
wait = min(backoff, 30.0)
|
||||
self.log.info("reconnect in %.0fs", wait)
|
||||
if self.stop.wait(wait):
|
||||
break
|
||||
backoff = min(backoff * 2, 30.0)
|
||||
@@ -0,0 +1,536 @@
|
||||
"""CoAP-over-DTLS client for Samsung RT-OCF appliances (RFC 7252 + 6347).
|
||||
|
||||
Replaces the TLS-over-TCP transport used in the original dryer bridge.
|
||||
Both the oven (UDP/49154) and the dryer (UDP/49155) speak CoAP-over-DTLS
|
||||
with the ECDHE-ECDSA-AES128-GCM-SHA256 cipher and ab0b0ac4 client cert.
|
||||
|
||||
Wire-level details that matter (from local-tools/oven-findings.md §17):
|
||||
* DTLS ciphertext MTU must be 1200; otherwise OpenSSL fragments the
|
||||
client cert across two datagrams and TizenRT drops the second.
|
||||
* Samsung's RT-OCF uses ACK+separate-CON for the larger responses.
|
||||
The reader MUST correlate by (token, mid) — not arrival order —
|
||||
or interleaved one-shot / OBSERVE traffic mis-attributes.
|
||||
* Multi-block GET requires the SAME CoAP token across every block
|
||||
of the response ("token-stable Block2"). Fresh-token-per-block
|
||||
is silently dropped by the server.
|
||||
|
||||
Reader thread owns the UDP socket. Callers issue get()/post() and block
|
||||
on a per-token Event the reader signals. OBSERVE notifications are
|
||||
delivered via the on_notification callback.
|
||||
"""
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
|
||||
from OpenSSL import SSL
|
||||
|
||||
from .logger import logger
|
||||
|
||||
|
||||
# CoAP option numbers (RFC 7252 + 7641 + 7959)
|
||||
URI_PATH = 11
|
||||
URI_QUERY = 15
|
||||
OBSERVE = 6
|
||||
CONTENT_FORMAT = 12
|
||||
ACCEPT = 17
|
||||
BLOCK2 = 23
|
||||
SIZE2 = 28
|
||||
|
||||
# CoAP message types
|
||||
TYPE_CON = 0
|
||||
TYPE_NON = 1
|
||||
TYPE_ACK = 2
|
||||
TYPE_RST = 3
|
||||
|
||||
# CoAP method codes
|
||||
METHOD_GET = 0x01
|
||||
METHOD_POST = 0x02
|
||||
|
||||
# CoAP content-format value for application/cbor
|
||||
CF_CBOR = b'\x3c'
|
||||
|
||||
# OBSERVE option values (RFC 7641 §2)
|
||||
OBSERVE_REGISTER = b'' # register / refresh
|
||||
OBSERVE_DEREGISTER = bytes([1]) # deregister
|
||||
|
||||
# Block2 SZX=6 → 1024-byte blocks. The largest size Samsung's RT-OCF
|
||||
# will honour and the only one the probes have validated end-to-end.
|
||||
BLOCK_SZX = 6
|
||||
|
||||
|
||||
def _vlen(v):
|
||||
"""Variable-length integer encoder used in option deltas + lengths."""
|
||||
if v < 13: return v, b''
|
||||
if v < 269: return 13, bytes([v - 13])
|
||||
return 14, struct.pack('>H', v - 269)
|
||||
|
||||
|
||||
def encode_options(opts):
|
||||
"""Encode a list of (option_number, value_bytes) tuples."""
|
||||
out = b''
|
||||
prev = 0
|
||||
for n, val in sorted(opts, key=lambda x: x[0]):
|
||||
d, dx = _vlen(n - prev)
|
||||
l, lx = _vlen(len(val))
|
||||
out += bytes([(d << 4) | l]) + dx + lx + val
|
||||
prev = n
|
||||
return out
|
||||
|
||||
|
||||
def parse_coap(data):
|
||||
"""Decode a CoAP datagram. Returns (mtype, code, mid, token,
|
||||
options, payload). options is a list of (num, value_bytes)."""
|
||||
mt = (data[0] >> 4) & 0x03
|
||||
tkl = data[0] & 0x0F
|
||||
code = data[1]
|
||||
mid = int.from_bytes(data[2:4], 'big')
|
||||
tok = data[4:4 + tkl]
|
||||
i = 4 + tkl
|
||||
opts = []
|
||||
prev = 0
|
||||
payload = b''
|
||||
while i < len(data):
|
||||
b = data[i]
|
||||
if b == 0xFF:
|
||||
payload = data[i + 1:]
|
||||
break
|
||||
d_nib, l_nib = b >> 4, b & 0x0F
|
||||
i += 1
|
||||
if d_nib == 13:
|
||||
delta = 13 + data[i]; i += 1
|
||||
elif d_nib == 14:
|
||||
delta = 269 + int.from_bytes(data[i:i + 2], 'big'); i += 2
|
||||
elif d_nib == 15:
|
||||
raise ValueError("reserved option delta nibble 15")
|
||||
else:
|
||||
delta = d_nib
|
||||
if l_nib == 13:
|
||||
length = 13 + data[i]; i += 1
|
||||
elif l_nib == 14:
|
||||
length = 269 + int.from_bytes(data[i:i + 2], 'big'); i += 2
|
||||
elif l_nib == 15:
|
||||
raise ValueError("reserved option length nibble 15")
|
||||
else:
|
||||
length = l_nib
|
||||
num = prev + delta
|
||||
opts.append((num, data[i:i + length]))
|
||||
i += length
|
||||
prev = num
|
||||
return mt, code, mid, tok, opts, payload
|
||||
|
||||
|
||||
def build_coap(mtype, code, mid, token, options, payload=b''):
|
||||
"""Build a CoAP datagram. mtype: CON/NON/ACK/RST. token: bytes (may
|
||||
be empty for ACK). options: list of (num, value_bytes)."""
|
||||
tkl = len(token)
|
||||
hdr = bytes([(1 << 6) | (mtype << 4) | tkl, code,
|
||||
(mid >> 8) & 0xFF, mid & 0xFF])
|
||||
body = hdr + token + encode_options(options)
|
||||
if payload:
|
||||
body += b'\xFF' + payload
|
||||
return body
|
||||
|
||||
|
||||
def block_value(num, more, szx):
|
||||
"""Encode a CoAP Block-N option value."""
|
||||
v = (num << 4) | ((more & 1) << 3) | (szx & 7)
|
||||
if v <= 0xFF: return bytes([v])
|
||||
if v <= 0xFFFF: return struct.pack('>H', v)
|
||||
return struct.pack('>I', v)[1:]
|
||||
|
||||
|
||||
def fmt_code(c):
|
||||
"""0x45 → '2.05', 0x84 → '4.04'. Used in log lines."""
|
||||
return f"{c >> 5}.{c & 0x1F:02d}"
|
||||
|
||||
|
||||
def _split_dtls(buf):
|
||||
"""Split a UDP datagram that contains one-or-more DTLS records.
|
||||
OpenSSL sometimes hands the BIO multiple records back-to-back; we
|
||||
must send each as its own UDP datagram or TizenRT drops them."""
|
||||
o, out = 0, []
|
||||
while o + 13 <= len(buf):
|
||||
L = int.from_bytes(buf[o + 11:o + 13], 'big')
|
||||
end = o + 13 + L
|
||||
if end > len(buf):
|
||||
break
|
||||
out.append(buf[o:end])
|
||||
o = end
|
||||
return out
|
||||
|
||||
|
||||
class DtlsCoapSession:
|
||||
"""Single sustained DTLS-CoAP session.
|
||||
|
||||
Caller drives lifecycle:
|
||||
sess = DtlsCoapSession(host, port, cert, key)
|
||||
sess.connect()
|
||||
sess.start_reader()
|
||||
sess.subscribe([...], on_notification=cb) # OBSERVE
|
||||
code, body = sess.get(['device', '0']) # Block2 fetch
|
||||
code, _ = sess.post(['mode','vs','0'], cbor)
|
||||
sess.close()
|
||||
"""
|
||||
|
||||
HANDSHAKE_TIMEOUT_S = 12.0
|
||||
READER_RECV_TIMEOUT_S = 1.0 # short so stop_event propagates quickly
|
||||
MAX_BLOCKS = 32 # safety bound for Block2 fetches
|
||||
|
||||
def __init__(self, host, port, cert_path, key_path,
|
||||
on_notification=None, mtu=1200):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.cert_path = str(cert_path)
|
||||
self.key_path = str(key_path)
|
||||
self.on_notification = on_notification # fn(href, payload_bytes)
|
||||
self.mtu = mtu
|
||||
|
||||
self.sock = None
|
||||
self.conn = None
|
||||
self.dest = None
|
||||
|
||||
self._send_lock = threading.Lock()
|
||||
self._mid = 0x5000
|
||||
self._tok_counter = 0
|
||||
# token (bytes) → (Event, container_dict)
|
||||
self._pending = {}
|
||||
# token (bytes) → href (str)
|
||||
self._observe_tokens = {}
|
||||
|
||||
self._stop = threading.Event()
|
||||
self._reader_thread = None
|
||||
|
||||
# ---- lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self):
|
||||
"""DTLS handshake. Blocks up to HANDSHAKE_TIMEOUT_S. Raises
|
||||
ConnectionError / TimeoutError on failure."""
|
||||
ctx = SSL.Context(SSL.DTLS_METHOD)
|
||||
ctx.set_verify(SSL.VERIFY_NONE, lambda *_: True)
|
||||
# @SECLEVEL=0 needed because the AC14K_M-rooted ab0b0ac4 chain
|
||||
# is SHA-1 signed, which OpenSSL 3.x's default security level
|
||||
# rejects. The chain comes from Samsung's leaked CA so the
|
||||
# signature algorithm isn't ours to change.
|
||||
ctx.set_cipher_list(b'ECDHE-ECDSA-AES128-GCM-SHA256:@SECLEVEL=0')
|
||||
ctx.use_certificate_chain_file(self.cert_path)
|
||||
ctx.use_privatekey_file(self.key_path)
|
||||
ctx.check_privatekey()
|
||||
|
||||
conn = SSL.Connection(ctx, None)
|
||||
conn.set_connect_state()
|
||||
conn.set_ciphertext_mtu(self.mtu)
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.settimeout(2.0)
|
||||
dest = (self.host, self.port)
|
||||
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < self.HANDSHAKE_TIMEOUT_S:
|
||||
try:
|
||||
conn.do_handshake()
|
||||
break
|
||||
except SSL.WantReadError:
|
||||
pass
|
||||
except SSL.Error as e:
|
||||
sock.close()
|
||||
raise ConnectionError(f"DTLS handshake error: {e}") from e
|
||||
try:
|
||||
o = conn.bio_read(65535)
|
||||
if o:
|
||||
for r in _split_dtls(o):
|
||||
sock.sendto(r, dest)
|
||||
except SSL.WantReadError:
|
||||
pass
|
||||
try:
|
||||
d, _ = sock.recvfrom(65535)
|
||||
if d:
|
||||
conn.bio_write(d)
|
||||
except socket.timeout:
|
||||
pass
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
sock.close()
|
||||
raise TimeoutError(
|
||||
f"DTLS handshake timeout to {self.host}:{self.port}")
|
||||
|
||||
self.sock = sock
|
||||
self.conn = conn
|
||||
self.dest = dest
|
||||
self._stop.clear()
|
||||
|
||||
def start_reader(self):
|
||||
"""Spawn the reader thread. Must be called after connect()."""
|
||||
if self.sock is None:
|
||||
raise RuntimeError("connect() before start_reader()")
|
||||
t = threading.Thread(target=self._reader_loop,
|
||||
daemon=True, name='dtls-reader')
|
||||
t.start()
|
||||
self._reader_thread = t
|
||||
|
||||
def join(self):
|
||||
"""Block until the reader thread exits (i.e. socket dies)."""
|
||||
if self._reader_thread is not None:
|
||||
self._reader_thread.join()
|
||||
|
||||
def close(self):
|
||||
"""Tear down session. Signals reader_loop and wakes pending
|
||||
waiters with an error."""
|
||||
self._stop.set()
|
||||
if self.conn is not None:
|
||||
try:
|
||||
self.conn.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
if self.sock is not None:
|
||||
try:
|
||||
self.sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
for tok, (ev, container) in list(self._pending.items()):
|
||||
container.setdefault('err', 'socket closed')
|
||||
ev.set()
|
||||
self._pending.clear()
|
||||
self._observe_tokens.clear()
|
||||
self.sock = None
|
||||
self.conn = None
|
||||
|
||||
# ---- send / receive plumbing -------------------------------------
|
||||
|
||||
def _next_mid(self):
|
||||
self._mid = (self._mid + 1) & 0xFFFF
|
||||
return self._mid
|
||||
|
||||
def _next_tok(self):
|
||||
self._tok_counter = (self._tok_counter + 1) & 0xFFFFFFFF
|
||||
# 4-byte tokens — fits within tkl=8 cap with headroom and
|
||||
# avoids collisions across long-running OBSERVE subscriptions.
|
||||
return self._tok_counter.to_bytes(4, 'big')
|
||||
|
||||
def _send_dgram(self, datagram):
|
||||
"""Send a CoAP datagram. Holds the send lock for the
|
||||
BIO-drain so two writers can't interleave records."""
|
||||
with self._send_lock:
|
||||
if self.conn is None:
|
||||
raise ConnectionError("DTLS session closed")
|
||||
self.conn.send(datagram)
|
||||
try:
|
||||
while True:
|
||||
o = self.conn.bio_read(65535)
|
||||
if not o:
|
||||
break
|
||||
for r in _split_dtls(o):
|
||||
self.sock.sendto(r, self.dest)
|
||||
except SSL.WantReadError:
|
||||
pass
|
||||
|
||||
def _reader_loop(self):
|
||||
"""Pump UDP socket → DTLS BIO → CoAP parser. Demuxes to pending
|
||||
/ observe handlers. Exits on socket error or stop event."""
|
||||
sock = self.sock
|
||||
conn = self.conn
|
||||
sock.settimeout(self.READER_RECV_TIMEOUT_S)
|
||||
try:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
d, _ = sock.recvfrom(65535)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
if not d:
|
||||
continue
|
||||
try:
|
||||
conn.bio_write(d)
|
||||
except SSL.Error as e:
|
||||
logger.warning("DTLS bio_write: %s", e)
|
||||
return
|
||||
# Drain all app data the DTLS conn has buffered. One
|
||||
# UDP datagram can yield zero, one, or several CoAP
|
||||
# records depending on how mbedtls packed them.
|
||||
while True:
|
||||
try:
|
||||
pl = conn.recv(65535)
|
||||
except SSL.WantReadError:
|
||||
break
|
||||
except SSL.ZeroReturnError:
|
||||
logger.info("DTLS peer closed connection")
|
||||
return
|
||||
except SSL.Error as e:
|
||||
logger.warning("DTLS recv: %s", e)
|
||||
return
|
||||
if not pl:
|
||||
break
|
||||
try:
|
||||
self._dispatch_coap(pl)
|
||||
except Exception as e:
|
||||
logger.warning("dispatch: %s", e)
|
||||
finally:
|
||||
# Make sure pending waiters don't hang if the reader dies.
|
||||
for tok, (ev, container) in list(self._pending.items()):
|
||||
container.setdefault('err', 'reader exited')
|
||||
ev.set()
|
||||
|
||||
def _dispatch_coap(self, datagram):
|
||||
try:
|
||||
mt, code, mid, tok, ropts, payload = parse_coap(datagram)
|
||||
except Exception as e:
|
||||
logger.debug("malformed CoAP: %s", e)
|
||||
return
|
||||
|
||||
# ACK back any CON from the device to suppress retransmits.
|
||||
# RFC 7252 §4.2 — ACK is a bare frame (token len 0, code 0).
|
||||
if mt == TYPE_CON:
|
||||
try:
|
||||
self._send_dgram(build_coap(TYPE_ACK, 0, mid, b'', []))
|
||||
except Exception as e:
|
||||
logger.warning("ACK send: %s", e)
|
||||
|
||||
# Empty ACK with no options & no payload = "separate response
|
||||
# coming" — used by Samsung's RT-OCF for the larger reads. Stop
|
||||
# the retransmit timer on the client side and wait for the CON.
|
||||
if mt == TYPE_ACK and code == 0 and not payload and not ropts:
|
||||
return
|
||||
|
||||
# Pending one-shot? Resolve and return.
|
||||
rec = self._pending.get(tok)
|
||||
if rec is not None:
|
||||
ev, container = rec
|
||||
container['code'] = code
|
||||
container['mtype'] = mt
|
||||
container['mid'] = mid
|
||||
container['options'] = ropts
|
||||
container['payload'] = payload
|
||||
ev.set()
|
||||
return
|
||||
|
||||
# OBSERVE notification?
|
||||
href = self._observe_tokens.get(tok)
|
||||
if href is not None:
|
||||
if code != 0x45:
|
||||
logger.warning("observe %s: non-2.05 %s",
|
||||
href, fmt_code(code))
|
||||
return
|
||||
cb = self.on_notification
|
||||
if cb is not None:
|
||||
try:
|
||||
cb(href, payload)
|
||||
except Exception as e:
|
||||
logger.warning("notification callback %s: %s",
|
||||
href, e)
|
||||
return
|
||||
|
||||
# Stale token (post-reconnect or unknown) — drop quietly.
|
||||
|
||||
# ---- request primitives ------------------------------------------
|
||||
|
||||
def get(self, path_segs, query=(), timeout=10.0):
|
||||
"""Token-stable Block2 GET. Returns (code, payload_bytes).
|
||||
|
||||
Reuses one CoAP token across every block of a multi-block
|
||||
response — Samsung's server keys per-transfer state on the
|
||||
token, and dropping a fresh token on block 1+ silently drops
|
||||
the request."""
|
||||
if self.conn is None:
|
||||
raise ConnectionError("DTLS session closed")
|
||||
tok = self._next_tok()
|
||||
blob = b''
|
||||
num = 0
|
||||
last_code = None
|
||||
last_opts = []
|
||||
deadline = time.time() + timeout
|
||||
while True:
|
||||
ev = threading.Event()
|
||||
container = {}
|
||||
self._pending[tok] = (ev, container)
|
||||
try:
|
||||
mid = self._next_mid()
|
||||
opts = [(URI_PATH, s.encode()) for s in path_segs]
|
||||
for q in query:
|
||||
opts.append((URI_QUERY, q.encode()))
|
||||
opts.append((ACCEPT, CF_CBOR))
|
||||
if num > 0:
|
||||
opts.append((BLOCK2, block_value(num, 0, BLOCK_SZX)))
|
||||
self._send_dgram(
|
||||
build_coap(TYPE_CON, METHOD_GET, mid, tok, opts))
|
||||
wait = max(0.1, deadline - time.time())
|
||||
if not ev.wait(wait):
|
||||
raise TimeoutError(
|
||||
f"GET /{'/'.join(path_segs)} block {num} timeout")
|
||||
if 'err' in container:
|
||||
raise ConnectionError(container['err'])
|
||||
finally:
|
||||
self._pending.pop(tok, None)
|
||||
|
||||
code = container['code']
|
||||
payload = container['payload']
|
||||
ropts = container['options']
|
||||
last_code = code
|
||||
last_opts = ropts
|
||||
blob += payload
|
||||
# 4.xx / 5.xx responses don't carry Block2 continuation —
|
||||
# bail with whatever we got. Caller decides if 4.xx is fatal.
|
||||
if code >> 5 != 2:
|
||||
return code, blob
|
||||
b2 = [v for n, v in ropts if n == BLOCK2]
|
||||
more = 0
|
||||
if b2:
|
||||
bv = int.from_bytes(b2[0], 'big')
|
||||
more = (bv >> 3) & 1
|
||||
if not more:
|
||||
break
|
||||
num += 1
|
||||
if num > self.MAX_BLOCKS:
|
||||
raise ConnectionError(
|
||||
f"GET /{'/'.join(path_segs)}: >{self.MAX_BLOCKS} "
|
||||
f"blocks, aborting")
|
||||
return last_code, blob
|
||||
|
||||
def post(self, path_segs, body_cbor, timeout=8.0):
|
||||
"""Single-frame POST with a CBOR-encoded body. Returns
|
||||
(code, payload_bytes). body_cbor must already be encoded."""
|
||||
if self.conn is None:
|
||||
raise ConnectionError("DTLS session closed")
|
||||
tok = self._next_tok()
|
||||
mid = self._next_mid()
|
||||
opts = [(URI_PATH, s.encode()) for s in path_segs]
|
||||
opts.append((CONTENT_FORMAT, CF_CBOR))
|
||||
opts.append((ACCEPT, CF_CBOR))
|
||||
datagram = build_coap(TYPE_CON, METHOD_POST, mid, tok, opts,
|
||||
body_cbor)
|
||||
ev = threading.Event()
|
||||
container = {}
|
||||
self._pending[tok] = (ev, container)
|
||||
try:
|
||||
self._send_dgram(datagram)
|
||||
if not ev.wait(timeout):
|
||||
raise TimeoutError(
|
||||
f"POST /{'/'.join(path_segs)} timeout")
|
||||
if 'err' in container:
|
||||
raise ConnectionError(container['err'])
|
||||
return container['code'], container['payload']
|
||||
finally:
|
||||
self._pending.pop(tok, None)
|
||||
|
||||
def subscribe(self, path_segs):
|
||||
"""Register an OBSERVE on the given path. The initial 2.05
|
||||
notification and all subsequent state-change notifications
|
||||
will fire on_notification(href, payload_bytes).
|
||||
|
||||
Returns the token used (in case the caller wants to deregister
|
||||
later)."""
|
||||
if self.conn is None:
|
||||
raise ConnectionError("DTLS session closed")
|
||||
tok = self._next_tok()
|
||||
href = '/' + '/'.join(path_segs)
|
||||
# Register the token BEFORE sending — otherwise the device
|
||||
# could respond between send() and the dict insert, and the
|
||||
# reader thread would drop the initial 2.05 as "stale".
|
||||
self._observe_tokens[tok] = href
|
||||
mid = self._next_mid()
|
||||
opts = [(URI_PATH, s.encode()) for s in path_segs]
|
||||
opts.append((OBSERVE, OBSERVE_REGISTER))
|
||||
opts.append((ACCEPT, CF_CBOR))
|
||||
self._send_dgram(
|
||||
build_coap(TYPE_CON, METHOD_GET, mid, tok, opts))
|
||||
return tok
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Configuration — env-var driven.
|
||||
|
||||
Two flavours:
|
||||
* SharedConfig — MQTT broker, cert paths, HA prefix, timers. One per
|
||||
process.
|
||||
* ApplianceConfig — one per appliance the bridge is supervising. Keys
|
||||
come from `APPLIANCE_<n>_*` env vars (1-indexed). The list of
|
||||
appliances is `APPLIANCE_COUNT` entries long.
|
||||
|
||||
.env in cwd hydrates os.environ at import time; docker-compose env wins
|
||||
over file contents."""
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _load_env_file(filename='.env'):
|
||||
"""If a .env-style file is present in cwd, hydrate os.environ from
|
||||
it. Existing env wins so docker-compose `environment:` overrides
|
||||
file contents."""
|
||||
env_path = Path.cwd() / filename
|
||||
if not env_path.exists():
|
||||
return
|
||||
for line in env_path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#') or '=' not in line:
|
||||
continue
|
||||
k, v = line.split('=', 1)
|
||||
k = k.strip(); v = v.strip()
|
||||
if k and k not in os.environ:
|
||||
os.environ[k] = v
|
||||
|
||||
|
||||
_load_env_file('.env')
|
||||
|
||||
|
||||
def _resolve_cert(env_key, basename):
|
||||
"""Cert lookup: explicit env > /config/<basename> (Docker mount) >
|
||||
./certs/<basename> (bare-metal dev)."""
|
||||
if os.getenv(env_key):
|
||||
return Path(os.environ[env_key])
|
||||
docker_path = Path('/config') / basename
|
||||
if docker_path.exists():
|
||||
return docker_path
|
||||
return Path.cwd() / 'certs' / basename
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SharedConfig:
|
||||
"""Process-wide config (MQTT, cert paths, intervals)."""
|
||||
CERT_PATH: Path
|
||||
KEY_PATH: Path
|
||||
MQTT_BROKER: Optional[str]
|
||||
MQTT_PORT: int
|
||||
MQTT_USER: Optional[str]
|
||||
MQTT_PASS: Optional[str]
|
||||
HA_DISCOVERY_PREFIX: str
|
||||
HEALTH_INTERVAL_S: int
|
||||
HEARTBEAT_INTERVAL_S: int
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> 'SharedConfig':
|
||||
return cls(
|
||||
CERT_PATH=_resolve_cert('CERT_PATH', 'ab0b0ac4_fullchain.pem'),
|
||||
KEY_PATH=_resolve_cert('KEY_PATH', 'ab0b0ac4.key'),
|
||||
MQTT_BROKER=os.getenv('MQTT_BROKER'),
|
||||
MQTT_PORT=int(os.getenv('MQTT_PORT', '1883')),
|
||||
MQTT_USER=os.getenv('MQTT_USER') or None,
|
||||
MQTT_PASS=os.getenv('MQTT_PASS') or None,
|
||||
HA_DISCOVERY_PREFIX=os.getenv('HA_DISCOVERY_PREFIX',
|
||||
'homeassistant'),
|
||||
HEALTH_INTERVAL_S=int(os.getenv('HEALTH_INTERVAL_S', '60')),
|
||||
HEARTBEAT_INTERVAL_S=int(os.getenv('HEARTBEAT_INTERVAL_S',
|
||||
'600')),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApplianceConfig:
|
||||
"""Per-appliance runtime config.
|
||||
|
||||
Sourced from `APPLIANCE_<index>_*` env vars (1-indexed). `index` is
|
||||
just a stable identifier for logs; it does not appear in MQTT
|
||||
topics or HA discovery (those are keyed off `topic_prefix`)."""
|
||||
index: int
|
||||
klass: str # 'dryer', 'oven', …
|
||||
ip: str
|
||||
ocf_port: Optional[int] # None → descriptor.default_observe_port
|
||||
topic_prefix: str
|
||||
device_name: str
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, index: int) -> 'ApplianceConfig':
|
||||
prefix = f'APPLIANCE_{index}_'
|
||||
klass = os.getenv(prefix + 'CLASS')
|
||||
if not klass:
|
||||
raise ValueError(f"{prefix}CLASS not set")
|
||||
ip = os.getenv(prefix + 'IP')
|
||||
if not ip:
|
||||
raise ValueError(f"{prefix}IP not set")
|
||||
port_env = os.getenv(prefix + 'OCF_PORT')
|
||||
port = int(port_env) if port_env else None
|
||||
topic = os.getenv(prefix + 'TOPIC') or f'samsung_{klass}'
|
||||
name = os.getenv(prefix + 'NAME') or f'Samsung {klass.title()}'
|
||||
return cls(
|
||||
index=index, klass=klass, ip=ip, ocf_port=port,
|
||||
topic_prefix=topic, device_name=name,
|
||||
)
|
||||
|
||||
|
||||
def load_appliances() -> list[ApplianceConfig]:
|
||||
"""Read APPLIANCE_COUNT and build the appliance list. At least one
|
||||
is required."""
|
||||
count = int(os.getenv('APPLIANCE_COUNT', '1'))
|
||||
if count < 1:
|
||||
raise ValueError("APPLIANCE_COUNT must be >= 1")
|
||||
return [ApplianceConfig.from_env(i + 1) for i in range(count)]
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Stdout logging — works correctly under Docker's PYTHONUNBUFFERED=1.
|
||||
|
||||
Two logger families share the root handler:
|
||||
* `samsung_appliance` (and its children) for module-level lines —
|
||||
DTLS warnings, generic startup chatter, MQTT plumbing.
|
||||
* `<class>.<serial>` for per-appliance bridge lines — e.g.
|
||||
`dryer.<serial>`. Each PushBridge gets its own such logger
|
||||
via bridge_logger() once the seed reveals the appliance's serial.
|
||||
|
||||
Both trees propagate to the root logger, which is the one with the
|
||||
StreamHandler, so the same format applies everywhere."""
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s %(levelname)-5s %(name)-26s %(message)s',
|
||||
datefmt='%H:%M:%S',
|
||||
handlers=[logging.StreamHandler(sys.stdout)],
|
||||
)
|
||||
|
||||
logger = logging.getLogger("samsung_appliance")
|
||||
|
||||
|
||||
def bridge_logger(klass: str, serial: str | None = None) -> logging.Logger:
|
||||
"""Return a top-level logger tagged with the appliance class and,
|
||||
once known, the serial. Pre-seed callers pass serial=None and get
|
||||
e.g. `dryer`; post-seed callers pass the serial and get e.g.
|
||||
`dryer.<serial>`."""
|
||||
name = f"{klass}.{serial}" if serial else klass
|
||||
return logging.getLogger(name)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Shared sensor helpers.
|
||||
|
||||
Appliance-specific flattening lives in samsung_appliance/appliances/*.py;
|
||||
this module only carries utilities that every descriptor uses (currently
|
||||
the /device/0 link-dict indexer).
|
||||
"""
|
||||
|
||||
|
||||
def index_links(device0_body):
|
||||
"""Turn the /device/0 CBOR list-of-{href, rep} into a dict keyed
|
||||
by href. The first list entry is the device-level rep itself and
|
||||
isn't useful here, so skip it."""
|
||||
out = {}
|
||||
if not isinstance(device0_body, list):
|
||||
return out
|
||||
for entry in device0_body[1:]:
|
||||
if isinstance(entry, dict) and 'href' in entry:
|
||||
out[entry['href']] = entry.get('rep') or {}
|
||||
return out
|
||||
Reference in New Issue
Block a user