ADR-0005: Universal IR — code database & rendering

Status

Accepted

Area

Infrared

Context

Universal IR is a real code DATABASE, not hardcoded codes (rewritten after research + on-device failure). There is no single "Samsung code": vendors change IR protocol/command maps across models, so a curated slice of a code database is required rather than a fixed table per brand.

The transmit path is fundamentally constrained: Android’s ConsumerIrManager is transmit-only — there is no way to learn a code from the real remote.

The original hardcoded Samsung was wrong twice: NEC 9000µs header instead of Samsung32’s 4500µs, and power cmd `0x40 instead of 0x02 — so the TV never recognised the frame. The DB + correct renderers fix this.

Decision

IR encoding in tv-core

IR encoding is pure and in tv-core::ir (NEC family, 38 kHz, header + 32 LSB-first bits + stop), so it is fully unit-tested with no hardware. Ships a samsung_tv code set (Samsung’s 0xE0E0 custom code); other brands slot in as new tables.

Android-only transmit plugin

Transmit is an Android-only Tauri plugin (IrPlugin.ktConsumerIrManager.transmit). The Rust ir_send_key command computes the pattern in tv-core and forwards it; on desktop (no emitter) it returns a clear error.

android.permission.TRANSMIT_IR is required — found on-device: without it ConsumerIrManager.transmit throws SecurityException ("permission never granted" warning). Added to the manifest + a consumerir uses-feature.

IR pseudo-devices

IR devices are client-only pseudo-devices in the UI (id ir:<code_set>, vendor ir); they skip pairing/persistence and route key presses to ir_send_key instead of a TvBackend.

Code database & wizard

The code database embeds a curated slice of Flipper-IRDB (crates/tv-core/irdb/<Brand>/.ir, 19 TV brands, 87 code sets) and renders each .ir entry’s (protocol, address, command) to timings. The UI is a *find-code wizard (send POWER → "did it react?" → next/save), because Android’s ConsumerIrManager is transmit-only (no way to learn a code from the real remote).

Rendered protocols: NEC, NECext, Samsung32, SIRC/15/20, RC5/RC5X (+ raw passthrough).

RC5/RC5X rendering

RC5/RC5X are now rendered (Manchester bi-phase, 36 kHz). 14 bits MSB-first (S1=1; S2=1 for RC5 or !cmd bit6 for RC5X; toggle T; 5 addr + 6 cmd bits), half-bit 889µs. We build half-bit levels then coalesce equal-level runs into durations; the pattern is forced to start (and end) on a mark by folding the leading/trailing space into the inter-frame gap. This unblocks Grundig/Philips volume/power, which previously rendered nothing.

RC5 toggle: a module-level AtomicU32 flips on every rendered frame, so two taps of the same key send different-toggle frames → the TV scores each tap as a fresh press = exactly one step (no runaway auto-repeat). Deterministically unit-tested; the actual RC5 waveform on a real TV is unverifiable without an IR blaster.

Raw-frame trimming

Over-captured raw frames are trimmed to the first frame on parse: some .ir raw entries were captured with the button held, so data: holds a frame + inter-frame gap + repeat, making the TV register multiple presses (Grundig volume stepped 2× per tap). We cut at the first OFF slot (odd index) whose duration exceeds 15 ms — a real inter-frame gap; intra-frame spaces are < ~2 ms — leaving one clean frame ending on a mark (unchanged if no such gap, never trimmed below 3 slots).

Bidirectional model-scan matching

Model-scan matching is bidirectional (find_by_model): the query is a whole OCR blob off the back-of-set sticker, so we match if the normalized id/label contains the query (short typed model) OR any of a set’s model tokens (filename minus brand prefix, split, normalized, len ≥ 4) is contained in the query (a blob mentioning the model). Fixes OCR finding nothing because the old code only matched id/label containing the query.

Consequences

The DB + correct renderers fix the original hardcoded-Samsung failure. Covered rendered protocols are NEC, NECext, Samsung32, SIRC/15/20, and RC5/RC5X, plus raw passthrough.

RC6/Kaseikyo/RCA parsed entries are still skipped unless a raw capture exists — a residual coverage gap for some Philips/Panasonic sets.

RC6 is deliberately left None: it needs a leader, mode bits, a double-width toggle bit and the OPPOSITE Manchester convention to RC5. Rather than ship a guessed frame that silently fails on hardware, it stays unsynthesized (raw captures still work). Follow-up if an RC6-only set is needed.

What is unverifiable without an IR blaster: the transmit path builds and the class is verified present in the APK, but it can only be functionally verified on a phone that has an IR blaster; likewise the actual RC5 waveform on a real TV is unverifiable without one.