Files
denon-bluetooth/PROJECT_CONTEXT.md
ckoch a5d2865eb2 Initial commit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:59:47 -04:00

143 lines
7.4 KiB
Markdown

# Denon AVR-S540BT Bluetooth Remote — Project Context
## What We're Building
A custom Python application to control the Denon AVR-S540BT AV receiver over
Bluetooth, replicating the functionality of the official "Denon 500 Series Remote"
Android app without relying on it.
## How We Got Here
### Discovery
The AVR-S540BT has no network (no Ethernet, no WiFi). The only remote control
interface is Bluetooth, used by Denon's own "Denon 500 Series Remote" app.
Denon does not publish the Bluetooth control protocol.
### Reverse Engineering Method
1. Enabled Bluetooth HCI snoop log on a Pixel 10 Pro XL (Android Developer
Options → Enable Bluetooth HCI snoop log)
2. Used the official Denon app to perform actions: connect, volume up/down,
input selection, etc.
3. Extracted the log via `adb bugreport` (direct `adb pull` is blocked on
Android 15) and unzipped the btsnoop_hci.log from inside the zip
4. Parsed the binary BTSnoop v1 file in Python (no external dependencies)
and identified ACL data packets containing the string "AT" (0x41 0x54)
5. Decoded the packet structure
### Protocol Details
- **Transport**: Bluetooth Classic, RFCOMM (Serial Port Profile)
- **Channel**: 2 (service name "BT SPP 2", confirmed via SDP in the log)
- **Framing**: Each packet starts with bytes `41 54` ("AT"), followed by
binary command bytes. The trailing byte in raw HCI is an RFCOMM FCS
(Frame Check Sequence) added by the kernel — NOT part of the application
payload. When using `socket.AF_BLUETOOTH / BTPROTO_RFCOMM`, the kernel
handles framing automatically; you just read/write raw application data.
### Decoded Commands
#### Phone → AVR (commands)
| Action | Bytes (hex) | Notes |
|------------------|------------------------------|-----------------------------|
| Volume Up | `41 54 07 00 00 00` | |
| Volume Down | `41 54 07 01 00 00` | |
| Mute On | `41 54 07 1D 01 01 FE` | Discrete on |
| Mute Off | `41 54 07 1D 01 00 FF` | Discrete off |
| Power Off | `41 54 00 0A 01 00 FF` | |
| Power On | `41 54 00 0A 01 01 FE` | |
| Get Input Sources| `41 54 00 04 00 00` | |
| Get Status | `41 54 00 06 00 00` | |
| Select Input | `41 54 00 07 01 [src] [chk]` | chk = 0xFF XOR src |
| Mute Toggle* | `41 54 07 25 01 20 DF` | Used in handshake, not user |
#### AVR → Phone (responses)
| Response | Bytes (hex) | Notes |
|-------------------|------------------------------------------------------|------------------------------------------|
| Volume Report | `41 54 07 02 03 C5 [vol] 00 [chk]` | vol/2 = display value, chk = 0xFF-vol+1 |
| Mute State | `41 54 07 1D 01 [state] [chk]` | state: 01=muted, 00=unmuted |
| Sound Mode | `41 54 07 1E 15 00 [20 bytes text] [chk]` | ASCII, space-padded, 27 bytes total |
| Power Ack | `41 54 07 0B 01 00 FF` | Sent by AVR on power off |
| Input Sources | `41 54 00 04 0A 07 40 81 55 82 52 53 54 56 57 B1` | 0x81/0x82 are category separators |
| Current Input | `41 54 00 07 02 [src] 00 [chk]` | |
| Power Echo | `41 54 00 0A 01 [state] [chk]` | AVR echoes power state back |
| Status Keepalive | `41 54 00 0B 01 07 F8` | Sent by AVR after power on |
| Device Name | `41 54 00 0E 0A 00 [name bytes] [chk]` | Returned " DENON AV", 16 bytes total |
| Status Response | `41 54 00 06 45 ... [75 bytes]` | Full status dump |
#### Known Input Source Byte Codes
These were observed in the log. Exact label↔code mapping is approximate and
should be confirmed by testing:
| Byte | Label |
|------|-----------|
| 0x40 | Bluetooth |
| 0x55 | Media Player | ('U' — label approximate, needs confirmation)
| 0x52 | CBL/SAT |
| 0x53 | DVD |
| 0x54 | Blu-ray |
| 0x56 | Game |
| 0x57 | AUX |
### Second Capture (Session 2)
Actions performed: volume 20.5→21, mute on, mute off, volume down, mute on,
mute off, power off, power on, power off, power on.
**New findings:**
- **Mute** is discrete on/off (07 1D), not a toggle. The 07 25 command seen
in the first capture is part of the connection handshake, not user mute.
- **Power** uses 00 0A with 01 00 = off, 01 01 = on.
- After power on, the AVR sends a status keepalive (00 0B 01 07) and the
Denon app replays the mute-toggle handshake (07 25) before resuming.
- **Volume display mapping**: raw byte / 2 = display value (e.g., 0x29=41
→ display "20.5", 0x2a=42 → display "21").
- Source byte **0x55** ("Media Player"?) discovered in sources list.
- **0x81 / 0x82** in the sources response are category separators, not sources.
### Commands Not Yet Captured
The following still need to be reverse engineered:
- Individual sound mode selection (only the status report was seen)
- Tuner controls (band, tune up/down, preset)
- Quick Select buttons
## Current Codebase
### denon_remote.py
A Python 3 tkinter application (stdlib + optional python3-dbus for device
auto-detection).
**Key design decisions:**
- Uses `socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM`
(Linux built-in, no PyBluez needed)
- Receive loop runs in a background daemon thread
- All UI updates routed through a `queue.Queue` polled with `after(50,...)`
to keep tkinter thread-safe
- Packet length detection is best-effort heuristic based on known packet types;
unknown packets fall back to reading until the next `AT` header or 32 bytes
- Input source buttons are rendered dynamically from the AVR's own source list
response rather than being hard-coded
- Auto-detects paired Denon devices via BlueZ D-Bus (falls back gracefully
if python3-dbus is unavailable)
- Power on/off and discrete mute on/off commands implemented
- Mute state tracked and reflected in UI (button changes to red "MUTED")
- Volume displayed as the AVR's display value (raw_byte / 2)
**Known limitations / things to improve:**
- Packet length detection covers all observed packet types but unknown types
may still be mis-framed
- Input source label mapping is approximate (especially 0x55 "Media Player")
- No reconnect logic if the connection drops
- Sound mode selection not implemented (only displays current mode)
- The UI aesthetic uses Courier as a monospace font — works everywhere but
could use a proper monospace like JetBrains Mono or similar if available
## Environment
- **Dev machine**: Linux (Ubuntu/Debian), user ckoch on host Shinobu
- **Receiver**: Denon AVR-S540BT
- **Test phone**: Pixel 10 Pro XL (Android 15)
- **Tools used**: adb (34.0.4), Wireshark 4.2.2, Python 3.12
## Next Steps
1. Test power on/off and mute on/off against the real receiver
2. Confirm the 0x55 source label (likely "Media Player" or "USB")
3. Do a third btsnoop capture targeting: sound mode switching, tuner, quick
select
4. Add reconnect logic for dropped connections
5. Build out a more complete and polished application