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:
47
custom_components/busch_radio_spotify/__init__.py
Normal file
47
custom_components/busch_radio_spotify/__init__.py
Normal 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
|
||||
109
custom_components/busch_radio_spotify/config_flow.py
Normal file
109
custom_components/busch_radio_spotify/config_flow.py
Normal 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,
|
||||
)
|
||||
12
custom_components/busch_radio_spotify/manifest.json
Normal file
12
custom_components/busch_radio_spotify/manifest.json
Normal 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
|
||||
}
|
||||
202
custom_components/busch_radio_spotify/media_player.py
Normal file
202
custom_components/busch_radio_spotify/media_player.py
Normal 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
|
||||
24
custom_components/busch_radio_spotify/strings.json
Normal file
24
custom_components/busch_radio_spotify/strings.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
24
custom_components/busch_radio_spotify/translations/de.json
Normal file
24
custom_components/busch_radio_spotify/translations/de.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
24
custom_components/busch_radio_spotify/translations/en.json
Normal file
24
custom_components/busch_radio_spotify/translations/en.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user