Initiales Release: Busch-Radio Spotify Bridge v1.0.0

Stellt Spotify-Musik via Spotify Connect als HTTP-MP3-Stream bereit,
den das Busch-Jäger Unterputz-Internetradio direkt abspielen kann.

Komponenten:
- ha-addon/: Home Assistant Add-on (librespot + ffmpeg + Icecast2)
- custom_components/: Optionale HA Integration mit Media-Player-Entity
- README.md: Vollständige Installations- und Konfigurationsanleitung
- example-config.yaml: Kommentierte Beispielkonfiguration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 11:52:15 +02:00
parent 5f3ebe9e9f
commit 7b56fdc2bb
14 changed files with 1186 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
"""Busch-Radio Spotify Bridge Home Assistant Custom Component.
Diese Integration stellt einen Media-Player-Entity bereit, der den Status
des Icecast-Streams (vom Add-on) in der HA-Oberfläche anzeigt.
Voraussetzung: Das 'Busch-Radio Spotify Bridge' Add-on muss laufen.
"""
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
_LOGGER = logging.getLogger(__name__)
DOMAIN = "busch_radio_spotify"
PLATFORMS = ["media_player"]
CONF_HOST = "host"
CONF_PORT = "port"
CONF_MOUNT = "mount"
CONF_PASSWORD = "password"
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Integration aus einem Config-Entry aufsetzen."""
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = entry.data
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
_LOGGER.info(
"Busch-Radio Spotify Bridge verbunden: %s:%s%s",
entry.data[CONF_HOST],
entry.data[CONF_PORT],
entry.data[CONF_MOUNT],
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Config-Entry entladen."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id, None)
return unload_ok

View File

@@ -0,0 +1,109 @@
"""Config Flow für die Busch-Radio Spotify Bridge Integration.
Führt den Nutzer durch die Einrichtung in der HA-Oberfläche.
Fragt nach:
- Hostname/IP des HA-Hosts (normalerweise 'localhost' wenn Add-on läuft)
- Port (Standard: 8000)
- Stream-Mount-Pfad (Standard: /stream.mp3)
- Icecast Admin-Passwort (identisch mit 'icecast_password' in der Add-on Konfig)
"""
from __future__ import annotations
import logging
import aiohttp
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from . import CONF_HOST, CONF_MOUNT, CONF_PASSWORD, CONF_PORT, DOMAIN
_LOGGER = logging.getLogger(__name__)
DEFAULT_HOST = "localhost"
DEFAULT_PORT = 8000
DEFAULT_MOUNT = "/stream.mp3"
DEFAULT_PASSWORD = "busch-radio-geheim"
async def _test_icecast_connection(
hass: HomeAssistant, host: str, port: int, password: str
) -> str | None:
"""Verbindung zu Icecast testen.
Gibt None zurück wenn alles ok, sonst einen Fehlercode.
"""
session = async_get_clientsession(hass)
url = f"http://{host}:{port}/admin/stats"
auth = aiohttp.BasicAuth("admin", password)
try:
async with session.get(
url,
auth=auth,
timeout=aiohttp.ClientTimeout(total=5),
) as resp:
if resp.status == 200:
return None
if resp.status == 401:
return "invalid_auth"
return "cannot_connect"
except aiohttp.ClientConnectorError:
return "cannot_connect"
except Exception:
return "unknown"
class BuschRadioSpotifyConfigFlow(
config_entries.ConfigFlow, domain=DOMAIN
):
"""Config Flow für Busch-Radio Spotify Bridge."""
VERSION = 1
async def async_step_user(
self, user_input: dict | None = None
) -> config_entries.FlowResult:
"""Ersten Einrichtungsschritt anzeigen."""
errors: dict[str, str] = {}
if user_input is not None:
error = await _test_icecast_connection(
self.hass,
user_input[CONF_HOST],
user_input[CONF_PORT],
user_input[CONF_PASSWORD],
)
if error is None:
# Doppelte Einrichtung verhindern
await self.async_set_unique_id(
f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}"
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"Busch-Radio ({user_input[CONF_HOST]}:{user_input[CONF_PORT]})",
data=user_input,
)
else:
errors["base"] = error
schema = vol.Schema(
{
vol.Required(CONF_HOST, default=DEFAULT_HOST): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.All(
vol.Coerce(int), vol.Range(min=1, max=65535)
),
vol.Required(CONF_MOUNT, default=DEFAULT_MOUNT): str,
vol.Required(CONF_PASSWORD, default=DEFAULT_PASSWORD): str,
}
)
return self.async_show_form(
step_id="user",
data_schema=schema,
errors=errors,
)

View File

@@ -0,0 +1,12 @@
{
"domain": "busch_radio_spotify",
"name": "Busch-Radio Spotify Bridge",
"version": "1.0.0",
"documentation": "https://gitea.example.com/retr0/busch-radio-spotify",
"issue_tracker": "https://gitea.example.com/retr0/busch-radio-spotify/issues",
"requirements": [],
"dependencies": [],
"codeowners": ["@retr0"],
"iot_class": "local_polling",
"config_flow": true
}

View File

@@ -0,0 +1,202 @@
"""Media-Player-Entity für den Busch-Radio Spotify Bridge Stream.
Pollt den Icecast-Admin-Endpoint alle 30 Sekunden und zeigt:
- Ob der Stream aktiv ist (Quelle verbunden)
- Anzahl der aktuellen Hörer
- Die Stream-URL zum Eintragen ins Radio
Der Entity-Status entspricht dem HA MediaPlayerState:
- PLAYING → mindestens ein Hörer verbunden
- IDLE → Quelle aktiv, aber kein Hörer
- OFF → Icecast nicht erreichbar / Add-on gestoppt
"""
from __future__ import annotations
import logging
import xml.etree.ElementTree as ET
from datetime import timedelta
import aiohttp
from homeassistant.components.media_player import (
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import CONF_HOST, CONF_MOUNT, CONF_PASSWORD, CONF_PORT, DOMAIN
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=30)
REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=5)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Media-Player-Entity aus Config-Entry anlegen."""
async_add_entities([BuschRadioMediaPlayer(hass, entry)], update_before_add=True)
class BuschRadioMediaPlayer(MediaPlayerEntity):
"""Repräsentiert den Busch-Radio Spotify Bridge Stream.
Zeigt den Icecast-Stream-Status in der HA-Oberfläche an.
Die eigentliche Wiedergabe wird über die Spotify-App gesteuert.
"""
_attr_icon = "mdi:radio"
_attr_has_entity_name = False
_attr_should_poll = True
# Dieser Player hat keine eigenen Steuer-Features (Spotify-App übernimmt das),
# aber wir exportieren PLAY/PAUSE als Dummy damit HA ihn als Media-Player erkennt.
_attr_supported_features = MediaPlayerEntityFeature(0)
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self._hass = hass
self._entry = entry
self._host: str = entry.data[CONF_HOST]
self._port: int = entry.data[CONF_PORT]
self._mount: str = entry.data[CONF_MOUNT]
self._password: str = entry.data[CONF_PASSWORD]
self._listeners: int = 0
self._source_connected: bool = False
self._available: bool = False
self._bitrate: str | None = None
self._audio_codec: str | None = None
self._attr_name = f"Busch-Radio ({self._host})"
self._attr_unique_id = f"busch_radio_{self._host}_{self._port}"
# ── Properties ───────────────────────────────────────────────────────────
@property
def stream_url(self) -> str:
"""Öffentliche Stream-URL."""
return f"http://{self._host}:{self._port}{self._mount}"
@property
def state(self) -> MediaPlayerState:
if not self._available:
return MediaPlayerState.OFF
if not self._source_connected:
return MediaPlayerState.IDLE
if self._listeners > 0:
return MediaPlayerState.PLAYING
return MediaPlayerState.IDLE
@property
def available(self) -> bool:
return True # Entity immer verfügbar; Status spiegelt Icecast-Zustand
@property
def extra_state_attributes(self) -> dict:
return {
"stream_url": self.stream_url,
"listeners": self._listeners,
"source_connected": self._source_connected,
"bitrate_kbps": self._bitrate,
"codec": self._audio_codec,
"icecast_status_url": f"http://{self._host}:{self._port}",
}
@property
def media_title(self) -> str | None:
if self._source_connected:
return "Spotify"
return None
@property
def media_artist(self) -> str | None:
if self._listeners > 0:
return f"{self._listeners} Hörer"
return None
# ── Update ───────────────────────────────────────────────────────────────
async def async_update(self) -> None:
"""Icecast-Stats abfragen und Entity-Status aktualisieren."""
session = async_get_clientsession(self._hass)
stats_url = f"http://{self._host}:{self._port}/admin/stats"
auth = aiohttp.BasicAuth("admin", self._password)
try:
async with session.get(
stats_url, auth=auth, timeout=REQUEST_TIMEOUT
) as resp:
if resp.status == 200:
text = await resp.text()
self._parse_icecast_stats(text)
self._available = True
elif resp.status == 401:
_LOGGER.warning(
"Icecast: Authentifizierung fehlgeschlagen. "
"Passwort in der Integration prüfen."
)
self._available = False
else:
_LOGGER.debug(
"Icecast-Stats: HTTP %d von %s", resp.status, stats_url
)
self._available = False
except aiohttp.ClientConnectorError:
_LOGGER.debug(
"Icecast nicht erreichbar unter %s:%d (Add-on läuft?)",
self._host,
self._port,
)
self._available = False
self._source_connected = False
self._listeners = 0
except Exception as exc:
_LOGGER.warning("Fehler beim Abrufen der Icecast-Stats: %s", exc)
self._available = False
def _parse_icecast_stats(self, xml_text: str) -> None:
"""Icecast-XML-Statistiken parsen."""
try:
root = ET.fromstring(xml_text)
except ET.ParseError as exc:
_LOGGER.warning("Icecast-XML konnte nicht geparst werden: %s", exc)
return
# Suche nach unserem Mount-Point
self._source_connected = False
self._listeners = 0
self._bitrate = None
self._audio_codec = None
for source in root.findall("source"):
mount = source.get("mount", "")
if mount != self._mount:
continue
self._source_connected = True
listeners_el = source.find("listeners")
if listeners_el is not None and listeners_el.text:
try:
self._listeners = int(listeners_el.text)
except ValueError:
pass
bitrate_el = source.find("bitrate")
if bitrate_el is not None:
self._bitrate = bitrate_el.text
codec_el = source.find("audio_codecname")
if codec_el is not None:
self._audio_codec = codec_el.text
break

View File

@@ -0,0 +1,24 @@
{
"config": {
"step": {
"user": {
"title": "Busch-Radio Spotify Bridge",
"description": "Verbinde die Integration mit dem laufenden Busch-Radio Add-on.\nDas Passwort muss mit dem 'icecast_password' in der Add-on Konfiguration übereinstimmen.",
"data": {
"host": "Hostname oder IP-Adresse",
"port": "Icecast-Port",
"mount": "Stream-Mount-Pfad",
"password": "Icecast Admin-Passwort"
}
}
},
"error": {
"cannot_connect": "Verbindung zu Icecast fehlgeschlagen. Läuft das Add-on?",
"invalid_auth": "Falsches Passwort. Mit 'icecast_password' in der Add-on Konfig vergleichen.",
"unknown": "Unbekannter Fehler. Prüfe die Logs."
},
"abort": {
"already_configured": "Diese Icecast-Instanz ist bereits konfiguriert."
}
}
}

View File

@@ -0,0 +1,24 @@
{
"config": {
"step": {
"user": {
"title": "Busch-Radio Spotify Bridge",
"description": "Verbinde die Integration mit dem laufenden Busch-Radio Add-on.\nDas Passwort muss mit dem 'icecast_password' in der Add-on Konfiguration übereinstimmen.",
"data": {
"host": "Hostname oder IP-Adresse",
"port": "Icecast-Port",
"mount": "Stream-Mount-Pfad",
"password": "Icecast Admin-Passwort"
}
}
},
"error": {
"cannot_connect": "Verbindung zu Icecast fehlgeschlagen. Läuft das Add-on?",
"invalid_auth": "Falsches Passwort. Mit 'icecast_password' in der Add-on Konfig vergleichen.",
"unknown": "Unbekannter Fehler. Prüfe die Logs."
},
"abort": {
"already_configured": "Diese Icecast-Instanz ist bereits konfiguriert."
}
}
}

View File

@@ -0,0 +1,24 @@
{
"config": {
"step": {
"user": {
"title": "Busch-Radio Spotify Bridge",
"description": "Connect the integration to the running Busch-Radio add-on.\nThe password must match 'icecast_password' in the add-on configuration.",
"data": {
"host": "Hostname or IP address",
"port": "Icecast port",
"mount": "Stream mount path",
"password": "Icecast admin password"
}
}
},
"error": {
"cannot_connect": "Failed to connect to Icecast. Is the add-on running?",
"invalid_auth": "Wrong password. Compare with 'icecast_password' in the add-on config.",
"unknown": "Unknown error. Check the logs."
},
"abort": {
"already_configured": "This Icecast instance is already configured."
}
}
}