Initial commit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user