Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/master' into dual-stack
Browse files Browse the repository at this point in the history
  • Loading branch information
bdraco committed Mar 20, 2024
2 parents 4529903 + b2e3c9d commit 0df6d2a
Show file tree
Hide file tree
Showing 8 changed files with 70 additions and 38 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/pythonpublish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.1.1
- uses: actions/checkout@v4.1.2
- name: Set up Python
uses: actions/setup-python@v5.0.0
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4.1.1
- uses: actions/checkout@v4.1.2
- name: Set up Python 3.11
uses: actions/setup-python@v5.0.0
with:
Expand Down
2 changes: 2 additions & 0 deletions pychromecast/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

MF_CANTON = "Canton Elektronik GmbH + Co. KG"
MF_GOOGLE = "Google Inc."
MF_HARMAN = "HARMAN International Industries"
MF_JBL = "JBL"
MF_LENOVO = "LENOVO"
MF_LG = "LG"
Expand Down Expand Up @@ -47,6 +48,7 @@
"JBL Link 20": (CAST_TYPE_AUDIO, MF_JBL),
"JBL Link 300": (CAST_TYPE_AUDIO, MF_JBL),
"JBL Link 500": (CAST_TYPE_AUDIO, MF_JBL),
"JBL Link Portable": (CAST_TYPE_AUDIO, MF_HARMAN),
"lenovocd-24502f": (CAST_TYPE_AUDIO, MF_LENOVO),
"Lenovo Smart Display 7": (CAST_TYPE_CHROMECAST, MF_LENOVO),
"LG WK7 ThinQ Speaker": (CAST_TYPE_AUDIO, MF_LG),
Expand Down
30 changes: 29 additions & 1 deletion pychromecast/controllers/homeassistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from collections.abc import Callable
from functools import partial
import logging
import threading
from typing import Any

Expand All @@ -29,6 +30,8 @@
ERR_NOT_CONNECTED = 21
ERR_FETCH_CONFIG_FAILED = 22

_LOGGER = logging.getLogger(__name__)


class HomeAssistantController(BaseController):
"""Controller to interact with Home Assistant."""
Expand All @@ -45,6 +48,7 @@ def __init__(
app_id: str = APP_HOMEASSISTANT_LOVELACE,
hass_connect_timeout: float = DEFAULT_HASS_CONNECT_TIMEOUT,
) -> None:
_LOGGER.debug("HomeAssistantController.__init__")
super().__init__(app_namespace, app_id)
self.hass_url = hass_url
self.hass_uuid = hass_uuid
Expand Down Expand Up @@ -77,16 +81,19 @@ def hass_connected(self) -> bool:
def channel_connected(self) -> None:
"""Called when a channel has been openend that supports the
namespace of this controller."""
_LOGGER.debug("HomeAssistantController.channel_connected")
self.get_status()

def channel_disconnected(self) -> None:
"""Called when a channel is disconnected."""
_LOGGER.debug("HomeAssistantController.channel_disconnected")
self.status = None
self._hass_connecting_event.set()

def receive_message(self, _message: CastMessage, data: dict) -> bool:
"""Called when a message is received."""
if data.get("type") == "receiver_status":
_LOGGER.debug("HomeAssistantController.receive_message %s", data)
if data["hassUrl"] != self.hass_url or data["hassUUID"] != self.hass_uuid:
self.logger.info("Received status for another instance")
self.unregister()
Expand All @@ -96,6 +103,9 @@ def receive_message(self, _message: CastMessage, data: dict) -> bool:
self.status = data

if was_connected or not self.hass_connected:
_LOGGER.debug(
"HomeAssistantController.receive_message already connected"
)
return True

# We just got connected, call the callbacks.
Expand All @@ -104,6 +114,7 @@ def receive_message(self, _message: CastMessage, data: dict) -> bool:
return True

if data.get("type") == "receiver_error":
_LOGGER.debug("HomeAssistantController.receive_message %s", data)
if data.get("error_code") == ERR_WRONG_INSTANCE:
self.logger.info("Received ERR_WRONG_INSTANCE")
self.unregister()
Expand All @@ -113,14 +124,19 @@ def receive_message(self, _message: CastMessage, data: dict) -> bool:

def _call_on_connect_callbacks(self, msg_sent: bool) -> None:
"""Call on connect callbacks."""
_LOGGER.debug("HomeAssistantController._call_on_connect_callbacks %s", msg_sent)
while self._on_connect:
self._on_connect.pop()(msg_sent, None)

def _connect_hass(self, callback_function: CallbackType) -> None:
"""Connect to Home Assistant and call the provided callback."""
_LOGGER.debug("HomeAssistantController._connect_hass")
self._on_connect.append(callback_function)

if not self._hass_connecting_event.is_set():
_LOGGER.debug(
"HomeAssistantController._connect_hass _hass_connecting_event not set"
)
return

self._hass_connecting_event.clear()
Expand All @@ -135,6 +151,9 @@ def _connect_hass(self, callback_function: CallbackType) -> None:
}
)
except Exception: # pylint: disable=broad-except
_LOGGER.debug(
"HomeAssistantController._connect_hass failed to send connect message"
)
self._hass_connecting_event.set()
self._call_on_connect_callbacks(False)
raise
Expand All @@ -143,17 +162,18 @@ def _connect_hass(self, callback_function: CallbackType) -> None:
try:
if not self._hass_connecting_event.is_set():
self.logger.warning("_connect_hass failed for %s", self.hass_url)
self._call_on_connect_callbacks(False)
raise PyChromecastError() # pylint: disable=broad-exception-raised
finally:
self._hass_connecting_event.set()
self._call_on_connect_callbacks(False)

def show_demo(self) -> None:
"""Show the demo."""
self.send_message({"type": "show_demo"})

def get_status(self, *, callback_function: CallbackType | None = None) -> None:
"""Get status of Home Assistant Cast."""
_LOGGER.debug("HomeAssistantController.get_status")
self._send_connected_message(
{
"type": "get_status",
Expand All @@ -171,6 +191,7 @@ def show_lovelace_view(
callback_function: CallbackType | None = None,
) -> None:
"""Show a Lovelace UI."""
_LOGGER.debug("HomeAssistantController.show_lovelace_view")
self._send_connected_message(
{
"type": "show_lovelace_view",
Expand All @@ -186,10 +207,17 @@ def _send_connected_message(
self, data: dict[str, Any], callback_function: CallbackType | None
) -> None:
"""Send a message to a connected Home Assistant Cast"""
_LOGGER.debug("HomeAssistantController._send_connected_message %s", data)
if self.hass_connected:
_LOGGER.debug(
"HomeAssistantController._send_connected_message already connected"
)
self.send_message_nocheck(data, callback_function=callback_function)
return

_LOGGER.debug(
"HomeAssistantController._send_connected_message not yet connected"
)
self._connect_hass(
chain_on_success(
partial(self.send_message_nocheck, data), callback_function
Expand Down
4 changes: 4 additions & 0 deletions pychromecast/response_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
from __future__ import annotations

from collections.abc import Callable
import logging
import threading
from typing import Protocol

from .error import RequestFailed, RequestTimeout

_LOGGER = logging.getLogger(__name__)

CallbackType = Callable[[bool, dict | None], None]
"""Signature of optional callback functions supported by methods sending messages.
Expand Down Expand Up @@ -61,6 +64,7 @@ def chain_on_success(

def _callback(msg_sent: bool, response: dict | None) -> None:
if not msg_sent:
_LOGGER.debug("Not calling on_success %s", on_success)
if callback_function:
callback_function(msg_sent, response)
return
Expand Down
52 changes: 25 additions & 27 deletions pychromecast/socket_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,31 +10,31 @@
from __future__ import annotations

import abc
from dataclasses import dataclass
import errno
import json
import logging
import select
import selectors
import socket
import ssl
import threading
import time
from collections import defaultdict
from dataclasses import dataclass
from struct import pack, unpack

import zeroconf

from .controllers import CallbackType, BaseController
from .const import MESSAGE_TYPE, REQUEST_ID, SESSION_ID
from .controllers import BaseController, CallbackType
from .controllers.media import MediaController
from .controllers.receiver import CastStatus, CastStatusListener, ReceiverController
from .const import MESSAGE_TYPE, REQUEST_ID, SESSION_ID
from .dial import get_host_from_service
from .error import (
ChromecastConnectionError,
ControllerNotRegistered,
UnsupportedNamespace,
NotConnected,
PyChromecastStopped,
UnsupportedNamespace,
)

# pylint: disable-next=no-name-in-module
Expand Down Expand Up @@ -64,13 +64,9 @@
CONNECTION_STATUS_FAILED_RESOLVE = "FAILED_RESOLVE"
# The socket connection was lost and needs to be retried
CONNECTION_STATUS_LOST = "LOST"
# Check for select poll method
SELECT_HAS_POLL = hasattr(select, "poll")

HB_PING_TIME = 10
HB_PONG_TIME = 10
POLL_TIME_BLOCKING = 5.0
POLL_TIME_NON_BLOCKING = 0.01
TIMEOUT_TIME = 30.0
RETRY_TIME = 5.0

Expand Down Expand Up @@ -215,6 +211,11 @@ def __init__(
self.connecting = True
self.first_connection = True
self.socket: socket.socket | ssl.SSLSocket | None = None
self.selector = selectors.DefaultSelector()
self.wakeup_selector_key = self.selector.register(
self.socketpair[0], selectors.EVENT_READ
)
self.remote_selector_key: selectors.SelectorKey | None = None

# dict mapping namespace on Controller objects
self._handlers: dict[str, set[BaseController]] = defaultdict(set)
Expand All @@ -238,8 +239,10 @@ def initialize_connection( # pylint:disable=too-many-statements, too-many-branc
tries = self.tries

if self.socket is not None:
self.selector.unregister(self.socket)
self.socket.close()
self.socket = None
self.remote_selector_key = None

# Make sure nobody is blocking.
for callback_function in self._request_callbacks.values():
Expand Down Expand Up @@ -288,10 +291,15 @@ def mdns_backoff(
try:
if self.socket is not None:
# If we retry connecting, we need to clean up the socket again
self.socket.close() # type: ignore[unreachable]
self.selector.unregister(self.socket) # type: ignore[unreachable]
self.socket.close()
self.socket = None
self.remote_selector_key = None

self.socket = new_socket()
self.remote_selector_key = self.selector.register(
self.socket, selectors.EVENT_READ
)
self.socket.settimeout(self.timeout)
self._report_connection_status(
ConnectionStatus(
Expand Down Expand Up @@ -539,7 +547,7 @@ def run(self) -> None:
self.logger.debug("Thread started...")
while not self.stop.is_set():
try:
if self._run_once(timeout=POLL_TIME_BLOCKING) == 1:
if self._run_once() == 1:
break
except Exception: # pylint: disable=broad-except
self._force_recon = True
Expand All @@ -554,7 +562,7 @@ def run(self) -> None:
# Clean up
self._cleanup()

def _run_once(self, timeout: float = POLL_TIME_NON_BLOCKING) -> int:
def _run_once(self) -> int:
"""Receive from the socket and handle data."""
# pylint: disable=too-many-branches, too-many-statements, too-many-return-statements

Expand All @@ -568,20 +576,8 @@ def _run_once(self, timeout: float = POLL_TIME_NON_BLOCKING) -> int:
assert self.socket is not None

# poll the socket, as well as the socketpair to allow us to be interrupted
rlist = [self.socket, self.socketpair[0]]
try:
if SELECT_HAS_POLL is True:
# Map file descriptors to socket objects because select.select does not support fd > 1024
# https://stackoverflow.com/questions/14250751/how-to-increase-filedescriptors-range-in-python-select
fd_to_socket = {rlist_item.fileno(): rlist_item for rlist_item in rlist}

poll_obj = select.poll()
for poll_fd in rlist:
poll_obj.register(poll_fd, select.POLLIN)
poll_result = poll_obj.poll(timeout * 1000) # timeout in milliseconds
can_read = [fd_to_socket[fd] for fd, _status in poll_result]
else:
can_read, _, _ = select.select(rlist, [], [], timeout)
ready = self.selector.select()
except (ValueError, OSError) as exc:
self.logger.error(
"[%s(%s):%s] Error in select call: %s",
Expand All @@ -593,9 +589,10 @@ def _run_once(self, timeout: float = POLL_TIME_NON_BLOCKING) -> int:
self._force_recon = True
return 0

can_read = {key for key, _ in ready}
# read message from chromecast
message = None
if self.socket in can_read and not self._force_recon:
if self.remote_selector_key in can_read and not self._force_recon:
try:
message = self._read_message()
except InterruptLoop as exc:
Expand Down Expand Up @@ -631,7 +628,7 @@ def _run_once(self, timeout: float = POLL_TIME_NON_BLOCKING) -> int:
else:
data = _dict_from_message_payload(message)

if self.socketpair[0] in can_read:
if self.wakeup_selector_key in can_read:
# Clear the socket's buffer
self.socketpair[0].recv(128)

Expand Down Expand Up @@ -776,6 +773,7 @@ def _cleanup(self) -> None:

self.socketpair[0].close()
self.socketpair[1].close()
self.selector.close()

self.connecting = True

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "PyChromecast"
version = "14.0.0"
version = "14.0.1"
description = "Python module to talk to Google Chromecast."
readme = "README.rst"
authors = [
Expand Down
14 changes: 7 additions & 7 deletions requirements-test.txt
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
beautifulsoup4==4.12.3
black==24.2.0
black==24.3.0
flake8==7.0.0
mypy==1.8.0
mypy==1.9.0
PlexAPI==4.15.10
pylint==3.0.3
pylint==3.1.0
rstcheck==6.2.0
types-beautifulsoup4==4.12.0.20240106
types-html5lib==1.1.11.20240217
types-protobuf==4.24.0.20240129
types-requests==2.31.0.20240218
types-beautifulsoup4==4.12.0.20240229
types-html5lib==1.1.11.20240228
types-protobuf==4.24.0.20240311
types-requests==2.31.0.20240311
yle-dl==20240130

0 comments on commit 0df6d2a

Please sign in to comment.