Commit Graph
29 Commits
Author SHA1 Message Date
Marc Billow eecbc19dde fix(observe): clear stale notifications before re-attempting observe mode 2026-07-09 18:36:36 -05:00
Marc Billow 97260441f1 feat(observe): add subscribe-and-wait mode selection 2026-07-09 18:33:03 -05:00
Marc Billow eba73118e9 feat(observe): add ObserveManager with write-settle guard 2026-07-09 18:28:46 -05:00
Marc Billow 3ab0d5553e refactor(fridge): use HA's own translation system for enum entities
Replace the ad-hoc option_names dicts (hardcoded English, manual
reverse-mapping) with the idiomatic HA approach: normalize raw Samsung
enum values to lowercase at the select/sensor boundary and let
strings.json/translations/en.json supply the display text, keyed by
the normalized value. select.py maps the chosen (lowercased) option
back to the device's original casing before writing. The ice-making
status sensor now also declares device_class=enum with an options
list, as HA expects for enum sensor state translations.

Also drop the hardcoded _BZONE_MODES list in favor of discovering
beverage zone modes from the device's own roomSupportedModes field,
matching how flex_zone_mode and ice_type already work.
2026-07-09 16:22:52 -05:00
Marc Billow b386063e79 fix(fridge): translate enum select/sensor values in Python, not strings.json
hassfest rejects translation keys containing uppercase letters, but the
raw Samsung enum values for flex zone, beverage zone, ice type, and ice
making status are uppercase (e.g. CV_TTYPE_RF9000A_FREEZE). Follow the
existing dryer-course pattern instead: map raw device values to
human-readable names in Python via a new SelectDesc.option_names field
(mirrored read/write in select.py) and a value_fn for the sensor, and
drop the now-unused translation_key entries from strings.json/en.json.
2026-07-09 14:12:58 -05:00
Marc Billow 5b8a079fa6 chore: prepare repo for HACS listing
- Add hacs.json and MIT LICENSE required for HACS/default-store inclusion
- Fill required manifest.json keys (documentation, issue_tracker,
  codeowners) and reorder per hassfest's key-ordering rule
- Add validate.yml workflow running hassfest and HACS validation
- Update README install note now that hacs.json is checked in
2026-07-09 14:02:46 -05:00
Marc Billow 5d1a32aa9a Map remaining coverage-gap hrefs, fix discover() false-gap bug, improve device naming
Maps the fridge/dishwasher hrefs flagged by last session's coverage-gap
Repairs issue: defrost delay (switch) + block status (diagnostic binary
sensor), master ice-maker enable, self-check trigger/status, dishwasher
diagnosis trigger/status, and last-operation-source. Adds fallback
capabilities for /doors, /temperatures, and /icemaker/status that only
bind when the richer per-instance hrefs they duplicate are absent, so
simpler devices without those hrefs still get the data. Everything else
in the original gap list moves to capabilities.ignored (Bixby audio
feedback, inert DR/energy-planner resources, redundant metadata).

Fixes discover() reporting a known href as an unregistered gap whenever
its match_fn declined to bind for that device (e.g. a filter capability
on hardware without that filter) — it's now only a gap when no capability
is registered for the href at all.

Device naming now uses x.com.samsung.da.modelNum (not the OCF /oic/p,d
metadata, which produced inconsistent names like "[dishwasher] Samsung")
to build "Samsung <Type> (<model>)".
2026-07-08 15:24:34 -05:00
Marc Billow d2699f0f5b Add diagnostics platform, config-flow gap warning, issue template
Completes the device-capability-diagnostics spec:

- diagnostics.py: the standard HA diagnostics hook, returning device type,
  one_ui_version, unbound hrefs, and the redacted raw resource tree, plus
  integration and smartthings-local version numbers. This is what the
  Repairs issue (added in the previous commit) points users at, and what
  they attach to a device-support issue.
- config_flow.py: _probe_and_validate now also reports oneUiVersion and
  whether the device type is recognized. Recognized types are unaffected;
  an unrecognized type shows a new confirm_unknown_type step explaining
  that only common capabilities will be available before creating the
  entry, so expectations are set at setup time rather than only after the
  fact via Repairs.
- .github/ISSUE_TEMPLATE/device-support.yml: structured template for
  filing a capability gap, linked from both the Repairs issue and the
  config-flow confirmation step.
- README: short section pointing at this whole mechanism.
2026-07-08 00:10:30 -05:00
Marc Billow 35b47d6b5a Add coverage-gap detection: ignored-hrefs registry, Repairs issue, redaction
Implements the first half of the device-capability-diagnostics spec:

- registry/capabilities/ignored.py: known-noise hrefs (Bixby/voice
  provisioning, WiFi/BLE info, OTA/region housekeeping, and a couple of
  redundant hrefs) declared as no-entity Capability objects, so
  discover()'s existing unknown-resource reporting treats them as covered
  instead of flagging every device as having gaps. Folded into each
  by_type registry and into the global fallback CAPABILITIES set.
- discovery.py: log() callback now passes the raw href instead of a
  formatted message, so callers can collect a clean unbound-hrefs list.
- coordinator.py: wires that callback into self._unbound_hrefs, tracks
  device_type_name/one_ui_version, and raises (or clears) a Repairs issue
  when a device's type is unrecognized or it has genuinely unmodeled
  hrefs left over.
- registry/redact.py: recursive, substring-keyed redaction for anything
  that looks like account/identity data, ahead of the diagnostics.py
  platform that will consume it.

Verified against the real dishwasher/refrigerator fixtures: known-noise
hrefs no longer show up as gaps, while genuine gaps (e.g. /bespoke/vs/0
on the fridge) still do.
2026-07-07 23:49:58 -05:00
Marc Billow b67e0e5cf5 Rename ocf/ to registry/, it's a capability registry now
The DTLS/CoAP transport code that made "ocf" an accurate name moved out to
the smartthings-local package. What's left here (capability.py, entities.py,
discovery.py, adapter.py, identity.py, capabilities/, by_type/, plus the
/device/0 batch parser) is entirely the device capability registry, so name
the package for what it does.

Flattened the redundant ocf/registry/ nesting into a single top-level
registry/ package and updated every import across the platform modules and
test suite accordingly. Verified: full test suite (80/80) passes, and the
Docker dev container reconnects to both live appliances and rediscovers
their entities cleanly after the rename.
2026-07-07 22:54:57 -05:00
Marc Billow 4782878583 Depend on published smartthings-local package, rewrite README
Now that mbillow/localthings#8's protocol fixes are merged upstream and
smartthings-local 0.1.0 is on PyPI, drop the vendored ocf/coap_dtls.py +
ocf/observe_refresh.py transport (dead code, never instantiated) and the
duplicated ocf_root_ca.pem in favor of the real package. manifest.json and
requirements-dev.txt now pin smartthings-local>=0.1.0 instead of the
unmerged git branch.

README rewritten from scratch — it still described the old standalone
MQTT-bridge (samsung_appliance/, main.py, docker-compose bridge) that this
repo moved off of; it now documents the real architecture: a native HA
custom component with config-flow-driven cert minting and a per-device-type
capability registry, with the protocol layer split out to smartthings-local.
2026-07-07 22:08:02 -05:00
Marc Billow db2148467b feat: dishwasher cycle/settings entities, stale-state fixes, LED/sound controls
Dishwasher:
- Revert remote_control to BinarySensorDesc ("Smart Control", read-only)
- Add cycle SelectDesc on /course/vs/0 via options-array RMW (AI Wash,
  Pre blast, Self clean, Normal, Express 60, Heavy, Pots and pans,
  Delicate, Plastic, Baby Care)
- Add Storm Wash+ and Auto release dry SwitchDescs on /course/vs/0
- Add Sanitize SwitchDesc and Smart Dry SelectDesc on /dishwasher/vs/0
- Add Sound volume NumberDesc (0–15 step 5) on /settings/sound/volume/vs/0
- Add Door LED night brightness, night start/end time to DOOR_LED capability
- Add SOUND_VOLUME capability to laundry module

Stale-state fixes (firmware leaves values set after cycle ends):
- finish_time: suppress when machine state is not active
- progress: return 'Idle' when machine state is not active
- water filter: gate WATER_FILTER capability on filterStatus != 'notused'

Translations:
- Add ICESTATUS_RUN → "Making ice" to strings.json and en.json

Tests:
- Update dishwasher and refrigerator golden fixtures to reflect current entity set
- Remove dead test_project_extrapolates_remaining_time (on_observation removed)
2026-07-01 00:36:24 -05:00
Marc Billow 800b113495 feat: mutable child lock, remote control, delay start; stale-data fallback on poll failure
Child lock and remote control promoted from read-only binary sensors to
writable switches. Delay start time promoted from a raw string sensor to
a writable TimeEntity.

On persistent poll failure (both initial attempt and reconnect), fall back
to the last known resource values instead of raising UpdateFailed. This
keeps entities available with stale data rather than flooding history with
Unavailable gaps whenever the device drops a /device/0 block fetch.
UpdateFailed is still raised on the very first poll before any data exists.
2026-06-30 23:29:31 -05:00
Marc Billow eb6e2d34d7 feat: replace completion_time/completion_minutes with finish_time timestamp
Removes two redundant sensors (a raw H:MM:SS string and a duration integer)
in favour of a single SensorDeviceClass.TIMESTAMP that shows the absolute
estimated finish time. The HA frontend auto-renders this as "in X minutes"
and keeps it current without re-polling.

Also removes dead _project / _on_observation / _rem_minutes code from both
operational.py and oven.py — these hooks were declared on the Capability but
never called anywhere in the coordinator or adapter.
2026-06-30 23:16:06 -05:00
Marc Billow 62fc3b44f4 fix: alarm code sensor registers on stub resources, shows all active codes
Two fixes for the alarm_code diagnostic sensor:

1. _is_included now treats an empty rep ({}) from /device/0 as a stub
   meaning the resource exists but hasn't been individually fetched yet.
   Previously the field-presence check against {} returned False, leaving
   the entity unregistered and showing Unavailable from the entity registry.

2. Replace _last_alarm_code (silently dropped all but the final alarm) with
   _active_alarm_codes which joins all codes in the items list. Shows 'none'
   when no alarms are active.
2026-06-30 23:02:11 -05:00
Marc Billow 022a192bca feat: CoAP inter-request pacing to prevent RT-OCF rate limit drops
Samsung appliances silently drop CoAP requests when hit faster than their
firmware ceiling (~8–14 req/s measured; dishwasher unknown). Multi-block
Block2 fetches on LAN hit ~100 req/s instantaneously, causing the observed
burst timeouts on block 4 and block 10 of /device/0.

Adds rate_limit_rps (default 5 req/s / 200 ms) to DtlsCoapSession with a
pace() method that enforces the inter-block delay via _stop.wait() so session
teardown interrupts the sleep cleanly. Coordinator calls pace() between hrefs
in _poll_hrefs_blocking. Also adds device IP to all GET log lines for easier
per-device filtering.
2026-06-30 22:40:14 -05:00
Marc Billow 9ea4ddd3a3 fix: gate entity registration on field presence by default
_is_included now checks that an entity's field exists in the resource
rep before registering it, preventing phantom entities when optional
fields are absent on a shared resource. Explicit exists_fn still takes
priority for cases requiring custom logic. All platforms use the shared
helper; select.py's inline duplicate removed.
2026-06-30 16:59:45 -05:00
Marc Billow da573a725f feat: fridge entity improvements, CoAP reliability fixes, time platform
Fridge entities:
- Add strip_prefix_in_key to pattern caps; rename temp entities to
  "Cooler/Freezer Temperature" and "Cooler/Freezer Setpoint"
- Add number sliders (NumberMode.SLIDER)
- Remove redundant sensors/binary_sensors that shadow switches
- Add entity_category='config' to setpoints, refrigeration modes,
  autofill, sabbath, sound, LED, flex zone, nighttime ice, ice type,
  beverage zone
- Add WELCOME_LIGHTING, STATUS_LOCK (auto door opener + fridge sound),
  DOOR_ALERT (alarm tone select 1-4), CABINET_LIGHT_ENHANCED (daytime
  brightness, night brightness, brighten gradually, start/end time),
  ICEMAKER_NIGHTTIME, FLEX_ZONE
- Add time platform with rep_fn support for derived end-time entity;
  apply timezone.offset when reading/writing night schedule times
- Fix ice_making_status translation: move to entity.sensor section

CoAP/DTLS reliability:
- Fix Block2 SZX negotiation: adopt server's block size for block 1+
- Add per-block retransmission (3 attempts, 4s each)
- Add 5s asyncio.sleep before DTLS reconnect for TizenRT session teardown

Translations:
- Add HA translation_key system for ice type, beverage zone, flex zone,
  night brightness, door alarm; remove Python-level value_fn translation
2026-06-30 16:34:39 -05:00
Marc Billow 98fa7196f2 feat: cleaner temperature entity names and remove redundant sensors
- Add strip_prefix_in_key to Capability so pattern caps can drop their
  href_prefix segments from key derivation; temp caps use it so
  /temperature/desired/cooler/0 → "Cooler Setpoint" and
  /temperature/current/cooler/0 → "Cooler Temperature" instead of the
  verbose "Temperature Desired Cooler Temp F"
- Change TEMP_CURRENT key to 'temperature' and TEMP_SETPOINT to 'setpoint'
  to keep state keys distinct and names readable
- Number entities now render as sliders instead of text boxes
- Remove ICEMAKER_STATUS sensor (redundant with per-unit enabled switches)
- Remove Cabinet light level sensor and Cabinet light on binary sensor
  (redundant with Cabinet light switch)
2026-06-30 16:34:39 -05:00
Marc Billow 57cf1cdd11 fix: address code review findings from post-onboarding audit
- Add button platform (button.py + PLATFORMS) so oven/washer Start/Pause/Stop
  controls actually appear in HA
- Load DTLS cert chain from memory via _load_pem_chain(); eliminate all temp
  file writes for key material in config_flow and coordinator
- Fix PEM boundary: ensure leaf and CA blocks are separated by a newline in
  the stored fullchain regardless of whether the user pasted a trailing newline
- Dryer mode select: add static options tuple and value_fn to decode Course_XX
  hex strings back to human names; was permanently empty before
- Remove callable branch from LocalThingsSelect (options_field covers dynamic
  case; callable contract was broken — called with no args vs Callable[[dict]])
- Guard native_min/max_value overrides with hasattr + super() fallback to
  prevent AttributeError when native_min=None and range_field returns nothing
- Add async_close() public method to coordinator; __init__ now calls it instead
  of reaching in to _close_session directly
- Update tests: rename CONF_CERT_PEM/KEY_PEM → CA/LEAF variants, add
  leaf_cert_pem/leaf_key_pem to mock_probe return, fix mock PEM constants
  (no trailing newlines, matching what .strip() stores)
2026-06-30 16:34:39 -05:00
Marc Billow f25e616950 feat: mint leaf certs from CA keypair, fix DTLS SSL level, polish entity model
Config flow now accepts the AC14K_M CA cert+key, fetches the Samsung cloud
UUID from connect-v2.samsungiotcloud.com, mints a fresh RSA-2048 leaf cert
signed by the CA, and stores both the CA keypair and generated leaf for
reconnect. The DTLS context uses @SECLEVEL=0 in the cipher string — the only
channel that reaches cryptography's bundled OpenSSL — to permit the SHA-1
signed AC14K_M intermediate in Samsung's cert chain.

Entity model fixes:
- EntityCategory string values coerced to EntityCategory enum in entity.py
- SelectDesc gains options_field for live options from resource data; select
  registration skips entities where exists_fn rejects the initial resource
- NumberDesc gains range_field for live min/max from resource data
- Temperature sensors/numbers now carry device_class and unit (°F)
- Icemaker state sensor and on binary_sensor removed (duplicates of switch)
- Ice type select gated on x.com.samsung.da.iceType.supported presence
- Sabbath mode icon updated to mdi:hands-pray
2026-06-30 16:34:39 -05:00
Marc Billow f132d75f72 fix: use RTLD_DEFAULT in ctypes to hit same OpenSSL instance as pyOpenSSL 2026-06-30 16:34:39 -05:00
Marc Billow eb316580ae fix: send only leaf cert to avoid SHA-1 chain rejection
use_certificate_chain_file sends the full PEM chain including the
AC14K_M intermediate CA, which is SHA-1 signed. OpenSSL 3.x at
SECLEVEL=2 raises SSL_R_CA_MD_TOO_WEAK when it sees this cert — before
the handshake starts, before any verify callback fires.

Switch to use_certificate_file, which reads only the first PEM block
(the leaf cert, SHA-256 signed). Samsung's device already has AC14K_M
in its trust store and can verify the leaf without receiving the chain.

Also add verbose debug logging to _set_security_level so the logs show
exactly which API path ran and whether it succeeded, to aid diagnosis
if the error recurs after a library update.
2026-06-30 16:34:39 -05:00
Marc Billow 027768f7d7 fix: lower DTLS context security level to allow SHA-1 signed cert chain
The fullchain.pem we send as our client cert contains the AC14K_M
intermediate CA, which is SHA-1 signed. Samsung's OCF Root CA (trust
anchor) is also SHA-1 self-signed. OpenSSL 3.x SECLEVEL=2 rejects
SHA-1 in any certificate it touches — including our own outbound chain —
raising SSL_R_CA_MD_TOO_WEAK before the verify callback is reached.

Fix: call SSL_CTX_set_security_level(ctx, 1) before loading any certs.
Tries pyOpenSSL >= 24.0 ctx.set_security_level(), falls back to cffi,
then ctypes as a last resort. SECLEVEL=1 (80-bit minimum) allows SHA-1
while still enforcing meaningful key-strength constraints.
2026-06-30 16:34:39 -05:00
Marc Billow df4eeb0de5 fix: handle X509_V_ERR_CA_MD_TOO_WEAK in DTLS verify callback
@SECLEVEL=1 in the cipher string does not reliably lower the security
level for DTLS_METHOD contexts across all pyOpenSSL/OpenSSL builds.

Instead, explicitly allow error 66 (X509_V_ERR_CA_MD_TOO_WEAK) in the
verify callback. This is the error OpenSSL emits when a CA certificate
uses SHA-1 — which Samsung's OCF Root CA does. All other verification
errors (chain trust, expiry, key usage, revocation) are still enforced.
2026-06-30 16:34:39 -05:00
Marc Billow 58ecbaab46 fix: set SECLEVEL=1 to allow SHA-1 signed Samsung OCF Root CA
OpenSSL 3.x defaults to SECLEVEL=2, which rejects SHA-1 digests in the
certificate chain. Samsung's OCF Root CA is SHA-1 signed, so peer
verification failed with "ca md too weak" on every connect attempt.

SECLEVEL=1 (80-bit minimum) allows SHA-1 in the CA chain while still
enforcing meaningful key-strength and cipher constraints. Not going back
to SECLEVEL=0 which removes all restrictions.
2026-06-30 16:34:39 -05:00
Marc Billow b372bca377 feat: add translations and improve config flow UX
- translations/en.json: runtime translation file HA loads (strings.json
  is tooling-only); includes data_description hints for host, cert, key

- config_flow.py: cert_pem and key_pem rendered as multiline text areas
  via TextSelector so users can paste full PEM blocks; host stays single-line

- strings.json: expanded error messages and data_description to match

- entity.py: entities with name=None (pattern-cap doors, temps, icemmakers)
  now derive a human-readable title from their state key instead of
  displaying blank ("door_cooler_open1" → "Door Cooler Open 1")
2026-06-30 16:34:39 -05:00
Marc Billow cc2c1e5a1f refactor: move samsung_appliance/ into custom_components/localthings/ocf/
Makes the integration fully self-contained for HACS distribution.
Users installing via HACS get everything in one directory; no external
samsung_appliance package needed.

All component imports updated to relative (.ocf.*); all test imports
updated to custom_components.localthings.ocf.*. ocf_root_ca.pem moves
with the package and resolves correctly via Path(__file__).parent.
2026-06-30 16:34:20 -05:00
Marc Billow f7b58f00ee feat: replace MQTT bridge with localthings HA custom component
Convert from MQTT bridge architecture to a proper Home Assistant
integration (custom_components/localthings/):

- Config flow: IP-only entry, probes ports [49154,49155], cert/key
  reused from first device on subsequent adds
- Coordinator: DataUpdateCoordinator wrapping blocking DTLS session
  via async_add_executor_job; discovery on first poll; dynamic 5s/30s
  interval based on active_when
- Entity platforms: sensor, binary_sensor, switch, number, select
- Library cleanup: delete bridge.py, main.py, config.py, logger.py,
  keepalive.py, poll_scheduler.py, sensors.py, state_cache.py
- adapter.py stripped to flatten(), is_active(), _key()
- _parse_device0_batch moved to samsung_appliance/batch.py
2026-06-30 16:34:06 -05:00