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>
203 lines
7.0 KiB
Python
203 lines
7.0 KiB
Python
"""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
|