Commit Graph
56 Commits
Author SHA1 Message Date
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 8d43d54952 chore: add Docker Compose dev environment for HA testing
docker-compose.yml:
- Uses ghcr.io/home-assistant/home-assistant:stable
- network_mode: host (required for DTLS/UDP to reach LAN appliances)
- custom_components/localthings mounted read-only into /config/custom_components/
- ha_config/ volume for persistent HA state

ha_config/configuration.yaml:
- Minimal config (frontend, http, logger)
- localthings logged at DEBUG, everything else at warning

ha_config/ gitignored — contains auth tokens and secrets after onboarding
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 0bb27a9204 test: scaffold pytest-homeassistant-custom-component test harness
Adds HA component tests covering the localthings integration layer
(previously only the samsung_appliance library was tested):

- pyproject.toml: asyncio_mode=auto for async HA test patterns
- tests/localthings/conftest.py: hass_config_dir override, mock fixtures
  for probe and coordinator session, fridge fixture loader
- test_config_flow.py: first-device form, second-device cred reuse,
  happy path, cannot_connect error, duplicate-device abort (5 tests)
- test_coordinator.py: discovery on first refresh, device_info populated,
  idle interval default, UpdateFailed on persistent poll error (4 tests)
- test_sensor.py: sensors registered, sensor state from coordinator (2 tests)

Total: 70 library + 11 component = 81 tests
2026-06-30 16:34:10 -05:00
Marc Billow ee779c955b remove TODO_SECURITY.md — all findings resolved 2026-06-30 16:34:10 -05:00
Marc Billow d5dc92e673 fix: enable DTLS peer verification against Samsung OCF Root CA
Bundle certs/ocf_root_ca.pem as samsung_appliance/ocf_root_ca.pem and
switch from SSL.VERIFY_NONE to SSL.VERIFY_PEER. The appliance's server
certificate must now chain to the Samsung Electronics OCF Root CA,
closing the ARP-spoof / MITM attack surface on the local network.

.gitignore: carve out explicit exception for the bundled public CA cert.
2026-06-30 16:34:10 -05:00
Marc Billow ceab7585d9 fix: drop @SECLEVEL=0 and SHA-1 cert signing
Firmware accepts SHA-256-signed leaves (confirmed in TODO_SECURITY.md §3).
@SECLEVEL=0 was only needed to allow SHA-1 signatures past OpenSSL 3.x's
default security level — no longer required.

- coap_dtls.py: cipher string is now ECDHE-ECDSA-AES128-GCM-SHA256 (no seclevel override)
- local-tools/setup_cert.py: sign with sha256 instead of sha1
- TODO_SECURITY.md: mark MQTT finding resolved (bridge removed), mark SHA-1/SECLEVEL resolved
2026-06-30 16:34:10 -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
Marc Billow 785154eb83 refactor: replace exact-href door/temp/icemaker caps with generic pattern caps
Delete dead: DOOR_FRIDGE, DOOR_FREEZER, TEMP_FRIDGE/FREEZER_CURRENT/SETPOINT,
ICEMAKER_ONE, ICEMAKER_TWO, ICE2_MODES, temperature bounds constants.

Add: TEMP_CURRENT_GENERIC (href_prefix=/temperature/current/),
TEMP_SETPOINT_GENERIC (href_prefix=/temperature/desired/, write derives
CoAP path from href), ICEMAKER_GENERIC (href_prefix=/icemaker/,
match_fn gates on iceMaker.state field).

Key derivation strips 'vs' segments alongside digits so icemaker hrefs
like /icemaker/one/vs/0 produce icemaker_one_state not icemaker_one_vs_state.

write_fn signature extended: adapter now passes href as 3rd positional arg;
all existing write_fns updated to accept href=None.

Fridge golden: old ice1_*/ice2_*/fridge_*/freezer_* keys replaced by
icemaker_one_*/icemaker_two_*/temperature_current_*/temperature_desired_*.
2026-06-30 16:33:36 -05:00
Marc Billow 9e5a169c82 refactor: drop resources fallback; fixtures are device0-only 2026-06-30 16:33:36 -05:00
Marc Billow fd2aa9e64d fix: replace IP-named dumps with scrubbed named fixtures
Rename tests/fixtures/dumps/{IP}.json to tests/fixtures/{name}_device.json.
Scrub PII: serialNum, otnDUID, modelNum, diagEndPoint replaced with
TEST-* placeholders. conftest: _load_device(name) as primary API;
_load_resources(ip) kept as legacy shim for existing test call sites.
2026-06-30 16:33:36 -05:00
Marc Billow 27d6d09fd9 refactor: auto-derive pattern cap keys from href; move dumps to tests/fixtures
Remove key_fn from Capability — pattern cap keys are now always derived
automatically: strip digits from href segments, join with '_', append
'_' + desc.key. /door/cooler/0 + 'open' -> door_cooler_open.

DOOR_GENERIC entity key: 'open' (not 'door_open'). Remove _door_key helper.
Fridge registry: DOOR_FRIDGE/DOOR_FREEZER replaced by DOOR_GENERIC pattern cap.
Golden baseline: door_fridge_open -> door_cooler_open.

Move device dumps to tests/fixtures/dumps/ (tracked) so tests run in CI
without local-tools/ dependencies.
2026-06-30 16:33:36 -05:00
Marc Billow 1c4cb2e272 fix: address final review findings
- for_device(): suffix fallback for descriptive names
  ("french_door_refrigerator" → refrigerator registry)
- Capability.href_prefix: replaces rt_filter for pattern caps;
  DOOR_GENERIC now uses href_prefix='/door/' (batch reps have no rt)
- discover(): apply href_prefix gate before rt_filter in pattern loop
- Remove dead bound_hrefs variable from discover()
- Remove vestigial default_observe_port from RuntimeDescriptor
2026-06-30 16:33:36 -05:00
Marc Billow 6f681622c2 fix: remove unused request param, use context manager in e2e v2 test 2026-06-30 16:33:36 -05:00
Marc Billow ee7ae10481 test: add v2 e2e smoke test (batch + per-type registry full pipeline) 2026-06-30 16:33:36 -05:00
Marc Billow d4ff29d797 feat: switch conftest + golden tests to batch-first resource loading
conftest: _resources_from_dump() prefers /device/0 batch reps; falls
back to flat resources dict. Fixes _parse_device0_batch to include
stub reps (rep={href:...}) as {} so capabilities still bind on
resources with no current data (e.g. /alarms/vs/0 when no alarm active).

test_golden_regression: _new_state_and_uids now uses by_type.for_device
to select per-device-type capabilities + pattern caps.
2026-06-30 16:33:36 -05:00
Marc Billow 2b22673210 refactor: rewire bridge to batch-poll /device/0 + per-type registry
Replace PollScheduler + OBSERVE with:
- _parse_device0_batch() extracts {href: rep} from /device/0 list response
- _discover_and_publish() uses by_type.for_device(oneUiVersion) for
  device-specific capability registry; falls back to global CAPABILITIES
- _poll_loop thread: batch GET /device/0 at active_interval_s or
  idle_interval_s; applies each rep to the state cache
- All OBSERVE machinery removed (subscribe, notifications, fetchback)
2026-06-30 16:33:36 -05:00
Marc Billow 15f462563d refactor: simplify RuntimeDescriptor — drop observe/poll_tiers, add intervals
Remove observe_paths and poll_tiers from RuntimeDescriptor; add
active_interval_s (default 5s) and idle_interval_s (default 30s).
Delete _make_observe_paths() and _make_poll_tiers() helpers.
Bridge rewire in Task 6 will consume the new fields.
2026-06-30 16:32:42 -05:00
Marc Billow 1b36ec4a59 feat: fill per-device-type registries; add DOOR_GENERIC, OVEN_CONNECTED, dryer caps
- DOOR_GENERIC pattern cap (rt_filter=oic.r.door) in fridge.py with key_fn
- OVEN_CONNECTED (cloud connectivity) added to oven caps
- dryer.py: DRYER_SETTINGS, DRYER_COURSE, JOB_BEGINNING_STATUS, DRYER_DIAGNOSIS
- Dishwasher, refrigerator, oven, dryer DeviceRegistry objects fully populated
- Refrigerator registry includes DOOR_GENERIC as pattern cap
- Oven registry includes OVEN_SETPOINT + OVEN_MODE (safe: no fridge caps in oven registry)
2026-06-30 16:32:42 -05:00
Marc Billow e801af9ab7 feat: add by_type/ registry package with for_device() and DeviceRegistry 2026-06-30 16:32:42 -05:00
Marc Billow b10cfad194 feat: update discover() for pattern caps, match_fn, rt_filter gating
Registry changes from dict[str,Capability] to dict[str,list[Capability]].
discover() gains pattern_caps parameter (fallback for unmatched hrefs)
and gates each candidate on rt_filter and match_fn before binding.
Duplicate hrefs in registry allowed only if all caps set a discriminator.
2026-06-30 16:32:42 -05:00
Marc Billow 211fcd1324 fix: remove colliding oven caps from global ALL; update golden baselines
OVEN_SETPOINT (/temperatures/vs/0) and OVEN_MODE (/mode/vs/0) collide
with fridge hrefs. Remove from ALL; only OVEN_CAVITY (/oven/vs/0) is
oven-unique. Per-device-type registries (v2 plan) will restore them in
the oven-only registry.

Also updates golden baselines to include legitimate new entities added
since original capture (cycle_active, firmware_update, power_switch for
dishwasher; cabinet_light_switch, ice1 for refrigerator).
2026-06-30 16:32:42 -05:00
Marc Billow 81d97e2fb5 Task 1: Simplify Capability; extend BoundEntity
- Make href Optional[str] = None for pattern capabilities
- Remove observe field (v2 architecture observes all hrefs)
- Add rt_filter, match_fn, key_fn fields to Capability
- Add entities default value tuple[()] to Capability
- Add key_override field to BoundEntity
- Update _key() to use key_override when present
- Update _make_observe_paths() to observe all unique hrefs (OBSERVE as accelerator)
- Guard _build() to skip pattern capabilities (href is None)
- Add test_bound_entity_key_override_takes_precedence test
- Update test_capability_defaults to remove observe assertion

All 61 tests passing (60 existing + 1 new).
2026-06-30 16:32:42 -05:00
Marc Billow 1a76bf6d47 Fix C1 (switch discovery), C2 (power write_fn), I2 (golden oracle)
C1: SwitchDesc MQTT discovery now emits payload_on/off='On'/'Off',
state_on/off='On'/'Off', and a Jinja2 boolean-to-string value_template
so HA can correctly interpret and command switch entities.

C2: POWER write_fn corrected — path is ['power','vs','0'] segment list
(not a single slash-prefixed string), and payload comparison is
p == 'On' (not truthiness) so 'Off' can actually be sent.

I2: Golden regression oracle strengthened to exact equality for both
state_keys and discovery_unique_ids; prior subset-only check allowed
over-production to go undetected. Two golden tests now fail revealing
stale fixtures (dishwasher: +3 keys; refrigerator: +9 keys) — fixtures
intentionally NOT updated, human review required.
2026-06-30 16:32:42 -05:00
Marc Billow 5906aabd7c test: end-to-end discovery pipeline over real dumps 2026-06-30 16:32:42 -05:00
Marc Billow cd146ed2f0 refactor: discover entities at connect; delete appliance descriptor files
PushBridge no longer takes a descriptor at construction time.  Instead it
discovers entities from the live device cache immediately after the seed
(via registry.discover + build_runtime_descriptor), publishes HA discovery
payloads, wires on_observation, and builds command handlers — all in the
new idempotent _discover_and_publish(sess) method called from
_run_session_inner.

Changes:
- bridge.py: remove descriptor param; self.descriptor=None until first
  connect; SEED_PATH constant replaces self.descriptor.seed_path; all
  self.descriptor accesses guarded or moved post-discovery; inlined
  _bridge_diagnostic_discovery (was in appliances/base.py); subscribe to
  observe paths now happens after discovery (seed → discover → subscribe).
- config.py: klass is now optional (defaults to ''); topic_prefix/name
  defaults handle empty klass.
- main.py: drop get_descriptor import and pairs loop; build bridges as
  PushBridge(shared, app, cli); on_connect no longer publishes
  discovery_payloads (bridge self-publishes at discovery time).
- Delete samsung_appliance/appliances/ entirely (6 files, ~2600 lines).
2026-06-30 16:32:42 -05:00
Marc Billow 35222e6be5 refactor: make StateCache descriptor-free 2026-06-30 16:32:37 -05:00
Marc Billow f2c1d8391e feat: read device identity from OCF /oic/p and /oic/d 2026-06-30 16:32:37 -05:00
Marc Billow 246379acbc feat: assemble CAPABILITIES registry with duplicate-href guard 2026-06-30 16:32:37 -05:00
Marc Billow 23d5090faa feat: add oven capabilities and cycle-active gating 2026-06-30 16:32:37 -05:00
Marc Billow 9cbeb6a2b2 feat: add refrigerator capabilities with multi-instance support 2026-06-30 16:32:37 -05:00
Marc Billow 23b4cdc315 feat: add laundry-family capabilities; dishwasher golden green 2026-06-30 16:32:37 -05:00
Marc Billow d720b29a67 test: capture golden baselines from existing descriptors
Generates regression oracle from DISHWASHER and REFRIGERATOR descriptors:
- tests/fixtures/golden/dishwasher.json: 21 state keys, 23 discovery entities
- tests/fixtures/golden/refrigerator.json: 26 state keys, 24 discovery entities
- tests/test_golden_regression.py: parametrized test that will turn green as
  Tasks 9-12 port capabilities into the new registry
2026-06-30 16:32:37 -05:00
Marc Billow 299719f727 feat: add adapter building RuntimeDescriptor from bound entities 2026-06-30 16:32:37 -05:00
Marc Billow 6778c60a09 feat: add operational-state capability with extrapolation hooks 2026-06-30 16:32:37 -05:00
Marc Billow 0499c702d7 refactor: key capabilities on href instead of rt
OCF rt is a schema category shared by many unrelated resources.
The stable unique resource address is the href. Update Capability,
discovery, common capabilities, tests, and plan accordingly.
2026-06-30 16:32:37 -05:00
Marc Billow bdae3cc82d feat: add shared common capabilities 2026-06-30 16:32:37 -05:00
Marc Billow 73fff9c2cf fix: log each unknown rt individually in discover() 2026-06-30 16:32:37 -05:00
Marc Billow 8d63610f82 feat: add runtime capability discovery
Implement discovery module for converting OCF resource metadata into bound
entities, with support for multi-instance device resources and logging of
unknown capabilities.
2026-06-30 16:32:37 -05:00
Marc Billow 9b2eef26f7 feat: add Capability dataclass 2026-06-30 16:32:37 -05:00
Marc Billow 9acc6bc8ce feat: add HA-shaped entity description dataclasses 2026-06-30 16:32:37 -05:00
Marc Billow c6f08ea340 test: add pytest infra and device-dump fixtures 2026-06-30 16:32:37 -05:00
Jack Nagy 00ff961d33 Drop dangling local-tools/ references from README 2026-06-30 19:51:36 +01:00
Quite Yellow 6f703fc148 Update README.md 2026-06-30 19:49:02 +01:00
Quite Yellow 79c466de0c Merge pull request #5 from QuiteYellow/production-hardening
Production-hardening pass: half-open detection, cascade throttle, OBSERVE refresh, lamp/door coupling
2026-06-30 19:36:09 +01:00
Quite Yellow 6daf142175 Merge pull request #3 from QuiteYellow/cert-refactor
Ship setup_cert.py to repo root, auto-fetch all CA materials
2026-06-30 19:33:41 +01:00