Initial commit

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ckoch
2026-06-06 10:59:47 -04:00
commit a5d2865eb2
10 changed files with 1800 additions and 0 deletions
+25
View File
@@ -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)
+106
View File
@@ -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,
)
+37
View File
@@ -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",
}
+10
View File
@@ -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"
}
+221
View File
@@ -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)
+359
View File
@@ -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."
}
}
}