Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion homeassistant/brands/denon.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"domain": "denon",
"name": "Denon",
"integrations": ["denon", "denonavr", "heos"]
"integrations": ["denon", "denonavr", "denon_rs232", "heos"]
}
58 changes: 58 additions & 0 deletions homeassistant/components/denon_rs232/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""The Denon RS232 integration."""

from __future__ import annotations

from denon_rs232 import DenonReceiver, ReceiverState
from denon_rs232.models import MODELS

from homeassistant.const import CONF_DEVICE, CONF_MODEL, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady

from .const import (
DOMAIN, # noqa: F401
LOGGER,
DenonRS232ConfigEntry,
)

PLATFORMS = [Platform.MEDIA_PLAYER]


async def async_setup_entry(hass: HomeAssistant, entry: DenonRS232ConfigEntry) -> bool:
"""Set up Denon RS232 from a config entry."""
port = entry.data[CONF_DEVICE]
model = MODELS[entry.data[CONF_MODEL]]
receiver = DenonReceiver(port, model=model)

try:
await receiver.connect()
await receiver.query_state()
except (ConnectionError, OSError) as err:
LOGGER.error("Error connecting to Denon receiver at %s: %s", port, err)
if receiver.connected:
await receiver.disconnect()
raise ConfigEntryNotReady from err
Comment on lines +23 to +30
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catch TimeoutError (and any other connect/query exceptions you already handle in the config flow) during config entry setup so the entry retries instead of failing and leaving an open connection.

Copilot uses AI. Check for mistakes.

entry.runtime_data = receiver

@callback
def _on_disconnect(state: ReceiverState | None) -> None:
if state is None:
LOGGER.warning("Denon receiver disconnected, reloading config entry")
hass.config_entries.async_schedule_reload(entry.entry_id)

entry.async_on_unload(receiver.subscribe(_on_disconnect))

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

return True


async def async_unload_entry(hass: HomeAssistant, entry: DenonRS232ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

if unload_ok:
await entry.runtime_data.disconnect()

return unload_ok
141 changes: 141 additions & 0 deletions homeassistant/components/denon_rs232/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""Config flow for the Denon RS232 integration."""

from __future__ import annotations

from typing import Any

from denon_rs232 import DenonReceiver
from denon_rs232.models import MODELS
import voluptuous as vol

from homeassistant.components.usb import human_readable_device_name, scan_serial_ports
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DEVICE, CONF_MODEL

from .const import DOMAIN, LOGGER

MODEL_OPTIONS = {key: model.name for key, model in MODELS.items()}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would we include Other? (Do note again that we can use a selectselector here if we want to keep Other in).

In a way it would be cool if we can discover more models and have people report that to us so we can add them to the list

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RS232 protocol does not support capability or model discovery. Users need to tell us the models in advance so we can populate the right source list.

Example: https://github.com/home-assistant-libs/denon-rs232/blob/main/src/denon_rs232/models.py#L198-L210

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, I figured, but more like, there's not a real nice way for users to tell us their device upfront (and what its capable off). And I guess you can add this option to the reconfiguration flow in the future so the user can select another one.

There's no nice way to allow people to use it while finding out what device they use without disallowing them to set it up.

One option could be to allow to reconfigure it, log in the logs that users need to create an issue with the functionality it has, the model and that they need to reconfigure it once fixed. But that also depends on people reading logs

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So with Other, at least we give them all possible source options. And then hopefully, some people will open github issues and help fix it for everyone.

Reconfigure flow has been left out of the initial PR, can be added in the future.


OPTION_PICK_MANUAL = "Enter Manually"


async def _async_attempt_connect(port: str, model_key: str) -> str | None:
"""Attempt to connect to the receiver at the given port.

Returns None on success, error on failure.
"""
model = MODELS[model_key]
receiver = DenonReceiver(port, model=model)

try:
await receiver.connect()
except (
# When the port contains invalid connection data
ValueError,
# If it is a remote port, and we cannot connect
ConnectionError,
OSError,
):
return "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
return "unknown"
else:
await receiver.disconnect()
return None


class DenonRS232ConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Denon RS232."""

VERSION = 1

_model: str

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}

if user_input is not None:
if user_input[CONF_DEVICE] == OPTION_PICK_MANUAL:
self._model = user_input[CONF_MODEL]
return await self.async_step_manual()

self._async_abort_entries_match({CONF_DEVICE: user_input[CONF_DEVICE]})
error = await _async_attempt_connect(
user_input[CONF_DEVICE], user_input[CONF_MODEL]
)
if not error:
return self.async_create_entry(
title=MODELS[user_input[CONF_MODEL]].name,
data={
CONF_DEVICE: user_input[CONF_DEVICE],
CONF_MODEL: user_input[CONF_MODEL],
},
)
errors["base"] = error

ports = await self.hass.async_add_executor_job(get_ports)
ports[OPTION_PICK_MANUAL] = OPTION_PICK_MANUAL

if user_input is None and ports:
user_input = {CONF_DEVICE: next(iter(ports))}

return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Required(CONF_MODEL): vol.In(MODEL_OPTIONS),
vol.Required(CONF_DEVICE): vol.In(ports),
}
),
user_input or {},
),
errors=errors,
)

async def async_step_manual(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a manual port selection."""
errors: dict[str, str] = {}

if user_input is not None:
self._async_abort_entries_match({CONF_DEVICE: user_input[CONF_DEVICE]})
error = await _async_attempt_connect(user_input[CONF_DEVICE], self._model)
if not error:
return self.async_create_entry(
title=MODELS[self._model].name,
data={
CONF_DEVICE: user_input[CONF_DEVICE],
CONF_MODEL: self._model,
},
)
errors["base"] = error

return self.async_show_form(
step_id="manual",
data_schema=self.add_suggested_values_to_schema(
vol.Schema({vol.Required(CONF_DEVICE): str}),
user_input or {},
),
errors=errors,
)


def get_ports() -> dict[str, str]:
"""Get available serial ports keyed by their device path."""
return {
port.device: human_readable_device_name(
port.device,
port.serial_number,
port.manufacturer,
port.description,
port.vid,
port.pid,
)
for port in scan_serial_ports()
}
12 changes: 12 additions & 0 deletions homeassistant/components/denon_rs232/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Constants for the Denon RS232 integration."""

import logging

from denon_rs232 import DenonReceiver

from homeassistant.config_entries import ConfigEntry

LOGGER = logging.getLogger(__package__)
DOMAIN = "denon_rs232"

type DenonRS232ConfigEntry = ConfigEntry[DenonReceiver]
13 changes: 13 additions & 0 deletions homeassistant/components/denon_rs232/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"domain": "denon_rs232",
"name": "Denon RS232",
"codeowners": ["@balloob"],
"config_flow": true,
"dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/denon_rs232",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["denon_rs232"],
"quality_scale": "bronze",
"requirements": ["denon-rs232==3.0.0"]
}
Loading
Loading