Initial commit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
.env
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
*.swp
|
||||
*~
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -0,0 +1,142 @@
|
||||
# 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
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Denon AVR Bluetooth integration for Home Assistant."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN # noqa: F401
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Denon BT from a config entry."""
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Config flow for Denon AVR Bluetooth integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from .const import DOMAIN
|
||||
from .protocol import find_denon_devices
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_MAC_RE = re.compile(r"^([0-9A-F]{2}:){5}[0-9A-F]{2}$", re.IGNORECASE)
|
||||
|
||||
MANUAL_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("mac"): str,
|
||||
vol.Optional("name", default="Denon AVR-S540BT"): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class DenonBTConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Denon AVR Bluetooth."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._discovered: list[dict[str, Any]] = []
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Entry point — try D-Bus discovery, fall back to manual."""
|
||||
self._discovered = await self.hass.async_add_executor_job(
|
||||
find_denon_devices
|
||||
)
|
||||
if self._discovered:
|
||||
return await self.async_step_select()
|
||||
return await self.async_step_manual()
|
||||
|
||||
# ── Device selection ─────────────────────────────────────────────────────
|
||||
|
||||
async def async_step_select(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Let the user pick a discovered device or go manual."""
|
||||
if user_input is not None:
|
||||
chosen = user_input["device"]
|
||||
if chosen == "_manual":
|
||||
return await self.async_step_manual()
|
||||
|
||||
for dev in self._discovered:
|
||||
if dev["mac"] == chosen:
|
||||
await self.async_set_unique_id(dev["mac"].upper())
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=dev["name"],
|
||||
data={"mac": dev["mac"], "name": dev["name"]},
|
||||
)
|
||||
|
||||
options = {
|
||||
dev["mac"]: f"{dev['name']} ({dev['mac']})"
|
||||
for dev in self._discovered
|
||||
}
|
||||
options["_manual"] = "Enter MAC address manually\u2026"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="select",
|
||||
data_schema=vol.Schema(
|
||||
{vol.Required("device"): vol.In(options)}
|
||||
),
|
||||
)
|
||||
|
||||
# ── Manual MAC entry ─────────────────────────────────────────────────────
|
||||
|
||||
async def async_step_manual(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle manual MAC address entry."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
mac = user_input["mac"].strip().upper()
|
||||
name = user_input.get("name", "Denon AVR")
|
||||
|
||||
if not _MAC_RE.match(mac):
|
||||
errors["base"] = "invalid_mac"
|
||||
else:
|
||||
await self.async_set_unique_id(mac)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
data={"mac": mac, "name": name},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="manual",
|
||||
data_schema=MANUAL_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Constants for the Denon AVR Bluetooth integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "denon_bt"
|
||||
|
||||
# Bluetooth RFCOMM
|
||||
RFCOMM_CHANNEL: Final = 2
|
||||
CONNECT_TIMEOUT: Final = 10
|
||||
RECONNECT_INTERVAL: Final = 30 # max seconds between reconnect attempts
|
||||
|
||||
# Volume: raw byte / 2 = display value. 98 raw = display 49 (comfortable max).
|
||||
VOLUME_PRACTICAL_MAX: Final = 98
|
||||
|
||||
# ── Protocol commands (Phone → AVR) ─────────────────────────────────────────
|
||||
|
||||
CMD_VOLUME_UP: Final = bytes([0x41, 0x54, 0x07, 0x00, 0x00, 0x00])
|
||||
CMD_VOLUME_DOWN: Final = bytes([0x41, 0x54, 0x07, 0x01, 0x00, 0x00])
|
||||
CMD_GET_SOURCES: Final = bytes([0x41, 0x54, 0x00, 0x04, 0x00, 0x00])
|
||||
CMD_GET_STATUS: Final = bytes([0x41, 0x54, 0x00, 0x06, 0x00, 0x00])
|
||||
CMD_MUTE_ON: Final = bytes([0x41, 0x54, 0x07, 0x1D, 0x01, 0x01, 0xFE])
|
||||
CMD_MUTE_OFF: Final = bytes([0x41, 0x54, 0x07, 0x1D, 0x01, 0x00, 0xFF])
|
||||
CMD_POWER_OFF: Final = bytes([0x41, 0x54, 0x00, 0x0A, 0x01, 0x00, 0xFF])
|
||||
CMD_POWER_ON: Final = bytes([0x41, 0x54, 0x00, 0x0A, 0x01, 0x01, 0xFE])
|
||||
|
||||
# ── Known input source byte codes ───────────────────────────────────────────
|
||||
|
||||
DEFAULT_SOURCES: Final = {
|
||||
0x40: "Bluetooth",
|
||||
0x55: "Media Player",
|
||||
0x52: "CBL/SAT",
|
||||
0x53: "DVD",
|
||||
0x54: "Blu-ray",
|
||||
0x56: "Game",
|
||||
0x57: "AUX",
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"domain": "denon_bt",
|
||||
"name": "Denon AVR Bluetooth",
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"documentation": "https://github.com/ckoch/denon-bluetooth",
|
||||
"iot_class": "local_push",
|
||||
"requirements": [],
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Media player entity for Denon AVR Bluetooth."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
|
||||
from .const import (
|
||||
CMD_MUTE_OFF,
|
||||
CMD_MUTE_ON,
|
||||
CMD_POWER_OFF,
|
||||
CMD_POWER_ON,
|
||||
CMD_VOLUME_DOWN,
|
||||
CMD_VOLUME_UP,
|
||||
DOMAIN,
|
||||
RECONNECT_INTERVAL,
|
||||
VOLUME_PRACTICAL_MAX,
|
||||
)
|
||||
from .protocol import DenonBTClient, DenonState, cmd_select_input
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Denon BT media player from a config entry."""
|
||||
mac: str = entry.data["mac"]
|
||||
name: str = entry.data.get("name", "Denon AVR")
|
||||
async_add_entities([DenonBTMediaPlayer(hass, entry, mac, name)])
|
||||
|
||||
|
||||
class DenonBTMediaPlayer(MediaPlayerEntity):
|
||||
"""Representation of a Denon AVR-S540BT over Bluetooth RFCOMM."""
|
||||
|
||||
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_supported_features = (
|
||||
MediaPlayerEntityFeature.VOLUME_STEP
|
||||
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
| MediaPlayerEntityFeature.TURN_ON
|
||||
| MediaPlayerEntityFeature.TURN_OFF
|
||||
| MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
mac: str,
|
||||
name: str,
|
||||
) -> None:
|
||||
self._hass = hass
|
||||
self._mac = mac
|
||||
|
||||
self._attr_unique_id = mac.replace(":", "").lower()
|
||||
self._attr_device_info = {
|
||||
"identifiers": {(DOMAIN, mac)},
|
||||
"name": name,
|
||||
"manufacturer": "Denon",
|
||||
"model": "AVR-S540BT",
|
||||
"connections": {("bluetooth", mac)},
|
||||
}
|
||||
|
||||
self._client: DenonBTClient | None = None
|
||||
self._state = DenonState()
|
||||
self._reconnect_cancel: callback | None = None
|
||||
self._reconnect_attempts = 0
|
||||
|
||||
# ── Lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
self._client = DenonBTClient(
|
||||
self._hass, self._mac, self._handle_state_update
|
||||
)
|
||||
await self._async_try_connect()
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
self._cancel_reconnect()
|
||||
if self._client:
|
||||
await self._client.async_disconnect()
|
||||
|
||||
# ── State callback from protocol client ──────────────────────────────────
|
||||
|
||||
@callback
|
||||
def _handle_state_update(self, state: DenonState) -> None:
|
||||
"""Called on the event loop when the protocol client reports a change."""
|
||||
self._state = state
|
||||
self._attr_available = self._client.connected if self._client else False
|
||||
|
||||
if not self._attr_available:
|
||||
self._schedule_reconnect()
|
||||
else:
|
||||
self._reconnect_attempts = 0
|
||||
self._cancel_reconnect()
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
# ── Connection / reconnect ───────────────────────────────────────────────
|
||||
|
||||
async def _async_try_connect(self) -> None:
|
||||
if self._client and await self._client.async_connect():
|
||||
self._attr_available = True
|
||||
self._reconnect_attempts = 0
|
||||
_LOGGER.info("Connected to Denon AVR at %s", self._mac)
|
||||
else:
|
||||
self._attr_available = False
|
||||
self._schedule_reconnect()
|
||||
_LOGGER.warning(
|
||||
"Could not connect to Denon AVR at %s", self._mac
|
||||
)
|
||||
|
||||
def _schedule_reconnect(self) -> None:
|
||||
if self._reconnect_cancel is not None:
|
||||
return
|
||||
delay = min(RECONNECT_INTERVAL, 5 * (self._reconnect_attempts + 1))
|
||||
self._reconnect_cancel = async_call_later(
|
||||
self._hass, delay, self._async_reconnect
|
||||
)
|
||||
self._reconnect_attempts += 1
|
||||
_LOGGER.debug(
|
||||
"Reconnect to %s in %ds (attempt %d)",
|
||||
self._mac,
|
||||
delay,
|
||||
self._reconnect_attempts,
|
||||
)
|
||||
|
||||
def _cancel_reconnect(self) -> None:
|
||||
if self._reconnect_cancel:
|
||||
self._reconnect_cancel()
|
||||
self._reconnect_cancel = None
|
||||
|
||||
async def _async_reconnect(self, _now: Any = None) -> None:
|
||||
self._reconnect_cancel = None
|
||||
if self._client:
|
||||
await self._client.async_disconnect()
|
||||
await self._async_try_connect()
|
||||
|
||||
# ── State properties ─────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def state(self) -> MediaPlayerState:
|
||||
if not self._attr_available:
|
||||
return MediaPlayerState.OFF
|
||||
if self._state.power:
|
||||
return MediaPlayerState.ON
|
||||
return MediaPlayerState.OFF
|
||||
|
||||
@property
|
||||
def volume_level(self) -> float | None:
|
||||
if self._state.volume_raw is None:
|
||||
return None
|
||||
return min(1.0, self._state.volume_raw / VOLUME_PRACTICAL_MAX)
|
||||
|
||||
@property
|
||||
def is_volume_muted(self) -> bool | None:
|
||||
return self._state.muted
|
||||
|
||||
@property
|
||||
def source(self) -> str | None:
|
||||
return self._state.source
|
||||
|
||||
@property
|
||||
def source_list(self) -> list[str]:
|
||||
return list(self._state.sources.values())
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
attrs: dict[str, Any] = {}
|
||||
if self._state.sound_mode:
|
||||
attrs["sound_mode"] = self._state.sound_mode
|
||||
if self._state.volume_raw is not None:
|
||||
attrs["volume_raw"] = self._state.volume_raw
|
||||
attrs["volume_display"] = self._state.volume_raw / 2.0
|
||||
return attrs
|
||||
|
||||
# ── Commands ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
if self._client:
|
||||
if not self._client.connected:
|
||||
await self._async_try_connect()
|
||||
await self._client.async_send(CMD_POWER_ON)
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
if self._client:
|
||||
await self._client.async_send(CMD_POWER_OFF)
|
||||
|
||||
async def async_volume_up(self) -> None:
|
||||
if self._client:
|
||||
await self._client.async_send(CMD_VOLUME_UP)
|
||||
|
||||
async def async_volume_down(self) -> None:
|
||||
if self._client:
|
||||
await self._client.async_send(CMD_VOLUME_DOWN)
|
||||
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
if self._client:
|
||||
await self._client.async_send(CMD_MUTE_ON if mute else CMD_MUTE_OFF)
|
||||
|
||||
async def async_select_source(self, source: str) -> None:
|
||||
if not self._client:
|
||||
return
|
||||
for code, name in self._state.sources.items():
|
||||
if name == source:
|
||||
await self._client.async_send(cmd_select_input(code))
|
||||
return
|
||||
_LOGGER.warning("Unknown source: %s", source)
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Denon AVR-S540BT Bluetooth RFCOMM protocol client."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .const import (
|
||||
CMD_GET_SOURCES,
|
||||
CMD_GET_STATUS,
|
||||
CONNECT_TIMEOUT,
|
||||
DEFAULT_SOURCES,
|
||||
RFCOMM_CHANNEL,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DenonState:
|
||||
"""Current state of the Denon AVR."""
|
||||
|
||||
power: bool = False
|
||||
volume_raw: int | None = None
|
||||
muted: bool = False
|
||||
source: str | None = None
|
||||
source_code: int | None = None
|
||||
sound_mode: str | None = None
|
||||
sources: dict[int, str] = field(default_factory=lambda: dict(DEFAULT_SOURCES))
|
||||
|
||||
|
||||
def cmd_select_input(source_byte: int) -> bytes:
|
||||
"""Build input selection command."""
|
||||
return bytes([0x41, 0x54, 0x00, 0x07, 0x01, source_byte, 0x00])
|
||||
|
||||
|
||||
class DenonBTClient:
|
||||
"""Async-compatible Denon Bluetooth RFCOMM client for Home Assistant."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
mac: str,
|
||||
state_callback: Callable[[DenonState], None],
|
||||
) -> None:
|
||||
self._hass = hass
|
||||
self._mac = mac
|
||||
self._state_callback = state_callback
|
||||
|
||||
self._sock: socket.socket | None = None
|
||||
self._connected = False
|
||||
self._stop_event = threading.Event()
|
||||
self._recv_thread: threading.Thread | None = None
|
||||
self._state = DenonState()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
@property
|
||||
def state(self) -> DenonState:
|
||||
return self._state
|
||||
|
||||
# ── Connection ───────────────────────────────────────────────────────────
|
||||
|
||||
async def async_connect(self) -> bool:
|
||||
"""Connect to the AVR (blocking work runs in executor)."""
|
||||
try:
|
||||
await self._hass.async_add_executor_job(self._blocking_connect)
|
||||
return True
|
||||
except Exception as err:
|
||||
_LOGGER.error("Connection to %s failed: %s", self._mac, err)
|
||||
return False
|
||||
|
||||
def _blocking_connect(self) -> None:
|
||||
self._stop_event.clear()
|
||||
sock = socket.socket(
|
||||
socket.AF_BLUETOOTH,
|
||||
socket.SOCK_STREAM,
|
||||
socket.BTPROTO_RFCOMM,
|
||||
)
|
||||
sock.settimeout(CONNECT_TIMEOUT)
|
||||
sock.connect((self._mac, RFCOMM_CHANNEL))
|
||||
sock.settimeout(None)
|
||||
|
||||
self._sock = sock
|
||||
self._connected = True
|
||||
|
||||
self._recv_thread = threading.Thread(
|
||||
target=self._recv_loop,
|
||||
daemon=True,
|
||||
name=f"denon_bt_recv_{self._mac}",
|
||||
)
|
||||
self._recv_thread.start()
|
||||
|
||||
# Give the AVR a moment, then request initial state.
|
||||
time.sleep(0.3)
|
||||
self._blocking_send(CMD_GET_SOURCES)
|
||||
self._blocking_send(CMD_GET_STATUS)
|
||||
|
||||
async def async_disconnect(self) -> None:
|
||||
await self._hass.async_add_executor_job(self._blocking_disconnect)
|
||||
|
||||
def _blocking_disconnect(self) -> None:
|
||||
self._stop_event.set()
|
||||
self._connected = False
|
||||
if self._sock:
|
||||
try:
|
||||
self._sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._sock = None
|
||||
|
||||
# ── Send ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async def async_send(self, data: bytes) -> bool:
|
||||
if not self._connected:
|
||||
return False
|
||||
try:
|
||||
await self._hass.async_add_executor_job(self._blocking_send, data)
|
||||
return True
|
||||
except Exception as err:
|
||||
_LOGGER.error("Send to %s failed: %s", self._mac, err)
|
||||
await self.async_disconnect()
|
||||
return False
|
||||
|
||||
def _blocking_send(self, data: bytes) -> None:
|
||||
if self._sock:
|
||||
self._sock.sendall(data)
|
||||
|
||||
# ── Receive loop ─────────────────────────────────────────────────────────
|
||||
|
||||
def _recv_loop(self) -> None:
|
||||
buf = b""
|
||||
while not self._stop_event.is_set() and self._sock:
|
||||
try:
|
||||
chunk = self._sock.recv(256)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
|
||||
while len(buf) >= 4:
|
||||
idx = buf.find(b"AT")
|
||||
if idx == -1:
|
||||
buf = b""
|
||||
break
|
||||
if idx > 0:
|
||||
buf = buf[idx:]
|
||||
if len(buf) < 4:
|
||||
break
|
||||
|
||||
pkt_len = _packet_length(buf)
|
||||
if pkt_len is None or len(buf) < pkt_len:
|
||||
break
|
||||
|
||||
pkt = buf[:pkt_len]
|
||||
buf = buf[pkt_len:]
|
||||
self._handle_packet(pkt)
|
||||
|
||||
except Exception as err:
|
||||
_LOGGER.debug("Recv loop ended: %s", err)
|
||||
break
|
||||
|
||||
self._connected = False
|
||||
self._notify_state_change()
|
||||
|
||||
# ── Packet handling ──────────────────────────────────────────────────────
|
||||
|
||||
def _handle_packet(self, data: bytes) -> None:
|
||||
if len(data) < 4:
|
||||
return
|
||||
|
||||
cat, typ = data[2], data[3]
|
||||
changed = False
|
||||
|
||||
# Volume report: AT 07 02 03 C5 [vol] 00 [chk]
|
||||
if cat == 0x07 and typ == 0x02 and len(data) >= 7:
|
||||
if data[4] == 0x03 and data[5] == 0xC5:
|
||||
with self._lock:
|
||||
self._state.volume_raw = data[6]
|
||||
self._state.power = True
|
||||
changed = True
|
||||
|
||||
# Mute state: AT 07 1D 01 [state] [chk]
|
||||
elif cat == 0x07 and typ == 0x1D and len(data) >= 6:
|
||||
with self._lock:
|
||||
self._state.muted = data[4] == 0x01 and data[5] == 0x01
|
||||
changed = True
|
||||
|
||||
# Sound mode: AT 07 1E 15 00 [20 bytes text] [chk]
|
||||
elif cat == 0x07 and typ == 0x1E and len(data) >= 6:
|
||||
text = bytes(b for b in data[5:] if 32 <= b < 127).decode(
|
||||
"ascii", errors="replace"
|
||||
).strip()
|
||||
if text:
|
||||
with self._lock:
|
||||
self._state.sound_mode = text
|
||||
changed = True
|
||||
|
||||
# Input sources list: AT 00 04 0A 07 ...
|
||||
elif cat == 0x00 and typ == 0x04 and len(data) >= 8:
|
||||
discovered: dict[int, str] = {}
|
||||
for b in data[4:]:
|
||||
if b in DEFAULT_SOURCES:
|
||||
discovered[b] = DEFAULT_SOURCES[b]
|
||||
elif 0x40 <= b <= 0x7F:
|
||||
discovered[b] = f"Input {b:02X}"
|
||||
if discovered:
|
||||
with self._lock:
|
||||
self._state.sources = discovered
|
||||
changed = True
|
||||
|
||||
# Current input: AT 00 07 02 [src] 00 [chk]
|
||||
elif cat == 0x00 and typ == 0x07 and len(data) >= 6 and data[4] == 0x02:
|
||||
code = data[5]
|
||||
with self._lock:
|
||||
self._state.source_code = code
|
||||
self._state.source = self._state.sources.get(
|
||||
code, f"Input {code:02X}"
|
||||
)
|
||||
changed = True
|
||||
|
||||
# Power echo: AT 00 0A 01 [state] [chk]
|
||||
elif cat == 0x00 and typ == 0x0A and len(data) >= 6:
|
||||
with self._lock:
|
||||
self._state.power = data[4] == 0x01 and data[5] == 0x01
|
||||
changed = True
|
||||
|
||||
# Power ack (off): AT 07 0B 01 00 FF
|
||||
elif cat == 0x07 and typ == 0x0B and len(data) >= 6:
|
||||
if data[4] == 0x01 and data[5] == 0x00:
|
||||
with self._lock:
|
||||
self._state.power = False
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
self._notify_state_change()
|
||||
|
||||
def _notify_state_change(self) -> None:
|
||||
if self._hass and self._state_callback:
|
||||
self._hass.loop.call_soon_threadsafe(
|
||||
self._state_callback, self._state
|
||||
)
|
||||
|
||||
|
||||
# ── Packet length table (from denon_remote.py) ──────────────────────────────
|
||||
|
||||
|
||||
def _packet_length(buf: bytes) -> int | None:
|
||||
"""Determine expected packet length from the first bytes."""
|
||||
if len(buf) < 4:
|
||||
return None
|
||||
cat = buf[2]
|
||||
typ = buf[3]
|
||||
|
||||
if cat == 0x07:
|
||||
if typ in (0x00, 0x01):
|
||||
return 6
|
||||
if typ == 0x02:
|
||||
return 9
|
||||
if typ == 0x03:
|
||||
return 6
|
||||
if typ == 0x1E:
|
||||
return 27
|
||||
if typ in (0x0B, 0x1D, 0x25):
|
||||
return 7
|
||||
if typ == 0x31:
|
||||
return 14
|
||||
|
||||
if cat == 0x00:
|
||||
if typ == 0x04:
|
||||
if len(buf) > 4 and buf[4] == 0x00:
|
||||
return 6
|
||||
return 16
|
||||
if typ == 0x06:
|
||||
if len(buf) > 4 and buf[4] == 0x00:
|
||||
return 6
|
||||
return 75
|
||||
if typ == 0x07:
|
||||
if len(buf) > 4 and buf[4] == 0x01:
|
||||
return 7
|
||||
return 8
|
||||
if typ == 0x09:
|
||||
return 7
|
||||
if typ == 0x0A:
|
||||
return 7
|
||||
if typ == 0x0B:
|
||||
return 7
|
||||
if typ == 0x0D:
|
||||
return 21
|
||||
if typ == 0x0E:
|
||||
return 16
|
||||
if typ == 0x13:
|
||||
return 7
|
||||
|
||||
if cat == 0x0D:
|
||||
if typ == 0x08:
|
||||
return 13
|
||||
if typ == 0x0D:
|
||||
return 6
|
||||
|
||||
if cat == 0x81:
|
||||
if typ == 0x04:
|
||||
return 7
|
||||
if typ == 0x32:
|
||||
return 119
|
||||
if typ == 0x40:
|
||||
return 7
|
||||
|
||||
if cat == 0x82:
|
||||
if typ == 0x31:
|
||||
return 14
|
||||
|
||||
# Fallback: next AT header or 32 bytes max
|
||||
next_at = buf.find(b"AT", 2)
|
||||
if next_at > 0:
|
||||
return next_at
|
||||
return min(32, len(buf))
|
||||
|
||||
|
||||
# ── D-Bus device discovery ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def find_denon_devices() -> list[dict[str, str | bool]]:
|
||||
"""Query BlueZ D-Bus for paired devices with 'DENON' in the name."""
|
||||
try:
|
||||
import dbus # noqa: PLC0415
|
||||
|
||||
bus = dbus.SystemBus()
|
||||
manager = dbus.Interface(
|
||||
bus.get_object("org.bluez", "/"),
|
||||
"org.freedesktop.DBus.ObjectManager",
|
||||
)
|
||||
objects = manager.GetManagedObjects()
|
||||
results: list[dict[str, str | bool]] = []
|
||||
for _path, interfaces in objects.items():
|
||||
if "org.bluez.Device1" in interfaces:
|
||||
dev = interfaces["org.bluez.Device1"]
|
||||
name = str(dev.get("Name", ""))
|
||||
if "DENON" in name.upper():
|
||||
results.append(
|
||||
{
|
||||
"mac": str(dev.get("Address")),
|
||||
"name": name,
|
||||
"paired": bool(dev.get("Paired", False)),
|
||||
}
|
||||
)
|
||||
return results
|
||||
except Exception as err:
|
||||
_LOGGER.debug("D-Bus discovery failed: %s", err)
|
||||
return []
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"select": {
|
||||
"title": "Select Denon AVR",
|
||||
"description": "Select a discovered Denon device, or enter a MAC address manually.",
|
||||
"data": {
|
||||
"device": "Device"
|
||||
}
|
||||
},
|
||||
"manual": {
|
||||
"title": "Manual Configuration",
|
||||
"description": "Enter the Bluetooth MAC address of your Denon AVR receiver.",
|
||||
"data": {
|
||||
"mac": "MAC Address",
|
||||
"name": "Device Name"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_mac": "Invalid MAC address. Use the format XX:XX:XX:XX:XX:XX."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This device is already configured."
|
||||
}
|
||||
}
|
||||
}
|
||||
+858
@@ -0,0 +1,858 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Denon AVR-S540BT Bluetooth Remote — Prototype
|
||||
Uses RFCOMM channel 2 (BT SPP 2) as identified from btsnoop capture.
|
||||
Run on Linux with: python3 denon_remote.py
|
||||
"""
|
||||
|
||||
import socket
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from tkinter import font as tkfont
|
||||
import queue
|
||||
import struct
|
||||
import time
|
||||
|
||||
try:
|
||||
import dbus
|
||||
HAS_DBUS = True
|
||||
except ImportError:
|
||||
HAS_DBUS = False
|
||||
|
||||
# ─── Bluetooth device discovery ──────────────────────────────────────────────
|
||||
|
||||
def find_denon_devices():
|
||||
"""Query BlueZ D-Bus for paired devices with 'DENON' in the name."""
|
||||
if not HAS_DBUS:
|
||||
return []
|
||||
try:
|
||||
bus = dbus.SystemBus()
|
||||
manager = dbus.Interface(
|
||||
bus.get_object("org.bluez", "/"),
|
||||
"org.freedesktop.DBus.ObjectManager"
|
||||
)
|
||||
objects = manager.GetManagedObjects()
|
||||
results = []
|
||||
for path, interfaces in objects.items():
|
||||
if "org.bluez.Device1" in interfaces:
|
||||
dev = interfaces["org.bluez.Device1"]
|
||||
name = str(dev.get("Name", ""))
|
||||
if "DENON" in name.upper():
|
||||
results.append({
|
||||
"mac": str(dev.get("Address")),
|
||||
"name": name,
|
||||
"paired": bool(dev.get("Paired", False)),
|
||||
})
|
||||
return results
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def find_bt_adapter():
|
||||
"""Return the D-Bus object path of the first BlueZ adapter, or None."""
|
||||
if not HAS_DBUS:
|
||||
return None
|
||||
try:
|
||||
bus = dbus.SystemBus()
|
||||
manager = dbus.Interface(
|
||||
bus.get_object("org.bluez", "/"),
|
||||
"org.freedesktop.DBus.ObjectManager"
|
||||
)
|
||||
objects = manager.GetManagedObjects()
|
||||
for path, interfaces in objects.items():
|
||||
if "org.bluez.Adapter1" in interfaces:
|
||||
return str(path)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def discover_denon_devices(duration=8):
|
||||
"""Run BT discovery for `duration` seconds; return unpaired Denon devices.
|
||||
|
||||
Returns list of dicts: {mac, name, path} where path is the D-Bus object path.
|
||||
Must be called from a worker thread (blocks for `duration` seconds).
|
||||
"""
|
||||
if not HAS_DBUS:
|
||||
return []
|
||||
adapter_path = find_bt_adapter()
|
||||
if adapter_path is None:
|
||||
return []
|
||||
try:
|
||||
bus = dbus.SystemBus()
|
||||
adapter = dbus.Interface(
|
||||
bus.get_object("org.bluez", adapter_path),
|
||||
"org.bluez.Adapter1"
|
||||
)
|
||||
try:
|
||||
adapter.StartDiscovery()
|
||||
except dbus.exceptions.DBusException as e:
|
||||
if "InProgress" not in str(e):
|
||||
return []
|
||||
|
||||
time.sleep(duration)
|
||||
|
||||
try:
|
||||
adapter.StopDiscovery()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
manager = dbus.Interface(
|
||||
bus.get_object("org.bluez", "/"),
|
||||
"org.freedesktop.DBus.ObjectManager"
|
||||
)
|
||||
objects = manager.GetManagedObjects()
|
||||
results = []
|
||||
for path, interfaces in objects.items():
|
||||
if "org.bluez.Device1" in interfaces:
|
||||
dev = interfaces["org.bluez.Device1"]
|
||||
name = str(dev.get("Name", ""))
|
||||
paired = bool(dev.get("Paired", False))
|
||||
if "DENON" in name.upper() and not paired:
|
||||
results.append({
|
||||
"mac": str(dev.get("Address")),
|
||||
"name": name,
|
||||
"path": str(path),
|
||||
})
|
||||
return results
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def pair_and_trust_device(device_path):
|
||||
"""Pair and trust a BlueZ device. Blocks until pairing completes.
|
||||
|
||||
Returns (True, "") on success, (False, error_message) on failure.
|
||||
"""
|
||||
if not HAS_DBUS:
|
||||
return (False, "D-Bus not available")
|
||||
try:
|
||||
bus = dbus.SystemBus()
|
||||
device = dbus.Interface(
|
||||
bus.get_object("org.bluez", device_path),
|
||||
"org.bluez.Device1"
|
||||
)
|
||||
device.Pair()
|
||||
|
||||
props = dbus.Interface(
|
||||
bus.get_object("org.bluez", device_path),
|
||||
"org.freedesktop.DBus.Properties"
|
||||
)
|
||||
props.Set("org.bluez.Device1", "Trusted", dbus.Boolean(True))
|
||||
|
||||
return (True, "")
|
||||
except dbus.exceptions.DBusException as e:
|
||||
if "AlreadyExists" in str(e):
|
||||
return (True, "")
|
||||
return (False, str(e))
|
||||
except Exception as e:
|
||||
return (False, str(e))
|
||||
|
||||
# ─── Protocol constants ───────────────────────────────────────────────────────
|
||||
|
||||
RFCOMM_CHANNEL = 2
|
||||
|
||||
# Commands (PHONE -> AVR)
|
||||
CMD_VOLUME_UP = bytes([0x41, 0x54, 0x07, 0x00, 0x00, 0x00])
|
||||
CMD_VOLUME_DOWN = bytes([0x41, 0x54, 0x07, 0x01, 0x00, 0x00])
|
||||
CMD_GET_SOURCES = bytes([0x41, 0x54, 0x00, 0x04, 0x00, 0x00])
|
||||
CMD_GET_STATUS = bytes([0x41, 0x54, 0x00, 0x06, 0x00, 0x00])
|
||||
CMD_MUTE_ON = bytes([0x41, 0x54, 0x07, 0x1D, 0x01, 0x01, 0xFE])
|
||||
CMD_MUTE_OFF = bytes([0x41, 0x54, 0x07, 0x1D, 0x01, 0x00, 0xFF])
|
||||
CMD_POWER_OFF = bytes([0x41, 0x54, 0x00, 0x0A, 0x01, 0x00, 0xFF])
|
||||
CMD_POWER_ON = bytes([0x41, 0x54, 0x00, 0x0A, 0x01, 0x01, 0xFE])
|
||||
|
||||
def cmd_select_input(source_byte):
|
||||
# AT 00 07 01 [source] [checksum_placeholder]
|
||||
return bytes([0x41, 0x54, 0x00, 0x07, 0x01, source_byte, 0x00])
|
||||
|
||||
# Known input source codes observed in log (R,S,T,V,W bytes)
|
||||
# Exact label mapping is approximate — adjust after testing
|
||||
INPUT_SOURCES = {
|
||||
0x40: "Bluetooth",
|
||||
0x55: "Media Player", # 'U' — observed in source list, label approximate
|
||||
0x52: "CBL/SAT", # 'R'
|
||||
0x53: "DVD", # 'S'
|
||||
0x54: "Blu-ray", # 'T'
|
||||
0x56: "Game", # 'V'
|
||||
0x57: "AUX", # 'W'
|
||||
}
|
||||
|
||||
# ─── Bluetooth connection ─────────────────────────────────────────────────────
|
||||
|
||||
class DenonBT:
|
||||
def __init__(self, on_message, on_status):
|
||||
self.sock = None
|
||||
self.connected = False
|
||||
self.on_message = on_message # callback(bytes)
|
||||
self.on_status = on_status # callback(str)
|
||||
self._recv_thread = None
|
||||
self._stop = threading.Event()
|
||||
|
||||
def connect(self, mac):
|
||||
self._stop.clear()
|
||||
try:
|
||||
self.on_status("Connecting…")
|
||||
s = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM)
|
||||
s.settimeout(10)
|
||||
s.connect((mac, RFCOMM_CHANNEL))
|
||||
s.settimeout(None)
|
||||
self.sock = s
|
||||
self.connected = True
|
||||
self.on_status(f"Connected to {mac}")
|
||||
self._recv_thread = threading.Thread(target=self._recv_loop, daemon=True)
|
||||
self._recv_thread.start()
|
||||
# Request initial state
|
||||
time.sleep(0.3)
|
||||
self.send(CMD_GET_SOURCES)
|
||||
self.send(CMD_GET_STATUS)
|
||||
except Exception as e:
|
||||
self.connected = False
|
||||
self.on_status(f"Connection failed: {e}")
|
||||
|
||||
def disconnect(self):
|
||||
self._stop.set()
|
||||
self.connected = False
|
||||
if self.sock:
|
||||
try:
|
||||
self.sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.sock = None
|
||||
self.on_status("Disconnected")
|
||||
|
||||
def send(self, data: bytes):
|
||||
if not self.connected or not self.sock:
|
||||
self.on_status("Not connected")
|
||||
return
|
||||
try:
|
||||
self.sock.sendall(data)
|
||||
self.on_message(data, direction="SENT")
|
||||
except Exception as e:
|
||||
self.on_status(f"Send error: {e}")
|
||||
self.disconnect()
|
||||
|
||||
def _recv_loop(self):
|
||||
buf = b""
|
||||
while not self._stop.is_set() and self.sock:
|
||||
try:
|
||||
chunk = self.sock.recv(256)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
# Process all complete AT packets in buffer
|
||||
while len(buf) >= 4:
|
||||
idx = buf.find(b'AT')
|
||||
if idx == -1:
|
||||
buf = b""
|
||||
break
|
||||
if idx > 0:
|
||||
buf = buf[idx:]
|
||||
# Minimum packet is 4 bytes: AT + 2 command bytes
|
||||
# Use length byte at offset 3 if available, else wait for more
|
||||
if len(buf) < 4:
|
||||
break
|
||||
pkt_len = self._packet_length(buf)
|
||||
if pkt_len is None or len(buf) < pkt_len:
|
||||
break
|
||||
pkt = buf[:pkt_len]
|
||||
buf = buf[pkt_len:]
|
||||
self.on_message(pkt, direction="RECV")
|
||||
except Exception:
|
||||
break
|
||||
self.on_status("Disconnected")
|
||||
|
||||
def _packet_length(self, buf):
|
||||
"""Best-effort length detection for AT protocol packets."""
|
||||
if len(buf) < 4:
|
||||
return None
|
||||
cat = buf[2]
|
||||
typ = buf[3]
|
||||
# Known fixed-length packets
|
||||
if cat == 0x07:
|
||||
if typ in (0x00, 0x01): return 6 # vol up/down command
|
||||
if typ == 0x02: return 9 # vol report
|
||||
if typ == 0x03: return 6 # unknown init cmd
|
||||
if typ == 0x1e: return 27 # sound mode (AT + cmd + 0x15 + 0x00 + 20 text + chk)
|
||||
if typ in (0x0b, 0x1d, 0x25): return 7
|
||||
if typ == 0x31: return 14 # firmware/version string
|
||||
if cat == 0x00:
|
||||
if typ == 0x04:
|
||||
if len(buf) > 4 and buf[4] == 0x00:
|
||||
return 6 # request
|
||||
return 16 # response with sources
|
||||
if typ == 0x06:
|
||||
if len(buf) > 4 and buf[4] == 0x00:
|
||||
return 6 # request
|
||||
return 75 # status response
|
||||
if typ == 0x07:
|
||||
if len(buf) > 4 and buf[4] == 0x01:
|
||||
return 7 # input select command
|
||||
return 8 # input report
|
||||
if typ == 0x09: return 7
|
||||
if typ == 0x0a: return 7
|
||||
if typ == 0x0b: return 7
|
||||
if typ == 0x0d: return 21 # device info with MAC
|
||||
if typ == 0x0e: return 16 # device name
|
||||
if typ == 0x13: return 7
|
||||
if cat == 0x0d:
|
||||
if typ == 0x08: return 13
|
||||
if typ == 0x0d: return 6
|
||||
if cat == 0x81:
|
||||
if typ == 0x04: return 7
|
||||
if typ == 0x32: return 119 # large volume table
|
||||
if typ == 0x40: return 7
|
||||
if cat == 0x82:
|
||||
if typ == 0x31: return 14
|
||||
# Fallback: read until next AT or 32 bytes
|
||||
next_at = buf.find(b'AT', 2)
|
||||
if next_at > 0:
|
||||
return next_at
|
||||
return min(32, len(buf))
|
||||
|
||||
|
||||
# ─── GUI ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
BG = "#0d0d0d"
|
||||
PANEL = "#161616"
|
||||
BORDER = "#2a2a2a"
|
||||
ACCENT = "#e8c97a" # warm gold
|
||||
ACCENT2 = "#4fc3f7" # cool blue
|
||||
TEXT = "#f0ede6"
|
||||
MUTED = "#666"
|
||||
RED = "#e05252"
|
||||
GREEN = "#4caf50"
|
||||
|
||||
class DenonRemoteApp(tk.Tk):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.title("Denon AVR-S540BT Remote")
|
||||
self.configure(bg=BG)
|
||||
self.resizable(False, False)
|
||||
|
||||
self._msg_queue = queue.Queue()
|
||||
self._vol = tk.StringVar(value="—")
|
||||
self._vol_raw = None # raw volume byte (0x00-0xFF)
|
||||
self._muted = False
|
||||
self._sound_mode = tk.StringVar(value="—")
|
||||
self._status = tk.StringVar(value="Disconnected")
|
||||
self._sources = {} # byte -> label
|
||||
self._cur_input = None
|
||||
self._devices = [] # list of dicts from find_denon_devices()
|
||||
self._selected_device = None # index into _devices
|
||||
self._unpaired_devices = [] # list of dicts from discover_denon_devices()
|
||||
self._discovering = False
|
||||
self._pairing = False
|
||||
|
||||
self.bt = DenonBT(
|
||||
on_message=self._on_bt_message,
|
||||
on_status=self._on_bt_status,
|
||||
)
|
||||
|
||||
self._build_ui()
|
||||
self._poll_queue()
|
||||
self._scan_devices()
|
||||
|
||||
# ── UI construction ───────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
self._fonts()
|
||||
|
||||
# ── Header ────────────────────────────────────────────────────────────
|
||||
hdr = tk.Frame(self, bg=BG, pady=18)
|
||||
hdr.pack(fill="x", padx=24)
|
||||
|
||||
tk.Label(hdr, text="DENON", font=self.f_brand,
|
||||
bg=BG, fg=ACCENT).pack(side="left")
|
||||
tk.Label(hdr, text=" AVR-S540BT", font=self.f_model,
|
||||
bg=BG, fg=MUTED).pack(side="left", padx=(2, 0))
|
||||
|
||||
# Status dot
|
||||
self._status_dot = tk.Label(hdr, text="●", font=self.f_dot,
|
||||
bg=BG, fg=RED)
|
||||
self._status_dot.pack(side="right")
|
||||
tk.Label(hdr, textvariable=self._status, font=self.f_small,
|
||||
bg=BG, fg=MUTED).pack(side="right", padx=(0, 6))
|
||||
|
||||
self._divider()
|
||||
|
||||
# ── Connection panel ──────────────────────────────────────────────────
|
||||
conn = tk.Frame(self, bg=PANEL, padx=20, pady=16)
|
||||
conn.pack(fill="x", padx=16, pady=(12, 0))
|
||||
|
||||
tk.Label(conn, text="DEVICE", font=self.f_label,
|
||||
bg=PANEL, fg=MUTED).grid(row=0, column=0, sticky="w",
|
||||
columnspan=3)
|
||||
|
||||
self._device_frame = tk.Frame(conn, bg=PANEL)
|
||||
self._device_frame.grid(row=1, column=0, pady=(4, 0), sticky="w")
|
||||
|
||||
self._device_label = tk.Label(
|
||||
self._device_frame, text="Scanning…", font=self.f_mono,
|
||||
bg=PANEL, fg=MUTED, anchor="w"
|
||||
)
|
||||
self._device_label.pack(side="left")
|
||||
|
||||
btn_row = tk.Frame(conn, bg=PANEL)
|
||||
btn_row.grid(row=0, column=2, sticky="e")
|
||||
|
||||
self._scan_btn = self._btn(
|
||||
btn_row, "SCAN", self._scan_devices,
|
||||
bg=PANEL, fg=MUTED, font=self.f_small
|
||||
)
|
||||
self._scan_btn.pack(side="left", padx=(0, 4))
|
||||
|
||||
self._scan_pair_btn = self._btn(
|
||||
btn_row, "SCAN & PAIR", self._start_discovery,
|
||||
bg=PANEL, fg=ACCENT2, font=self.f_small
|
||||
)
|
||||
self._scan_pair_btn.pack(side="left")
|
||||
|
||||
self._conn_btn = self._btn(
|
||||
conn, "CONNECT", self._toggle_connect,
|
||||
bg=ACCENT, fg=BG, font=self.f_btn
|
||||
)
|
||||
self._conn_btn.grid(row=1, column=2, padx=(10, 0), pady=(4, 0),
|
||||
ipady=6, ipadx=14)
|
||||
|
||||
self._unpaired_frame = tk.Frame(conn, bg=PANEL)
|
||||
self._unpaired_frame.grid(row=2, column=0, columnspan=3,
|
||||
pady=(8, 0), sticky="w")
|
||||
|
||||
self._divider()
|
||||
|
||||
# ── Power panel ──────────────────────────────────────────────────
|
||||
pwr_frame = tk.Frame(self, bg=BG, pady=6)
|
||||
pwr_frame.pack(fill="x", padx=24)
|
||||
|
||||
tk.Label(pwr_frame, text="POWER", font=self.f_label,
|
||||
bg=BG, fg=MUTED).pack(side="left")
|
||||
|
||||
self._btn(
|
||||
pwr_frame, "OFF", lambda: self.bt.send(CMD_POWER_OFF),
|
||||
bg=PANEL, fg=RED, font=self.f_btn,
|
||||
).pack(side="right", ipady=5, ipadx=14)
|
||||
|
||||
self._btn(
|
||||
pwr_frame, "ON", lambda: self.bt.send(CMD_POWER_ON),
|
||||
bg=PANEL, fg=GREEN, font=self.f_btn,
|
||||
).pack(side="right", padx=(0, 6), ipady=5, ipadx=14)
|
||||
|
||||
self._divider()
|
||||
|
||||
# ── Volume panel ──────────────────────────────────────────────────────
|
||||
vol_frame = tk.Frame(self, bg=BG, pady=8)
|
||||
vol_frame.pack(fill="x", padx=24)
|
||||
|
||||
tk.Label(vol_frame, text="VOLUME", font=self.f_label,
|
||||
bg=BG, fg=MUTED).pack(anchor="w")
|
||||
|
||||
vol_ctrl = tk.Frame(vol_frame, bg=BG)
|
||||
vol_ctrl.pack(fill="x", pady=(8, 0))
|
||||
|
||||
self._vol_down_btn = self._btn(
|
||||
vol_ctrl, "▼", lambda: self.bt.send(CMD_VOLUME_DOWN),
|
||||
bg=PANEL, fg=TEXT, font=self.f_arrow,
|
||||
width=4, activebackground=BORDER
|
||||
)
|
||||
self._vol_down_btn.pack(side="left", ipady=12)
|
||||
|
||||
tk.Label(vol_ctrl, textvariable=self._vol, font=self.f_vol,
|
||||
bg=BG, fg=ACCENT, width=6).pack(side="left")
|
||||
|
||||
self._vol_up_btn = self._btn(
|
||||
vol_ctrl, "▲", lambda: self.bt.send(CMD_VOLUME_UP),
|
||||
bg=PANEL, fg=TEXT, font=self.f_arrow,
|
||||
width=4, activebackground=BORDER
|
||||
)
|
||||
self._vol_up_btn.pack(side="left", ipady=12)
|
||||
|
||||
self._mute_btn = self._btn(
|
||||
vol_ctrl, "MUTE", self._toggle_mute,
|
||||
bg=PANEL, fg=MUTED, font=self.f_btn,
|
||||
activebackground=RED
|
||||
)
|
||||
self._mute_btn.pack(side="right", ipady=6, ipadx=14)
|
||||
|
||||
self._divider()
|
||||
|
||||
# ── Sound mode ────────────────────────────────────────────────────────
|
||||
sm = tk.Frame(self, bg=BG, pady=6)
|
||||
sm.pack(fill="x", padx=24)
|
||||
|
||||
tk.Label(sm, text="SOUND MODE", font=self.f_label,
|
||||
bg=BG, fg=MUTED).pack(side="left")
|
||||
tk.Label(sm, textvariable=self._sound_mode, font=self.f_value,
|
||||
bg=BG, fg=ACCENT2).pack(side="right")
|
||||
|
||||
self._divider()
|
||||
|
||||
# ── Input sources ─────────────────────────────────────────────────────
|
||||
inp = tk.Frame(self, bg=BG, pady=8)
|
||||
inp.pack(fill="x", padx=24)
|
||||
|
||||
tk.Label(inp, text="INPUT", font=self.f_label,
|
||||
bg=BG, fg=MUTED).pack(anchor="w")
|
||||
|
||||
self._input_frame = tk.Frame(inp, bg=BG)
|
||||
self._input_frame.pack(fill="x", pady=(8, 0))
|
||||
|
||||
# Populated dynamically when sources are received
|
||||
self._input_buttons = {}
|
||||
self._render_inputs(INPUT_SOURCES)
|
||||
|
||||
self._divider()
|
||||
|
||||
# ── Log ───────────────────────────────────────────────────────────────
|
||||
log_hdr = tk.Frame(self, bg=BG)
|
||||
log_hdr.pack(fill="x", padx=24, pady=(4, 0))
|
||||
tk.Label(log_hdr, text="PACKET LOG", font=self.f_label,
|
||||
bg=BG, fg=MUTED).pack(side="left")
|
||||
self._btn(log_hdr, "CLEAR", self._clear_log,
|
||||
bg=BG, fg=MUTED, font=self.f_small).pack(side="right")
|
||||
|
||||
self._log = tk.Text(
|
||||
self, height=8, font=self.f_mono,
|
||||
bg="#0a0a0a", fg="#3a7a3a", insertbackground=ACCENT,
|
||||
relief="flat", bd=0, state="disabled",
|
||||
highlightthickness=1, highlightbackground=BORDER,
|
||||
padx=8, pady=6
|
||||
)
|
||||
self._log.pack(fill="x", padx=16, pady=(4, 16))
|
||||
self._log.tag_config("sent", foreground="#4fc3f7")
|
||||
self._log.tag_config("recv", foreground="#81c784")
|
||||
self._log.tag_config("info", foreground=MUTED)
|
||||
|
||||
def _fonts(self):
|
||||
self.f_brand = tkfont.Font(family="Courier", size=18, weight="bold")
|
||||
self.f_model = tkfont.Font(family="Courier", size=12)
|
||||
self.f_label = tkfont.Font(family="Courier", size=8, weight="bold")
|
||||
self.f_value = tkfont.Font(family="Courier", size=12)
|
||||
self.f_vol = tkfont.Font(family="Courier", size=32, weight="bold")
|
||||
self.f_btn = tkfont.Font(family="Courier", size=9, weight="bold")
|
||||
self.f_arrow = tkfont.Font(family="Courier", size=14, weight="bold")
|
||||
self.f_mono = tkfont.Font(family="Courier", size=9)
|
||||
self.f_small = tkfont.Font(family="Courier", size=8)
|
||||
self.f_dot = tkfont.Font(family="Courier", size=10)
|
||||
|
||||
def _btn(self, parent, text, cmd, **kw):
|
||||
defaults = dict(
|
||||
text=text, command=cmd,
|
||||
bg=PANEL, fg=TEXT,
|
||||
font=self.f_btn,
|
||||
relief="flat", bd=0,
|
||||
cursor="hand2",
|
||||
activeforeground=ACCENT,
|
||||
activebackground=BORDER,
|
||||
)
|
||||
defaults.update(kw)
|
||||
return tk.Button(parent, **defaults)
|
||||
|
||||
def _divider(self):
|
||||
tk.Frame(self, bg=BORDER, height=1).pack(
|
||||
fill="x", padx=16, pady=6)
|
||||
|
||||
def _render_inputs(self, sources):
|
||||
for w in self._input_frame.winfo_children():
|
||||
w.destroy()
|
||||
self._input_buttons = {}
|
||||
for code, label in sources.items():
|
||||
btn = self._btn(
|
||||
self._input_frame, label,
|
||||
lambda c=code: self._select_input(c),
|
||||
bg=PANEL, fg=MUTED, font=self.f_btn,
|
||||
)
|
||||
btn.pack(side="left", padx=(0, 6), ipady=5, ipadx=8)
|
||||
self._input_buttons[code] = btn
|
||||
if not sources:
|
||||
tk.Label(self._input_frame, text="No sources discovered yet",
|
||||
font=self.f_small, bg=BG, fg=MUTED).pack(side="left")
|
||||
|
||||
def _toggle_mute(self):
|
||||
if self._muted:
|
||||
self.bt.send(CMD_MUTE_OFF)
|
||||
else:
|
||||
self.bt.send(CMD_MUTE_ON)
|
||||
|
||||
def _update_mute_ui(self):
|
||||
if self._muted:
|
||||
self._mute_btn.configure(bg=RED, fg=TEXT, text="MUTED")
|
||||
else:
|
||||
self._mute_btn.configure(bg=PANEL, fg=MUTED, text="MUTE")
|
||||
|
||||
def _select_input(self, code):
|
||||
self.bt.send(cmd_select_input(code))
|
||||
self._highlight_input(code)
|
||||
|
||||
def _highlight_input(self, code):
|
||||
self._cur_input = code
|
||||
for c, btn in self._input_buttons.items():
|
||||
if c == code:
|
||||
btn.configure(fg=ACCENT, bg="#252010",
|
||||
highlightthickness=1,
|
||||
highlightbackground=ACCENT,
|
||||
highlightcolor=ACCENT)
|
||||
else:
|
||||
btn.configure(fg=MUTED, bg=PANEL,
|
||||
highlightthickness=0)
|
||||
|
||||
def _clear_log(self):
|
||||
self._log.configure(state="normal")
|
||||
self._log.delete("1.0", "end")
|
||||
self._log.configure(state="disabled")
|
||||
|
||||
# ── Device discovery ─────────────────────────────────────────────────────
|
||||
|
||||
def _scan_devices(self):
|
||||
self._devices = find_denon_devices()
|
||||
self._selected_device = 0 if self._devices else None
|
||||
self._render_device_list()
|
||||
|
||||
def _render_device_list(self):
|
||||
for w in self._device_frame.winfo_children():
|
||||
w.destroy()
|
||||
|
||||
if not HAS_DBUS:
|
||||
tk.Label(self._device_frame,
|
||||
text="Install python3-dbus for auto-detection",
|
||||
font=self.f_small, bg=PANEL, fg=RED).pack(side="left")
|
||||
return
|
||||
|
||||
if not self._devices:
|
||||
tk.Label(self._device_frame,
|
||||
text="No paired Denon device — use SCAN & PAIR",
|
||||
font=self.f_small, bg=PANEL, fg=MUTED).pack(side="left")
|
||||
return
|
||||
|
||||
if len(self._devices) == 1:
|
||||
dev = self._devices[0]
|
||||
tk.Label(self._device_frame,
|
||||
text=f"{dev['name']} {dev['mac']}",
|
||||
font=self.f_mono, bg=PANEL, fg=TEXT).pack(side="left")
|
||||
else:
|
||||
for i, dev in enumerate(self._devices):
|
||||
is_sel = (i == self._selected_device)
|
||||
btn = self._btn(
|
||||
self._device_frame,
|
||||
f"{dev['name']}",
|
||||
lambda idx=i: self._pick_device(idx),
|
||||
bg="#252010" if is_sel else PANEL,
|
||||
fg=ACCENT if is_sel else MUTED,
|
||||
font=self.f_btn,
|
||||
)
|
||||
btn.pack(side="left", padx=(0, 6), ipady=3, ipadx=6)
|
||||
|
||||
def _pick_device(self, idx):
|
||||
self._selected_device = idx
|
||||
self._render_device_list()
|
||||
|
||||
# ── Bluetooth discovery & pairing ─────────────────────────────────────────
|
||||
|
||||
def _start_discovery(self):
|
||||
if not HAS_DBUS:
|
||||
self._log_append("Install python3-dbus for Bluetooth scanning", "info")
|
||||
return
|
||||
if self._discovering:
|
||||
return
|
||||
self._discovering = True
|
||||
self._unpaired_devices = []
|
||||
self._scan_pair_btn.configure(state="disabled", text="SCANNING…")
|
||||
self._render_unpaired_list()
|
||||
self._log_append("Starting Bluetooth discovery…", "info")
|
||||
threading.Thread(target=self._discovery_worker, daemon=True).start()
|
||||
|
||||
def _discovery_worker(self):
|
||||
devices = discover_denon_devices(duration=8)
|
||||
self._msg_queue.put(("discovery_done", devices))
|
||||
|
||||
def _handle_discovery_done(self, devices):
|
||||
self._discovering = False
|
||||
self._unpaired_devices = devices
|
||||
self._scan_pair_btn.configure(state="normal", text="SCAN & PAIR")
|
||||
self._render_unpaired_list()
|
||||
count = len(devices)
|
||||
if count == 0:
|
||||
self._log_append("Discovery complete — no unpaired Denon devices found", "info")
|
||||
else:
|
||||
self._log_append(f"Discovery complete — found {count} unpaired Denon device(s)", "info")
|
||||
|
||||
def _render_unpaired_list(self):
|
||||
for w in self._unpaired_frame.winfo_children():
|
||||
w.destroy()
|
||||
|
||||
if self._discovering:
|
||||
tk.Label(self._unpaired_frame,
|
||||
text="Scanning for Denon devices…",
|
||||
font=self.f_small, bg=PANEL, fg=ACCENT2).pack(anchor="w")
|
||||
return
|
||||
|
||||
if not self._unpaired_devices:
|
||||
return
|
||||
|
||||
tk.Label(self._unpaired_frame,
|
||||
text="UNPAIRED DEVICES", font=self.f_label,
|
||||
bg=PANEL, fg=MUTED).pack(anchor="w", pady=(4, 2))
|
||||
|
||||
for dev in self._unpaired_devices:
|
||||
row = tk.Frame(self._unpaired_frame, bg=PANEL)
|
||||
row.pack(fill="x", pady=1)
|
||||
|
||||
tk.Label(row, text=f"{dev['name']} {dev['mac']}",
|
||||
font=self.f_small, bg=PANEL, fg=MUTED).pack(side="left")
|
||||
|
||||
self._btn(
|
||||
row, "PAIR",
|
||||
lambda d=dev: self._start_pair(d),
|
||||
bg=ACCENT2, fg=BG, font=self.f_small,
|
||||
).pack(side="right", ipadx=8, ipady=2)
|
||||
|
||||
def _start_pair(self, device):
|
||||
if self._pairing:
|
||||
return
|
||||
self._pairing = True
|
||||
self._log_append(f"Pairing with {device['name']} ({device['mac']})…", "info")
|
||||
for w in self._unpaired_frame.winfo_children():
|
||||
for child in w.winfo_children():
|
||||
if isinstance(child, tk.Button):
|
||||
child.configure(state="disabled")
|
||||
threading.Thread(
|
||||
target=self._pair_worker, args=(device,), daemon=True
|
||||
).start()
|
||||
|
||||
def _pair_worker(self, device):
|
||||
success, error = pair_and_trust_device(device["path"])
|
||||
self._msg_queue.put(("pair_done", device, success, error))
|
||||
|
||||
def _handle_pair_done(self, device, success, error):
|
||||
self._pairing = False
|
||||
if success:
|
||||
self._log_append(f"Paired and trusted: {device['name']}", "info")
|
||||
self._unpaired_devices = [
|
||||
d for d in self._unpaired_devices if d["mac"] != device["mac"]
|
||||
]
|
||||
self._render_unpaired_list()
|
||||
self._scan_devices()
|
||||
else:
|
||||
self._log_append(f"Pairing failed: {error}", "info")
|
||||
self._render_unpaired_list()
|
||||
|
||||
# ── Connection ────────────────────────────────────────────────────────────
|
||||
|
||||
def _toggle_connect(self):
|
||||
if self.bt.connected:
|
||||
self.bt.disconnect()
|
||||
self._conn_btn.configure(text="CONNECT", bg=ACCENT, fg=BG)
|
||||
else:
|
||||
if self._selected_device is None or not self._devices:
|
||||
self._log_append("No paired Denon device — use SCAN & PAIR", "info")
|
||||
return
|
||||
mac = self._devices[self._selected_device]["mac"]
|
||||
self._conn_btn.configure(text="DISCONNECT", bg=RED, fg=TEXT)
|
||||
threading.Thread(
|
||||
target=self.bt.connect, args=(mac,), daemon=True
|
||||
).start()
|
||||
|
||||
# ── Message handling ──────────────────────────────────────────────────────
|
||||
|
||||
def _on_bt_message(self, data: bytes, direction: str):
|
||||
self._msg_queue.put(("msg", data, direction))
|
||||
|
||||
def _on_bt_status(self, msg: str):
|
||||
self._msg_queue.put(("status", msg))
|
||||
|
||||
def _poll_queue(self):
|
||||
try:
|
||||
while True:
|
||||
item = self._msg_queue.get_nowait()
|
||||
if item[0] == "status":
|
||||
self._handle_status(item[1])
|
||||
elif item[0] == "msg":
|
||||
self._handle_message(item[1], item[2])
|
||||
elif item[0] == "discovery_done":
|
||||
self._handle_discovery_done(item[1])
|
||||
elif item[0] == "pair_done":
|
||||
self._handle_pair_done(item[1], item[2], item[3])
|
||||
except queue.Empty:
|
||||
pass
|
||||
self.after(50, self._poll_queue)
|
||||
|
||||
def _handle_status(self, msg: str):
|
||||
self._status.set(msg)
|
||||
connected = msg.startswith("Connected")
|
||||
self._status_dot.configure(fg=GREEN if connected else RED)
|
||||
if not connected:
|
||||
self._conn_btn.configure(text="CONNECT", bg=ACCENT, fg=BG)
|
||||
|
||||
def _handle_message(self, data: bytes, direction: str):
|
||||
hex_str = " ".join(f"{b:02x}" for b in data)
|
||||
tag = "sent" if direction == "SENT" else "recv"
|
||||
arrow = "→" if direction == "SENT" else "←"
|
||||
self._log_append(f"{arrow} {hex_str}", tag)
|
||||
|
||||
if len(data) < 3:
|
||||
return
|
||||
|
||||
cat = data[2]
|
||||
typ = data[3] if len(data) > 3 else None
|
||||
|
||||
# Volume report: AT 07 02 03 c5 [vol] ...
|
||||
if cat == 0x07 and typ == 0x02 and len(data) >= 7:
|
||||
if data[4] == 0x03 and data[5] == 0xc5:
|
||||
vol = data[6]
|
||||
self._vol_raw = vol
|
||||
# Display as dB: raw/2 gives the display value shown on the AVR
|
||||
vol_db = vol / 2.0
|
||||
self._vol.set(f"{vol_db:g}")
|
||||
return
|
||||
|
||||
# Mute state: AT 07 1d 01 [state] ...
|
||||
if cat == 0x07 and typ == 0x1d and len(data) >= 6:
|
||||
self._muted = (data[4] == 0x01 and data[5] == 0x01)
|
||||
self._update_mute_ui()
|
||||
return
|
||||
|
||||
# Sound mode: AT 07 1e ...
|
||||
if cat == 0x07 and typ == 0x1e and len(data) >= 6:
|
||||
text_bytes = data[5:]
|
||||
text = bytes(b for b in text_bytes if 32 <= b < 127).decode("ascii", errors="replace").strip()
|
||||
if text:
|
||||
self._sound_mode.set(text)
|
||||
return
|
||||
|
||||
# Input sources: AT 00 04 0a ...
|
||||
if cat == 0x00 and typ == 0x04 and direction == "RECV" and len(data) >= 8:
|
||||
raw = data[4:]
|
||||
discovered = {}
|
||||
for b in raw:
|
||||
if b in INPUT_SOURCES:
|
||||
discovered[b] = INPUT_SOURCES[b]
|
||||
elif 0x40 <= b <= 0x7f:
|
||||
discovered[b] = f"SRC-{b:02x}"
|
||||
if discovered:
|
||||
self._sources = discovered
|
||||
self._render_inputs(discovered)
|
||||
return
|
||||
|
||||
# Current input: AT 00 07 02 [source] ...
|
||||
if cat == 0x00 and typ == 0x07 and direction == "RECV" and len(data) >= 5:
|
||||
if data[4] == 0x02 and len(data) >= 6:
|
||||
self._highlight_input(data[5])
|
||||
return
|
||||
|
||||
# Device name: AT 00 0e ...
|
||||
if cat == 0x00 and typ == 0x0e and direction == "RECV" and len(data) >= 6:
|
||||
name_bytes = data[5:]
|
||||
name = bytes(b for b in name_bytes if 32 <= b < 127).decode("ascii", errors="replace").strip()
|
||||
if name:
|
||||
self._log_append(f" Device: {name}", "info")
|
||||
return
|
||||
|
||||
def _log_append(self, text: str, tag: str = ""):
|
||||
self._log.configure(state="normal")
|
||||
self._log.insert("end", text + "\n", tag)
|
||||
self._log.see("end")
|
||||
self._log.configure(state="disabled")
|
||||
|
||||
|
||||
# ─── Entry point ─────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = DenonRemoteApp()
|
||||
app.mainloop()
|
||||
Reference in New Issue
Block a user