Skip to content

Commit

Permalink
Uplift to 0.9.8 and merge back from core
Browse files Browse the repository at this point in the history
  • Loading branch information
postlund committed Dec 17, 2021
1 parent ae41668 commit 0ce0162
Show file tree
Hide file tree
Showing 11 changed files with 381 additions and 110 deletions.
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM python:3.9
FROM python:3.10

ARG USER=postlund
ARG UID=1000
Expand Down
28 changes: 22 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@

**==> READ EVERYTHING BEFORE UPGRADING!!! <==**

**TL;DR Initial work to support tvOS 15. Old config is not compatible, please remove
previously added devices before upgrading (or let me know what happens if you don't)!**

This is the beta version of the Apple TV integration for Home Assistant. Use with
care, possibly unstable and/or non-working code lives here. Be warned (but also, be brave).

Expand All @@ -19,15 +16,34 @@ shipped with Home Assistant:

* tvOS 15 support
* HomePods (full media controls)
* Local audio files can be streamed via RAOP (AirPlay) to all supported devices
via the `play_media` service
* Local audio files and HTTP(S) can be streamed via RAOP (AirPlay) to all supported devices
via the `play_media` service. Make sure `media_type` is set to `music`.
* Basic support for arbitrary AirPlay speakers. Metadata **ONLY** works when streaming
from Home Assistant, i.e. it will *not* reflect what someone else is streaming to
the device (the HomePod being an exception). Only `Stop` button works.
the device (the HomePod being an exception). Only `Stop` and `Pause` button works.
* Music app/iTunes in macOS can be controlled
* App launching via `input_source` and media browser (no app icons)
* Support for `volume_set`
* New fields: `media_content_id`, `series_name`, `episode` and `season`

## Changes

## Release 2.2.0

**REQUIRES HOME ASSISTANT 2021.12.x OR LATER!!!**

Updates to pyatv 0.9.8 which brings a few fixes (most relevant here):

* As the power state detection is very unreliable, it is now derived in comibation with
play state (a device could previously be seen as powered off while playing something).
If something is playing then the device is considered on.
* Button presses are more reliable and should not be ignore or get "stuck" anymore.
* Pressing the pause button when streaming to an AirPlay device will now stop playback.

Changes have been merged back from Home Assistant, which includes a lot of performance
updates related to device discovery via Zeroconf. Might be notable if you have many
devices.

## Release 2.1.0

**REQUIRES HOME ASSISTANT 2021.12.x OR LATER!!!**
Expand Down
18 changes: 9 additions & 9 deletions custom_components/apple_tv/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@

DEFAULT_NAME = "Apple TV"

BACKOFF_TIME_LOWER_LIMIT = 15 # seconds
BACKOFF_TIME_UPPER_LIMIT = 300 # Five minutes

SIGNAL_CONNECTED = "apple_tv_connected"
Expand Down Expand Up @@ -241,7 +242,11 @@ async def _connect_loop(self):
if self.atv is None:
self._connection_attempts += 1
backoff = min(
randrange(2 ** self._connection_attempts), BACKOFF_TIME_UPPER_LIMIT
max(
BACKOFF_TIME_LOWER_LIMIT,
randrange(2 ** self._connection_attempts),
),
BACKOFF_TIME_UPPER_LIMIT,
)

_LOGGER.debug("Reconnecting in %d seconds", backoff)
Expand Down Expand Up @@ -271,17 +276,12 @@ async def _scan(self):
return atvs[0]

_LOGGER.debug(
"Failed to find device %s with address %s, trying to scan",
"Failed to find device %s with address %s",
self.config_entry.title,
address,
)

atvs = await scan(self.hass.loop, identifier=identifiers, protocol=protocols)
if atvs:
return atvs[0]

_LOGGER.debug("Failed to find device %s, trying later", self.config_entry.title)

# We no longer multicast scan for the device since as soon as async_step_zeroconf runs,
# it will update the address and reload the config entry when the device is found.
return None

async def _connect(self, conf):
Expand Down
14 changes: 4 additions & 10 deletions custom_components/apple_tv/browse_media.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,20 @@

def build_app_list(app_list):
"""Create response payload for app list."""
title = None
media = None
children_media_class = None

title = "Apps"
media = [
app_list = [
{"app_id": app_id, "title": app_name, "type": MEDIA_TYPE_APP}
for app_name, app_id in app_list.items()
]
children_media_class = MEDIA_CLASS_APP

return BrowseMedia(
media_class=MEDIA_CLASS_DIRECTORY,
media_content_id=None,
media_content_type=MEDIA_TYPE_APPS,
title=title,
title="Apps",
can_play=True,
can_expand=False,
children=[item_payload(item) for item in media],
children_media_class=children_media_class,
children=[item_payload(item) for item in app_list],
children_media_class=MEDIA_CLASS_APP,
)


Expand Down
130 changes: 96 additions & 34 deletions custom_components/apple_tv/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
"""Config flow for Apple TV integration."""
from __future__ import annotations

import asyncio
from collections import deque
from ipaddress import ip_address
import logging
Expand Down Expand Up @@ -27,6 +30,8 @@

DEFAULT_START_OFF = False

DISCOVERY_AGGREGATION_TIME = 15 # seconds


async def device_scan(identifier, loop):
"""Scan for a specific device using identifier as filter."""
Expand All @@ -46,12 +51,13 @@ def _host_filter():
except ValueError:
return None

for hosts in (_host_filter(), None):
scan_result = await scan(loop, timeout=3, hosts=hosts)
matches = [atv for atv in scan_result if _filter_device(atv)]
# If we have an address, only probe that address to avoid
# broadcast traffic on the network
scan_result = await scan(loop, timeout=3, hosts=_host_filter())
matches = [atv for atv in scan_result if _filter_device(atv)]

if matches:
return matches[0], matches[0].all_identifiers
if matches:
return matches[0], matches[0].all_identifiers

return None, None

Expand Down Expand Up @@ -93,12 +99,21 @@ def device_identifier(self):
existing config entry. If that's the case, the unique_id from that entry is
re-used, otherwise the newly discovered identifier is used instead.
"""
for entry in self._async_current_entries():
for identifier in self.atv.all_identifiers:
if identifier in entry.data.get(CONF_IDENTIFIERS, [entry.unique_id]):
return entry.unique_id
all_identifiers = set(self.atv.all_identifiers)
if unique_id := self._entry_unique_id_from_identifers(all_identifiers):
return unique_id
return self.atv.identifier

@callback
def _entry_unique_id_from_identifers(self, all_identifiers: set[str]) -> str | None:
"""Search existing entries for an identifier and return the unique id."""
for entry in self._async_current_entries():
if all_identifiers.intersection(
entry.data.get(CONF_IDENTIFIERS, [entry.unique_id])
):
return entry.unique_id
return None

async def async_step_reauth(self, user_input=None):
"""Handle initial step when updating invalid credentials."""
self.context["title_placeholders"] = {
Expand Down Expand Up @@ -149,22 +164,32 @@ async def async_step_zeroconf(
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> data_entry_flow.FlowResult:
"""Handle device found via zeroconf."""
host = discovery_info.host
self._async_abort_entries_match({CONF_ADDRESS: host})
service_type = discovery_info.type[:-1] # Remove leading .
name = discovery_info.name.replace(f".{service_type}.", "")
properties = discovery_info.properties

# Extract unique identifier from service
self.scan_filter = get_unique_id(service_type, name, properties)
if self.scan_filter is None:
unique_id = get_unique_id(service_type, name, properties)
if unique_id is None:
return self.async_abort(reason="unknown")

if existing_unique_id := self._entry_unique_id_from_identifers({unique_id}):
await self.async_set_unique_id(existing_unique_id)
self._abort_if_unique_id_configured(updates={CONF_ADDRESS: host})

self._async_abort_entries_match({CONF_ADDRESS: host})
await self._async_aggregate_discoveries(host, unique_id)
# Scan for the device in order to extract _all_ unique identifiers assigned to
# it. Not doing it like this will yield multiple config flows for the same
# device, one per protocol, which is undesired.
self.scan_filter = host
return await self.async_find_device_wrapper(self.async_found_zeroconf_device)

async def async_found_zeroconf_device(self, user_input=None):
"""Handle device found after Zeroconf discovery."""
async def _async_aggregate_discoveries(self, host: str, unique_id: str) -> None:
"""Wait for multiple zeroconf services to be discovered an aggregate them."""
#
# Suppose we have a device with three services: A, B and C. Let's assume
# service A is discovered by Zeroconf, triggering a device scan that also finds
# service B but *not* C. An identifier is picked from one of the services and
Expand All @@ -177,31 +202,59 @@ async def async_found_zeroconf_device(self, user_input=None):
# since both flows really represent the same device. They will however end up
# as two separate flows.
#
# To solve this, all identifiers found during a device scan is stored as
# To solve this, all identifiers are stored as
# "all_identifiers" in the flow context. When a new service is discovered, the
# code below will check these identifiers for all active flows and abort if a
# match is found. Before aborting, the original flow is updated with any
# potentially new identifiers. In the example above, when service C is
# discovered, the identifier of service C will be inserted into
# "all_identifiers" of the original flow (making the device complete).
for flow in self._async_in_progress():
for identifier in self.atv.all_identifiers:
if identifier not in flow["context"].get("all_identifiers", []):
continue
#
# Wait DISCOVERY_AGGREGATION_TIME for multiple services to be
# discovered via zeroconf. Once the first service is discovered
# this allows other services to be discovered inside the time
# window before triggering a scan of the device. This prevents
# multiple scans of the device at the same time since each
# apple_tv device has multiple services that are discovered by
# zeroconf.
#
self._async_check_and_update_in_progress(host, unique_id)
await asyncio.sleep(DISCOVERY_AGGREGATION_TIME)
# Check again after sleeping in case another flow
# has made progress while we yielded to the event loop
self._async_check_and_update_in_progress(host, unique_id)
# Host must only be set AFTER checking and updating in progress
# flows or we will have a race condition where no flows move forward.
self.context[CONF_ADDRESS] = host

@callback
def _async_check_and_update_in_progress(self, host: str, unique_id: str) -> None:
"""Check for in-progress flows and update them with identifiers if needed."""
for flow in self._async_in_progress(include_uninitialized=True):
context = flow["context"]
if (
context.get("source") != config_entries.SOURCE_ZEROCONF
or context.get(CONF_ADDRESS) != host
):
continue
if (
"all_identifiers" in context
and unique_id not in context["all_identifiers"]
):
# Add potentially new identifiers from this device to the existing flow
identifiers = set(flow["context"]["all_identifiers"])
identifiers.update(self.atv.all_identifiers)
flow["context"]["all_identifiers"] = list(identifiers)

raise data_entry_flow.AbortFlow("already_in_progress")
context["all_identifiers"].append(unique_id)
raise data_entry_flow.AbortFlow("already_in_progress")

async def async_found_zeroconf_device(self, user_input=None):
"""Handle device found after Zeroconf discovery."""
self.context["all_identifiers"] = self.atv.all_identifiers

# Also abort if an integration with this identifier already exists
await self.async_set_unique_id(self.device_identifier)
self._abort_if_unique_id_configured()

# but be sure to update the address if its changed so the scanner
# will probe the new address
self._abort_if_unique_id_configured(
updates={CONF_ADDRESS: str(self.atv.address)}
)
self.context["identifier"] = self.unique_id
return await self.async_step_confirm()

Expand Down Expand Up @@ -245,14 +298,23 @@ async def async_find_device(self, allow_exist=False):
else model_str(dev_info.model)
),
}

if not allow_exist:
for identifier in self.atv.all_identifiers:
for entry in self._async_current_entries():
if identifier in entry.data.get(
CONF_IDENTIFIERS, [entry.unique_id]
):
raise DeviceAlreadyConfigured()
all_identifiers = set(self.atv.all_identifiers)
discovered_ip_address = str(self.atv.address)
for entry in self._async_current_entries():
if not all_identifiers.intersection(
entry.data.get(CONF_IDENTIFIERS, [entry.unique_id])
):
continue
if entry.data.get(CONF_ADDRESS) != discovered_ip_address:
self.hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_ADDRESS: discovered_ip_address},
)
self.hass.async_create_task(
self.hass.config_entries.async_reload(entry.entry_id)
)
if not allow_exist:
raise DeviceAlreadyConfigured()

async def async_step_confirm(self, user_input=None):
"""Handle user-confirmation of discovered node."""
Expand Down
7 changes: 4 additions & 3 deletions custom_components/apple_tv/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@
"name": "Apple TV",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/apple_tv",
"requirements": ["pyatv==0.9.7"],
"requirements": ["pyatv==0.9.8"],
"zeroconf": [
"_mediaremotetv._tcp.local.",
"_touch-able._tcp.local.",
{"type":"_airplay._tcp.local.","model":"appletv*"},
{"type":"_airplay._tcp.local.","model":"audioaccessory*"},
"_appletv-v2._tcp.local.",
"_airplay._tcp.local.",
"_raop._tcp.local.",
"_hscp._tcp.local."
],
"codeowners": ["@postlund"],
"version": "2.1.0",
"version": "2.2.0",
"iot_class": "local_push"
}
12 changes: 8 additions & 4 deletions custom_components/apple_tv/media_player.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Support for Apple TV media player."""
import logging

from pyatv import exceptions
from pyatv.const import (
DeviceState,
FeatureName,
Expand Down Expand Up @@ -150,10 +151,13 @@ async def _update_app_list(self):
_LOGGER.debug("Updating app list")
try:
apps = await self.atv.apps.app_list()
except exceptions.NotSupportedError:
_LOGGER.error("Listing apps is not supported")
except exceptions.ProtocolError:
_LOGGER.exception("Failed to update app list")
else:
self._app_list = {app.name: app.identifier for app in apps}
self.async_write_ha_state()
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Failed to update app list")

@callback
def async_device_disconnected(self):
Expand Down Expand Up @@ -375,8 +379,8 @@ def _is_feature_available(self, feature):

async def async_browse_media(
self,
media_content_type=None, # : str | None = None,
media_content_id=None # : str | None = None,
media_content_type=None,
media_content_id=None,
) -> BrowseMedia:
"""Implement the websocket media browsing helper."""
return build_app_list(self._app_list)
Expand Down
Loading

0 comments on commit 0ce0162

Please sign in to comment.