Compare commits

...

10 Commits

Author SHA1 Message Date
1bd3fab67a Add SSL 2026-01-09 22:37:50 +01:00
Lyfesaver
363004fc99 Update README.md 2025-10-03 17:47:51 -05:00
Lyfesaver
3af45732a8 Update README.md 2025-10-03 17:29:17 -05:00
Lyfesaver
ce81d74ab1 Update manifest.json 2025-10-03 17:18:52 -05:00
Lyfesaver
97bc934030 Update sensor.py 2025-10-03 17:18:09 -05:00
Lyfesaver
456e662578 Update __init__.py 2025-10-03 17:17:42 -05:00
Lyfesaver
138d2ec2fe Create media_player.py 2025-10-03 17:17:16 -05:00
Lyfesaver
8e81cd8688 Update README.md 2025-10-03 16:00:41 -05:00
Lyfesaver
2a67f93990 Update README.md 2025-10-03 15:59:56 -05:00
Lyfesaver
28b6c0798e Update manifest.json 2025-10-03 15:53:02 -05:00
6 changed files with 304 additions and 216 deletions

138
README.md
View File

@@ -1,86 +1,94 @@
# Dispatcharr Integration for Home Assistant
This is a custom integration for Home Assistant that monitors active streams from a Dispatcharr server. It provides sensors to track the total number of streams and detailed information for each individual stream.
[![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://github.com/custom-components/hacs)
This is a custom integration for [Home Assistant](https://www.home-assistant.io/) that connects to your [Dispatcharr](https://github.com/Dispatcharr/Dispatcharr) instance. It provides real-time monitoring of active streams, creating dynamic media player entities for each stream and a summary sensor for the total count.
## Features
- Provides a sensor showing the total number of active streams (`sensor.total_active_streams`).
- Dynamically creates a unique sensor for each active stream, which is automatically removed when the stream stops.
- Pulls detailed program guide (EPG) information for each active stream, including program/episode titles and numbers.
- Displays stream-specific details like resolution, codecs, and client count.
- Includes an option to disable EPG fetching to reduce server load.
* **Total Stream Count:** A dedicated sensor (`sensor.dispatcharr_total_active_streams`) that shows the total number of currently active streams.
* **Dynamic Media Player Entities:** Creates a new media player entity for each active stream automatically. These entities are removed when the stream stops, keeping your Home Assistant instance clean.
* **Rich Stream Data:** Each media player provides detailed attributes, including channel name, client count, video/audio codecs, and more.
* **Live Program Information (Optional):** Parses your EPG data to display the currently airing program's title, description, and times as media player attributes. This feature can be disabled for performance.
## Installation and Configuration
## Prerequisites
### Initial Setup
* An understanding and acceptance that AI helped me make this. If that is not your thang... don't use it.
* A running instance of [Dispatcharr](https://github.com/Dispatcharr/Dispatcharr).
* [Home Assistant Community Store (HACS)](https://hacs.xyz/) installed on your Home Assistant instance.
* The username and password for your Dispatcharr user account.
1. Copy the `dispatcharr_sensor` directory into your Home Assistant `<config>/custom_components/` directory.
2. Restart Home Assistant.
3. Go to **Settings** > **Devices & Services** > **Add Integration**.
4. Search for "Dispatcharr" and select it.
5. In the configuration dialog, enter the following information:
- **Host:** The IP address of your Dispatcharr server (e.g., `192.168.0.121`).
- **Port:** The port your Dispatcharr server is running on (e.g., `9191`).
- **Username:** Your Dispatcharr username.
- **Password:** Your Dispatcharr password.
## Installation via HACS
### Optional Settings
This integration is not yet in the default HACS repository. You can add it as a custom repository.
To reduce the load on your server, especially on systems with limited resources, you can disable the fetching of detailed EPG (program guide) data. When disabled, sensors for active streams will still be created, but program-related attributes (`program_title`, `episode_title`, etc.) will not be populated.
1. In Home Assistant, go to **HACS** > **Integrations**.
2. Click the three dots in the top-right corner and select **"Custom repositories"**.
3. In the "Repository" field, enter the URL to this GitHub repository: `https://github.com/lyfesaver74/ha-dispatcharr`
4. For the "Category" dropdown, select **"Integration"**.
5. Click **"Add"**.
6. You should now see the "Dispatcharr Integration" in your HACS integrations list. Click **"Install"** and proceed with the installation.
7. Restart Home Assistant when prompted.
To change this setting:
1. Navigate to **Settings** > **Devices & Services**.
2. Find your Dispatcharr integration and click **Configure**.
3. A dialog box will appear. Uncheck the box labeled **"Enable EPG Program Data"** to disable it.
4. Click **Submit**.
## Configuration
## Sensors Provided
Once the integration is installed, you can add it to Home Assistant via the UI.
### Total Active Streams Sensor
1. Go to **Settings** > **Devices & Services**.
2. Click the **"+ Add Integration"** button in the bottom right.
3. Search for **"Dispatcharr"** and select it.
4. A configuration dialog will appear. Enter the following information:
* **Host:** The IP address of your Dispatcharr server (e.g., `192.168.0.121`).
* **Port:** The port your Dispatcharr server is running on (default is `9191`).
* **Username:** Your Dispatcharr username.
* **Password:** Your Dispatcharr password.
5. Click **"Submit"**. The integration will be set up and your entities will be created.
A single sensor that provides a numeric count of the total active streams.
## Optional Configuration (After Installation)
- **Entity ID:** `sensor.total_active_streams`
- **State:** A number representing the count of active streams (e.g., `2`).
The EPG data feature can be turned on or off at any time without re-installing the integration. This is useful for performance tuning on slower servers.
### Individual Stream Sensors
1. Go to **Settings** > **Devices & Services**.
2. Find the Dispatcharr integration and click **"Configure"**.
3. Check or uncheck the **"Enable EPG Data"** option.
4. Click **"Submit"**. The integration will automatically reload with the new setting.
These sensors are created on-the-fly when a stream starts and are removed when it stops.
## Provided Entities
- **Entity ID:** Will be generated based on the channel name, like `sensor.dispatcharr_amc`.
- **State:** "Streaming"
- **Attributes:**
- `channel_number`: The channel number from the EPG guide (e.g., `102`).
- `channel_name`: The display name of the channel (e.g., `AMC`).
- `program_title`: The title of the currently airing program.
- `episode_title`: The title of the specific episode, if available.
- `episode_number`: The season/episode number (e.g., `S1E18`), if available.
- `program_description`: The description of the current program.
- `program_start`: The start time of the current program (ISO format).
- `program_stop`: The end time of the current program (ISO format).
- `clients`: The number of clients watching the stream.
- `resolution`: The resolution of the stream (e.g., `1280x720`).
- `fps`: The frame rate of the stream.
- `video_codec`: The video codec being used.
- `audio_codec`: The audio codec being used.
- `avg_bitrate`: The average bitrate of the stream.
The integration will create the following entities:
## Example Lovelace Card
* **`sensor.dispatcharr_total_active_streams`**: A sensor that always exists and shows the total number of active streams. Its state is a number (e.g., `0`, `1`, `2`).
* **`media_player.dispatcharr_<channel_name>`** (Dynamic): A new media player entity will be created for each active stream. The entity ID is based on the channel name.
* The state will be `playing` when active.
* These entities will be removed automatically when no longer active.
You can use a Markdown card in your Home Assistant dashboard to display a clean summary of all active streams.
### Media Player Attributes
Each dynamic media player entity will have the following attributes:
| Attribute | Description | Example |
|---|---|---|
| `media_title` | The title of the currently airing program. | `Doctor Who` |
| `media_series_title` | The friendly name of the channel. | `US: BBC AMERICA HD` |
| `media_content_id` | The stream's internal channel number/ID. | `98209` |
| `app_name` | The source of the stream. | `Dispatcharr` |
| `entity_picture` | A direct URL to the channel's logo image. | `http://.../logos/262/cache/` |
| `clients` | The number of clients watching this stream. | `1` |
| `resolution` | The current video resolution. | `1280x720` |
| `fps` | The current frames per second. | `59.94` |
| `video_codec` | The video codec being used. | `h264` |
| `audio_codec` | The audio codec being used. | `aac` |
| `avg_bitrate` | The average bitrate of the stream. | `4.11 Mbps` |
| `program_description` | A description of the current program. | `The Doctor travels through time...` |
| `program_start` | The start time of the current program. | `2025-10-02T14:00:00-05:00` |
| `program_stop` | The end time of the current program. | `2025-10-02T15:00:00-05:00` |
## Troubleshooting
* **Program Data is `null`:** If the `media_title` and other program attributes are `null` (and the EPG option is enabled), it means the integration was unable to find matching guide data for that specific channel in your Dispatcharr EPG file. Please ensure that the channel has EPG data assigned within the Dispatcharr UI and that your EPG source has been recently refreshed.
* **Authentication Errors:** If you receive errors after setup, double-check that your Dispatcharr username and password are correct.
## License
This project is licensed under the MIT License - see the `LICENSE` file for details.
```yaml
type: markdown
title: Active Dispatcharr Streams
content: |
{% for stream in states.sensor | selectattr('attributes.channel_name', 'defined') | selectattr('entity_id', 'search', 'dispatcharr_') %}
**{{ stream.attributes.channel_name }} ({{ stream.attributes.channel_number }})**
*Now Playing:* {{ stream.attributes.program_title }}
{% if stream.attributes.episode_title %}
*Episode:* {{ stream.attributes.episode_title }} ({{ stream.attributes.episode_number }})
{% endif %}
*Clients:* {{ stream.attributes.clients }} | *Resolution:* {{ stream.attributes.resolution }}
***
{% else %}
No active streams.
{% endif %}

View File

@@ -17,14 +17,13 @@ from homeassistant.util import slugify
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.SENSOR]
PLATFORMS = [Platform.SENSOR, Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Dispatcharr from a config entry."""
coordinator = DispatcharrDataUpdateCoordinator(hass, entry)
# Perform initial data population. This will raise ConfigEntryNotReady on failure.
await coordinator.async_populate_channel_details()
await coordinator.async_populate_channel_map_from_xml()
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
@@ -32,6 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
@@ -46,7 +46,7 @@ class DispatcharrDataUpdateCoordinator(DataUpdateCoordinator):
self.config_entry = config_entry
self.websession = async_get_clientsession(hass)
self._access_token: str | None = None
self.channel_details: dict = {}
self.channel_map: dict = {}
super().__init__(
hass, _LOGGER, name=DOMAIN, update_interval=timedelta(seconds=30)
@@ -98,38 +98,96 @@ class DispatcharrDataUpdateCoordinator(DataUpdateCoordinator):
except aiohttp.ClientError as err:
raise UpdateFailed(f"API request to {url} failed: {err}") from err
async def async_populate_channel_details(self):
"""Fetch all channel details to build a lookup map."""
_LOGGER.info("Populating Dispatcharr channel details")
try:
all_channels = await self._api_request("GET", f"{self.base_url}/api/channels/channels/")
if isinstance(all_channels, list):
self.channel_details = {
channel['uuid']: channel for channel in all_channels if 'uuid' in channel
}
else:
_LOGGER.warning("Expected a list of channels, but received: %s", type(all_channels))
self.channel_details = {}
_LOGGER.debug("Found %d channels", len(self.channel_details))
except Exception as e:
_LOGGER.error("Could not populate channel details: %s", e)
raise ConfigEntryNotReady(f"Could not fetch channel details: {e}") from e
async def _get_current_programs_from_xml(self, numeric_channel_ids: list[str]) -> dict:
"""Get current program for EPG IDs by parsing the raw XMLTV file."""
if not numeric_channel_ids:
return {}
now = datetime.now(timezone.utc)
async def async_populate_channel_map_from_xml(self):
"""Fetch the XML file once to build a reliable map of channels."""
_LOGGER.info("Populating Dispatcharr channel map from XML file...")
try:
xml_string = await self._api_request("GET", f"{self.base_url}/output/epg", is_json=False)
except UpdateFailed as err:
raise ConfigEntryNotReady(f"Could not fetch EPG XML file to build channel map: {err}") from err
try:
root = ET.fromstring(xml_string)
self.channel_map = {}
for channel in root.iterfind("channel"):
display_name = channel.findtext("display-name")
channel_id = channel.get("id")
icon_tag = channel.find("icon")
icon_url = icon_tag.get("src") if icon_tag is not None else None
if display_name and channel_id:
slug_name = slugify(display_name)
self.channel_map[slug_name] = {"id": channel_id, "name": display_name, "logo_url": icon_url}
found_programs, channels_to_find = {}, set(numeric_channel_ids)
for program in root.iterfind("programme"):
if not channels_to_find: break
channel_id_str = program.get("channel")
if channel_id_str in channels_to_find:
if not self.channel_map:
raise ConfigEntryNotReady("XML was fetched, but no channels could be mapped.")
_LOGGER.info("Successfully built channel map with %d entries.", len(self.channel_map))
except ET.ParseError as e:
_LOGGER.error("Failed to parse XML for channel map: %s", e)
raise ConfigEntryNotReady(f"Failed to parse XML for channel map: {e}") from e
def _get_channel_details_from_stream_name(self, stream_name: str) -> dict | None:
"""(REWRITTEN) Match a stream name to a channel in the map, preferring the longest match."""
if not stream_name:
return None
simple_stream_name = slugify(re.sub(r'^\w+:\s*|\s+HD$', '', stream_name, flags=re.IGNORECASE))
_LOGGER.debug("Attempting to match simplified stream name: '%s'", simple_stream_name)
# 1. Try for a direct, exact match first (most reliable)
if simple_stream_name in self.channel_map:
_LOGGER.debug("Found exact match for '%s'", simple_stream_name)
return self.channel_map[simple_stream_name]
# 2. If no exact match, find all possible substring matches
possible_matches = []
for slug_key, details in self.channel_map.items():
if slug_key in simple_stream_name:
possible_matches.append((slug_key, details))
# 3. If any matches were found, sort them by length and return the longest one
if possible_matches:
_LOGGER.debug("Found possible matches: %s", [m[0] for m in possible_matches])
# Sort by the length of the key (item[0]), descending, and return the details of the best match
best_match = sorted(possible_matches, key=lambda item: len(item[0]), reverse=True)[0]
_LOGGER.debug("Selected best match: '%s'", best_match[0])
return best_match[1]
_LOGGER.debug("Could not find any match for stream name: '%s'", stream_name)
return None
async def _async_update_data(self):
"""Update data by fetching from authenticated endpoints."""
status_data = await self._api_request("GET", f"{self.base_url}/proxy/ts/status")
active_streams = status_data.get("channels", [])
if not active_streams: return {}
xml_string = await self._api_request("GET", f"{self.base_url}/output/epg", is_json=False)
try:
root = ET.fromstring(xml_string)
except ET.ParseError as e:
_LOGGER.error("Could not parse EPG XML on update: %s", e)
return self.data
enriched_streams = {}
now = datetime.now(timezone.utc)
for stream in active_streams:
stream_uuid = stream.get("channel_id")
stream_name = stream.get("stream_name")
if not stream_uuid or not stream_name: continue
details = self._get_channel_details_from_stream_name(stream_name)
enriched_stream = stream.copy()
if details:
xmltv_id = details["id"]
enriched_stream["xmltv_id"] = xmltv_id
enriched_stream["channel_name"] = details["name"]
enriched_stream["logo_url"] = details.get("logo_url")
for program in root.iterfind(f".//programme[@channel='{xmltv_id}']"):
start_str, stop_str = program.get("start"), program.get("stop")
if start_str and stop_str:
try:
@@ -137,47 +195,16 @@ class DispatcharrDataUpdateCoordinator(DataUpdateCoordinator):
stop_time = datetime.strptime(stop_str, "%Y%m%d%H%M%S %z")
if start_time <= now < stop_time:
episode_num_tag = program.find("episode-num[@system='onscreen']")
found_programs[channel_id_str] = {
"title": program.findtext("title"),
"description": program.findtext("desc"),
"start_time": start_time.isoformat(),
"end_time": stop_time.isoformat(),
enriched_stream["program"] = {
"title": program.findtext("title"), "description": program.findtext("desc"),
"start_time": start_time.isoformat(), "end_time": stop_time.isoformat(),
"subtitle": program.findtext("sub-title"),
"episode_num": episode_num_tag.text if episode_num_tag is not None else None,
}
channels_to_find.remove(channel_id_str)
except (ValueError, TypeError): continue
return found_programs
except (UpdateFailed, ET.ParseError) as e:
_LOGGER.warning("Could not get or parse EPG XML file, program info will be unavailable: %s", e)
return {}
async def _async_update_data(self):
"""Update data via authenticated API calls."""
status_data = await self._api_request("GET", f"{self.base_url}/proxy/ts/status")
active_streams = status_data.get("channels", [])
if not active_streams: return {}
break
except (ValueError, TypeError):
continue
current_programs_map = {}
if self.config_entry.options.get("enable_epg", True):
active_numeric_ids = list(set([
str(int(details['channel_number']))
for stream in active_streams
if (details := self.channel_details.get(stream['channel_id'])) and details.get('channel_number') is not None
]))
if active_numeric_ids:
current_programs_map = await self._get_current_programs_from_xml(active_numeric_ids)
enriched_streams = {}
for stream in active_streams:
stream_uuid = stream['channel_id']
enriched_stream = stream.copy()
details = self.channel_details.get(stream_uuid)
if details:
if logo_id := details.get("logo_id"):
enriched_stream["logo_url"] = f"{self.base_url}/api/channels/logos/{logo_id}/cache/"
if numeric_id_float := details.get("channel_number"):
numeric_id_str = str(int(numeric_id_float))
enriched_stream["program"] = current_programs_map.get(numeric_id_str)
enriched_streams[stream_uuid] = enriched_stream
return enriched_streams

View File

@@ -14,6 +14,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required("host"): str,
vol.Required("port", default=9191): int,
vol.Optional("ssl",default=False): bool,
vol.Required("username"): str,
vol.Required("password"): str,
}
@@ -33,4 +34,4 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
)
return self.async_create_entry(title="Dispatcharr", data=user_input)
return self.async_create_entry(title="Dispatcharr", data=user_input)

View File

@@ -1,7 +1,7 @@
{
"domain": "dispatcharr_sensor",
"name": "Dispatcharr Sessions Sensor",
"version": "2.2.0",
"version": "3.0.0",
"documentation": "https://github.com/lyfesaver74/ha-dispatcharr",
"requirements": [],
"dependencies": [],
@@ -10,3 +10,5 @@
"config_flow": true
}

View File

@@ -0,0 +1,128 @@
"""Media Player platform for Dispatcharr."""
import logging
import re
from homeassistant.core import HomeAssistant, callback
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.components.media_player import (
MediaPlayerEntity,
MediaPlayerDeviceClass,
MediaType,
)
from homeassistant.const import STATE_PLAYING
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.exceptions import PlatformNotReady
from .const import DOMAIN
from . import DispatcharrDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the media_player platform from a ConfigEntry."""
try:
coordinator = hass.data[DOMAIN][config_entry.entry_id]
except KeyError:
raise PlatformNotReady(f"Coordinator not found for entry {config_entry.entry_id}")
DispatcharrStreamManager(coordinator, async_add_entities)
class DispatcharrStreamManager:
"""Manages the creation and removal of media_player entities."""
def __init__(self, coordinator: DispatcharrDataUpdateCoordinator, async_add_entities: AddEntitiesCallback):
self._coordinator = coordinator
self._async_add_entities = async_add_entities
self._known_stream_ids = set()
self._coordinator.async_add_listener(self._update_entities)
@callback
def _update_entities(self) -> None:
"""Update, add, or remove entities based on coordinator data."""
if self._coordinator.data is None:
current_stream_ids = set()
else:
current_stream_ids = set(self._coordinator.data.keys())
new_stream_ids = current_stream_ids - self._known_stream_ids
if new_stream_ids:
new_entities = [DispatcharrStreamMediaPlayer(self._coordinator, stream_id) for stream_id in new_stream_ids]
self._async_add_entities(new_entities)
self._known_stream_ids.update(new_stream_ids)
class DispatcharrStreamMediaPlayer(CoordinatorEntity, MediaPlayerEntity):
"""Representation of a single Dispatcharr stream as a Media Player."""
_attr_should_poll = False
_attr_has_entity_name = True
_attr_device_class = MediaPlayerDeviceClass.TV
_attr_supported_features = 0 # Read-only entity supports no features
def __init__(self, coordinator: DispatcharrDataUpdateCoordinator, stream_id: str):
super().__init__(coordinator)
self._stream_id = stream_id
stream_data = self.coordinator.data.get(self._stream_id) or {}
name = stream_data.get("channel_name", stream_data.get("stream_name", f"Stream {self._stream_id[-6:]}"))
self._attr_name = name
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self._stream_id}"
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, name="Dispatcharr")
@property
def available(self) -> bool:
"""Return True if the stream is still in the coordinator's data."""
return super().available and self.coordinator.data is not None and self._stream_id in self.coordinator.data
# ADDED: This property override directly prevents the TypeError.
@property
def support_grouping(self) -> bool:
"""Flag if grouping is supported."""
return False
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if not self.available:
self.async_write_ha_state()
return
stream_data = self.coordinator.data[self._stream_id]
program_data = stream_data.get("program") or {}
# Set standard media player properties
self._attr_state = STATE_PLAYING
self._attr_app_name = "Dispatcharr"
self._attr_entity_picture = stream_data.get("logo_url")
self._attr_media_content_type = MediaType.TVSHOW
self._attr_media_series_title = program_data.get("title")
self._attr_media_title = program_data.get("subtitle") or program_data.get("title")
# Parse season and episode number
self._attr_media_season = None
self._attr_media_episode = None
episode_num_str = program_data.get("episode_num")
if episode_num_str:
match = re.search(r'S(\d+)E(\d+)', episode_num_str, re.IGNORECASE)
if match:
self._attr_media_season = int(match.group(1))
self._attr_media_episode = int(match.group(2))
# Store other details in extra attributes
self._attr_extra_state_attributes = {
"channel_number": stream_data.get("xmltv_id"),
"channel_name": stream_data.get("channel_name"),
"program_description": program_data.get("description"),
"program_start": program_data.get("start_time"),
"program_stop": program_data.get("end_time"),
"clients": stream_data.get("client_count"),
"resolution": stream_data.get("resolution"),
"video_codec": stream_data.get("video_codec"),
"audio_codec": stream_data.get("audio_codec"),
}
self.async_write_ha_state()

View File

@@ -25,32 +25,10 @@ async def async_setup_entry(
except KeyError:
raise PlatformNotReady(f"Coordinator not found for entry {config_entry.entry_id}")
DispatcharrStreamManager(coordinator, async_add_entities)
# This platform now ONLY creates the total streams sensor.
async_add_entities([DispatcharrTotalStreamSensor(coordinator)])
class DispatcharrStreamManager:
"""Manages the creation and removal of stream sensors."""
def __init__(self, coordinator: DispatcharrDataUpdateCoordinator, async_add_entities: AddEntitiesCallback):
self._coordinator = coordinator
self._async_add_entities = async_add_entities
self._known_stream_ids = set()
self._coordinator.async_add_listener(self._update_sensors)
@callback
def _update_sensors(self) -> None:
"""Update, add, or remove sensors based on coordinator data."""
if self._coordinator.data is None:
current_stream_ids = set()
else:
current_stream_ids = set(self._coordinator.data.keys())
new_stream_ids = current_stream_ids - self._known_stream_ids
if new_stream_ids:
new_sensors = [DispatcharrStreamSensor(self._coordinator, stream_id) for stream_id in new_stream_ids]
self._async_add_entities(new_sensors)
self._known_stream_ids.update(new_stream_ids)
class DispatcharrTotalStreamSensor(CoordinatorEntity, SensorEntity):
"""A sensor to show the total number of active Dispatcharr streams."""
_attr_state_class = SensorStateClass.MEASUREMENT
@@ -67,59 +45,3 @@ class DispatcharrTotalStreamSensor(CoordinatorEntity, SensorEntity):
def _handle_coordinator_update(self) -> None:
self._attr_native_value = len(self.coordinator.data or {})
self.async_write_ha_state()
class DispatcharrStreamSensor(CoordinatorEntity, SensorEntity):
"""Representation of a single, dynamic Dispatcharr stream sensor."""
_attr_should_poll = False
_attr_has_entity_name = True
def __init__(self, coordinator: DispatcharrDataUpdateCoordinator, stream_id: str):
super().__init__(coordinator)
self._stream_id = stream_id
channel_details = coordinator.channel_details.get(stream_id) or {}
name = channel_details.get("name", f"Stream {self._stream_id[-6:]}")
self._attr_name = name
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self._stream_id}"
self._attr_icon = "mdi:television-stream"
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, name="Dispatcharr")
@property
def available(self) -> bool:
"""Return True if the stream is still in the coordinator's data."""
return super().available and self.coordinator.data is not None and self._stream_id in self.coordinator.data
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if not self.available:
self.async_write_ha_state()
return
stream_data = self.coordinator.data[self._stream_id]
program_data = stream_data.get("program") or {}
channel_details = self.coordinator.channel_details.get(self._stream_id) or {}
self._attr_native_value = "Streaming"
self._attr_entity_picture = stream_data.get("logo_url")
self._attr_name = channel_details.get("name", self._attr_name)
self._attr_extra_state_attributes = {
"channel_number": channel_details.get("channel_number"),
"channel_name": channel_details.get("name"),
"logo_url": stream_data.get("logo_url"),
"clients": stream_data.get("client_count"),
"resolution": stream_data.get("resolution"),
"fps": stream_data.get("source_fps"),
"video_codec": stream_data.get("video_codec"),
"audio_codec": stream_data.get("audio_codec"),
"avg_bitrate": stream_data.get("avg_bitrate"),
"program_title": program_data.get("title"),
"episode_title": program_data.get("subtitle"),
"episode_number": program_data.get("episode_num"),
"program_description": program_data.get("description"),
"program_start": program_data.get("start_time"),
"program_stop": program_data.get("end_time"),
}
self.async_write_ha_state()