Commit Graph
31 Commits
Author SHA1 Message Date
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
Jack Nagy 46a930eb71 Production-hardening pass: half-open detection, cascade throttle, OBSERVE refresh, lamp/door coupling
Four failure modes observed in-house since the polling-first refactor
(b00c2fd):

1. Half-open DTLS sessions where the socket stays writable but the peer
   has gone silent. Ping sends succeed against a wedged peer because
   RT-OCF doesn't reliably emit a RST; only successful polls prove the
   session is live.

   - PollScheduler exposes last_success_ts (bumped on every 2.05).
   - KeepaliveTask takes liveness_fn(); ticks fail if no 2.05 in the
     last 60s, even when the ping send succeeded.
   - Bridge force-closes the session after 120s unreachable so
     run_forever() breaks out of sess.join() and reconnects.

2. RT-OCF cascade under load. One wedged path can eat 8s of timeout,
   the next tier tick fires immediately and stacks another attempt,
   and the device wedges harder.

   - PollTier.timeout_s per-tier override (hot=2s, warm=4s, sweep=15s).
   - On TimeoutError, the href goes into a 5-60s cooldown via the
     existing _defer_until mechanism.
   - take_window_stats() now reports successful-poll RTT separately
     from a timeout count, exposed as the "Poll Timeouts (window)"
     diagnostic entity in HA.
   - Active-window throttle: if the previous health window saw >=3
     timeouts and is_active=True, drop back to idle cadence -- stops
     stacking polls on a stalled responder.

3. OBSERVE table aging across cloud-auth blips. The device stays
   DTLS-reachable but the on-device stack clears its observer table
   during the blip, so push delivery stays dead even after upstream
   recovers.

   - New ObserveRefreshTask per bridge; every 6h derregs all current
     observer tokens and re-subscribes on the existing session.

4. Oven lamp/door coupling. Oven hardware auto-drives the lamp from
   door state but /mode/vs/0 is warm-tier (30s) so HA showed stale lamp
   during a cook.

   - Track door + lamp value-change timestamps in descriptor_state.
     When the door transition is newer, derive lamp from door_open.
     When an HA optimistic write is newer, the cache value wins.

Also: ANSI-coloured WARNING/ERROR lines (NO_COLOR=1 opt-out), jittered
reconnect backoff so dryer + oven don't sync up after a router blip.

In-house verification: running on dryer + oven since 2026-06-03.
2026-06-30 19:27:59 +01:00
Jack Nagy caf195ea1a Ship setup_cert.py to repo root, auto-fetch all CA materials
Closes #2.

Previously the cert minting script lived in local-tools/ (gitignored)
and the README pointed at a cert-only source that didn't include the
private key or upstream chain.

setup_cert.py now lives at the repo root and live-fetches both the
peer UUID (from the relevant TLS server cert subject DN) and the
full AC14K_M + upstream chain bundle (RemoteAccessCA + CECA + ROOTCA)
from a public mirror. Each fetch has an inline workaround if the
network is restricted (UUID=..., AC14K_M_CERT_BUNDLE=...,
BRAYSTORM_URL=...). Modulus-pair check catches a wrong-key mistake
before signing. bootstrap.py removed -- imported a package that was
renamed in commit b00c2fd.

Output files use neutral client.* names. README, .env.example,
docker-compose.yml, deploy.sh, and config.py updated to match.

Provenance receipts in local-tools/cert_provenance.md.
2026-06-30 19:27:24 +01:00
Jack Nagy b00c2fd90b Refactor to polling-first architecture with OBSERVE as accelerator
State freshness now comes from a tiered PollScheduler over the persistent
DTLS session; OBSERVE registrations are kept as an opportunistic
acceleration layer. Behaviour is identical online vs air-gapped except
for worst-case freshness latency.

Adds three modules:
- StateCache: single source of truth, source-tagged change events
- PollScheduler: hot/warm/cold + sweep tiers, write-defer past the
  fetchback-revert window, per-window RTT/slow-poll tracking
- KeepaliveTask: CoAP empty-CON ping with consecutive-fail detection
  driving MQTT availability

Bridge publishes per-appliance diagnostic entities (Push Active, Last
Update Source, Poll Max RTT, Slow Polls, Poll Errors, Stalest Resource
Age, Last OBSERVE Age) under HA's Diagnostic section. Tier cadences
are descriptor-declared, calibrated against measured per-firmware
ceilings (dryer ~14 req/s, oven ~8 req/s via probe_poll_rate_combined.py).

Drops HEARTBEAT_INTERVAL_S in favour of the descriptor-declared sweep
tier; PING_INTERVAL_S now consumed by KeepaliveTask inside the bridge
rather than driven from main.py.

README explains the push/poll split and what happens when the appliance
is blocked from internet.
2026-06-03 18:38:04 +01:00
Jack Nagy dbc9a57f1b Add oven controls and fix Samsung-OCF write semantics
Major session of local-OCF reverse engineering against the NV7000BS
oven and DV5000T dryer. Surfaces a working set of HA entities for the
oven and resolves several Samsung-quirk regressions in the bridge's
write path.

Key behavioural fixes:
- OBSERVE registrations now use single-byte tokens. Samsung RT-OCF
  silently drops registrations with TKL>1; same 4-byte tokens work
  fine for GET/POST. Symptom was that writes returned 2.04 but the
  appliance never pushed state changes.
- Per-session random starting tokens + MID. Samsung retains observer
  state across DTLS reconnects from the same cert; reusing tokens on
  reconnect silently no-ops.
- OBSERVE deregister sent on DtlsCoapSession.close(), with a stop-
  watcher thread in PushBridge.session_once() so SIGINT/SIGTERM
  actually reaches close() instead of hanging in sess.join().
- pyOpenSSL is not thread-safe — reader-loop conn.* calls now hold
  the same _send_lock the sender uses, dispatching decrypted packets
  outside the lock so the auto-ACK send doesn't deadlock.
- Periodic CoAP Ping (RFC 7252 §4.4) keepalive to keep DTLS warm.
- Post-write Block2 fetchback REMOVED. It was the root cause of
  every "setpoint/operationTime/modes revert ~3s after write"
  symptom — Samsung's stack treats a read on a freshly-written
  resource as a signal to invalidate that write. OBSERVE pushes
  keep HA in sync without the verification GET.

HA-facing changes (oven):
- New entities: Lamp (light), Sound (switch), Fast preheat, Natural
  steam, Setpoint (number), Cook time (number), Stop cycle (button).
- Cooking mode surfaced as a read-only sensor — the oven owns the
  modes field once a cycle is active and rolls local writes back.
- Cook time writes operationTime + remainingTime on
  /operational/state/vs/0 (discovered via OBSERVE capture of
  SmartThings mid-cycle changes — UpperTimerSet on /mode/vs/0
  options is vestigial and doesn't drive the running cycle).
- New cycle_active MQTT availability topic. Writes the oven only
  honours mid-cycle (setpoint, cook time, fast preheat, natural
  steam, stop) gate on it via avail_with_cycle / avail_with_remote_
  and_cycle. Sound + Lamp remain always-available.
- Cycle Start deliberately NOT exposed. Every byte-level approxi-
  mation of SmartThings's working start sequence is rejected at
  the firmware level. Empty discovery payloads remove the previous
  Start button and Cooking-mode select cleanly from HA.

Diagnostics:
- DEBUG_BRIDGE=1 env var enables verbose tracing (rx CON/NON/ACK/
  RST per frame, full link-tree dump at seed, /oic/res directory,
  REP changes on /operational/state, /oven, /power, mode options).
  Quiet in production.
2026-05-31 20:43:47 +01:00
Quite Yellow f577a32c50 Update README.md 2026-05-31 15:51:43 +01:00
Jack Nagy c99ef324fc Initial commit 2026-05-31 15:50:15 +01:00