Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
24 changes: 15 additions & 9 deletions custom_components/amplipi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,26 @@
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_NAME, CONF_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from pyamplipi.amplipi import AmpliPi
import logging
from .coordinator import AmpliPiDataClient

from .const import DOMAIN, AMPLIPI_OBJECT, CONF_VENDOR, CONF_VERSION, CONF_WEBAPP, CONF_API_PATH
from .const import DOMAIN, AMPLIPI_OBJECT, UPDATER_URL, CONF_VENDOR, CONF_VERSION, CONF_WEBAPP, CONF_API_PATH

PLATFORMS = ["media_player"]
PLATFORMS = ["media_player", "update"]

_LOGGER = logging.getLogger(__name__)

async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
AMPLIPI_OBJECT: AmpliPi(
f'http://{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}/api/',
10,
coordinator = AmpliPiDataClient(
hass=hass,
config_entry=entry,
endpoint=f'http://{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}/api/',
timeout=10,
http_session=async_get_clientsession(hass)
),
)

hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
AMPLIPI_OBJECT: coordinator,
CONF_VENDOR: entry.data[CONF_VENDOR],
CONF_NAME: entry.data[CONF_NAME],
CONF_HOST: entry.data[CONF_HOST],
Expand All @@ -28,6 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
CONF_VERSION: entry.data[CONF_VERSION],
CONF_WEBAPP: entry.data[CONF_WEBAPP],
CONF_API_PATH: entry.data[CONF_API_PATH],
UPDATER_URL: "http://{entry.data[CONF_HOST]}:5001"
}

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
Expand Down
1 change: 1 addition & 0 deletions custom_components/amplipi/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
CONF_VENDOR = "vendor"
CONF_VERSION = "version"
AMPLIPI_OBJECT = "amplipi_object"
UPDATER_URL = "updater_url"
CONF_WEBAPP = "webapp"
CONF_API_PATH = "api_path"
169 changes: 146 additions & 23 deletions custom_components/amplipi/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,47 @@
Used to synchronize the current AmpliPi state with all of the corresponding HA Entities
"""
from datetime import timedelta
from typing import Optional, Union, Callable

from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .models import Status, Source, Zone, Group, Stream
from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry
from homeassistant.helpers import aiohttp_client
from typing import List
from pydantic import BaseModel
import async_timeout
import logging
from .models import Status, Source, Zone, Group, Stream

from pyamplipi.amplipi import AmpliPi
from pyamplipi.models import SourceUpdate, ZoneUpdate, MultiZoneUpdate, GroupUpdate, PlayMedia, Announcement, Status as PyStatus, Source as PySource, Stream as PyStream, Group as PyGroup, Zone as PyZone, Status as PyStatus

from .models import Status, Source, Zone, Group, Stream
from .const import DOMAIN

class AmpliPiCoordinator(DataUpdateCoordinator):
def __init__(self, hass, logger, config_entry, api):
_LOGGER = logging.getLogger(__name__)

class AmpliPiDataSchema(BaseModel):
status: Status
latest: dict
releases: List[dict]
Comment on lines +25 to +28

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

A lot of the changes in this PR are just downstream effects of needing to add the github info to the data_coordinator's data stream


class AmpliPiDataClient(DataUpdateCoordinator, AmpliPi):
def __init__(self, hass, config_entry, endpoint, timeout, http_session):
super().__init__(
hass,
logger,
_LOGGER,
config_entry=config_entry,
name="hacs_amplipi",
update_interval=timedelta(seconds=2),
always_update=True
)
self.api = api

AmpliPi.__init__(
self,
endpoint=endpoint,
timeout=timeout,
http_session=http_session
)

async def get_friendly_name(self, entity_id):
"""Look up entity in hass.states and get the friendly name"""
Expand All @@ -33,26 +58,47 @@ async def get_entity_id_from_unique_id(self, unique_id: str):
if entry.unique_id == unique_id:
return entry.entity_id
return None

async def fetch_github_releases(self, session) -> tuple[dict, List[dict]]:
"""Fetch latest and all releases from GitHub using aiohttp."""
base_url = "https://api.github.com/repos/micro-nova/AmpliPi/releases"
try:
with async_timeout.timeout(10):
async with session.get(f"{base_url}/latest") as latest_resp:
latest: dict = await latest_resp.json()
async with session.get(base_url) as releases_resp:
releases: List[dict] = await releases_resp.json()
return latest, releases
except Exception as e:
_LOGGER.warning(f"Failed to fetch GitHub releases: {e}")
return {}, [{}]

async def _async_update_data(self) -> Status:
async def _async_update_data(self) -> AmpliPiDataSchema:
"""Fetch data from API endpoint and pre-process into lookup tables."""
return await self.get_status()

async def build_entity(entity, kind: str, cls, original_name: str):
unique_id = f"{DOMAIN}_{kind}_{entity['id']}"
entity_id = await self.get_entity_id_from_unique_id(unique_id) or f"media_player.{unique_id}"
friendly_name = await self.get_friendly_name(entity_id) or original_name
return cls(
**entity,
original_name=original_name,
unique_id=unique_id,
entity_id=entity_id,
friendly_name=friendly_name,
)
async def set_data(self, state: PyStatus) -> AmpliPiDataSchema:
"""
Take in a Status object from the AmpliPi API and add home assistant specific encoding to it before pushing it to global state.
Returns the newly encoded Status object just so that _async_update_data has something to return as well.
"""
async def build_entity(entity: Union[PySource, PyZone, PyGroup, PyStream], kind: str, cls, original_name: str):
try:
unique_id = f"{DOMAIN}_{kind}_{entity['id']}"
entity_id = await self.get_entity_id_from_unique_id(unique_id) or f"media_player.{unique_id}"
friendly_name = await self.get_friendly_name(entity_id) or original_name
return cls(
**entity,
original_name=original_name,
unique_id=unique_id,
entity_id=entity_id,
friendly_name=friendly_name,
)
except TypeError as e:
self.logger.error(f"Original name = {original_name}, entity = {entity}")
raise TypeError(e) from e

try:
state = await self.api.get_status()
state = state.dict()

state["sources"] = [
await build_entity(entity, "source", Source, f"Source {entity['id'] + 1}")
for entity in state["sources"]
Expand All @@ -72,8 +118,85 @@ async def build_entity(entity, kind: str, cls, original_name: str):
await build_entity(entity, "stream", Stream, entity["name"])
for entity in state["streams"]
]

return Status(**state)

status = Status(**state)
latest = self.latest
releases = self.releases
if self.latest is None or status.info.latest_release != self.latest.get("name"):
session = aiohttp_client.async_get_clientsession(self.hass)
latest, releases = await self.fetch_github_releases(session)

data = AmpliPiDataSchema(status=status, latest=latest, releases=releases)
self.async_set_updated_data(data)
return data

except Exception as e:
raise UpdateFailed(f"Error fetching data: {e}")
raise UpdateFailed(f"Error fetching data: {e}") from e

@property
def status(self) -> Optional[Status]:
return self.data.status if self.data is not None else None

@property
def latest(self) -> Optional[dict]:
return self.data.latest if self.data is not None else None

@property
def releases(self) -> Optional[List[dict]]:
return self.data.releases if self.data is not None else None

def intecept_and_consume(func: Callable):
"""Intercept the return of a function and consume the data into the data coordinator"""
async def wrapper(self, *args, **kwargs):
resp = await func(self, *args, **kwargs)
return await self.set_data(resp.dict())
return wrapper

@intecept_and_consume
async def get_status(self) -> AmpliPiDataSchema:
return await super().get_status()

@intecept_and_consume
async def set_source(self, source_id: int, source_update: SourceUpdate) -> AmpliPiDataSchema:
return await super().set_source(source_id, source_update)

@intecept_and_consume
async def set_zone(self, zone_id: int, zone_update: ZoneUpdate) -> AmpliPiDataSchema:
return await super().set_zone(zone_id, zone_update)

@intecept_and_consume
async def set_zones(self, zone_update: MultiZoneUpdate) -> AmpliPiDataSchema:
return await super().set_zones(zone_update)

@intecept_and_consume
async def play_media(self, media: PlayMedia) -> AmpliPiDataSchema:
return await super().play_media(media)

@intecept_and_consume
async def set_group(self, group_id, update: GroupUpdate) -> AmpliPiDataSchema:
return await super().set_group(group_id, update)

@intecept_and_consume
async def announce(self, announcement: Announcement, timeout: Optional[int] = None) -> AmpliPiDataSchema:
return await super().announce(announcement, timeout)

@intecept_and_consume
async def play_stream(self, stream_id: int) -> AmpliPiDataSchema:
return await super().play_stream(stream_id)

@intecept_and_consume
async def pause_stream(self, stream_id: int) -> AmpliPiDataSchema:
return await super().pause_stream(stream_id)

@intecept_and_consume
async def previous_stream(self, stream_id: int) -> AmpliPiDataSchema:
return await super().previous_stream(stream_id)

@intecept_and_consume
async def next_stream(self, stream_id: int) -> AmpliPiDataSchema:
return await super().previous_stream(stream_id)

@intecept_and_consume
async def stop_stream(self, stream_id: int) -> AmpliPiDataSchema:
return await super().stop_stream(stream_id)

Loading