Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Sky remote integration #124507

Merged
merged 20 commits into from
Nov 13, 2024
Merged
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
2 changes: 2 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -1309,6 +1309,8 @@ build.json @home-assistant/supervisor
/tests/components/siren/ @home-assistant/core @raman325
/homeassistant/components/sisyphus/ @jkeljo
/homeassistant/components/sky_hub/ @rogerselwyn
/homeassistant/components/sky_remote/ @dunnmj @saty9
/tests/components/sky_remote/ @dunnmj @saty9
/homeassistant/components/skybell/ @tkdrob
/tests/components/skybell/ @tkdrob
/homeassistant/components/slack/ @tkdrob @fletcherau
Expand Down
5 changes: 5 additions & 0 deletions homeassistant/brands/sky.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"domain": "sky",
"name": "Sky",
"integrations": ["sky_hub", "sky_remote"]
}
39 changes: 39 additions & 0 deletions homeassistant/components/sky_remote/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""The Sky Remote Control integration."""

import logging

from skyboxremote import RemoteControl, SkyBoxConnectionError

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady

PLATFORMS = [Platform.REMOTE]

_LOGGER = logging.getLogger(__name__)


type SkyRemoteConfigEntry = ConfigEntry[RemoteControl]


async def async_setup_entry(hass: HomeAssistant, entry: SkyRemoteConfigEntry) -> bool:
"""Set up Sky remote."""
host = entry.data[CONF_HOST]
port = entry.data[CONF_PORT]

_LOGGER.debug("Setting up Host: %s, Port: %s", host, port)
remote = RemoteControl(host, port)
try:
dunnmj marked this conversation as resolved.
Show resolved Hide resolved
await remote.check_connectable()
except SkyBoxConnectionError as e:
raise ConfigEntryNotReady from e

entry.runtime_data = remote
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."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
64 changes: 64 additions & 0 deletions homeassistant/components/sky_remote/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Config flow for sky_remote."""

import logging
from typing import Any

from skyboxremote import RemoteControl, SkyBoxConnectionError
import voluptuous as vol

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
import homeassistant.helpers.config_validation as cv

from .const import DEFAULT_PORT, DOMAIN, LEGACY_PORT

DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
}
)


async def async_find_box_port(host: str) -> int:
"""Find port box uses for communication."""
logging.debug("Attempting to find port to connect to %s on", host)
remote = RemoteControl(host, DEFAULT_PORT)
try:
await remote.check_connectable()
except SkyBoxConnectionError:
# Try legacy port if the default one failed
remote = RemoteControl(host, LEGACY_PORT)
await remote.check_connectable()
return LEGACY_PORT
return DEFAULT_PORT


class SkyRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Sky Remote."""

VERSION = 1
MINOR_VERSION = 1

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the user step."""

errors: dict[str, str] = {}
if user_input is not None:
logging.debug("user_input: %s", user_input)
self._async_abort_entries_match(user_input)
try:
port = await async_find_box_port(user_input[CONF_HOST])
except SkyBoxConnectionError:
logging.exception("while finding port of skybox")
errors["base"] = "cannot_connect"
else:
return self.async_create_entry(
title=user_input[CONF_HOST],
data={**user_input, CONF_PORT: port},
)

return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
6 changes: 6 additions & 0 deletions homeassistant/components/sky_remote/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Constants."""

DOMAIN = "sky_remote"

DEFAULT_PORT = 49160
LEGACY_PORT = 5900
10 changes: 10 additions & 0 deletions homeassistant/components/sky_remote/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"domain": "sky_remote",
"name": "Sky Remote Control",
"codeowners": ["@dunnmj", "@saty9"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sky_remote",
"integration_type": "device",
"iot_class": "assumed_state",
"requirements": ["skyboxremote==0.0.6"]
}
70 changes: 70 additions & 0 deletions homeassistant/components/sky_remote/remote.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Home Assistant integration to control a sky box using the remote platform."""

from collections.abc import Iterable
import logging
from typing import Any

from skyboxremote import VALID_KEYS, RemoteControl

from homeassistant.components.remote import RemoteEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback

from . import SkyRemoteConfigEntry
from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)


async def async_setup_entry(
hass: HomeAssistant,
config: SkyRemoteConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Sky remote platform."""
async_add_entities(
[SkyRemote(config.runtime_data, config.entry_id)],
True,
)


class SkyRemote(RemoteEntity):
dunnmj marked this conversation as resolved.
Show resolved Hide resolved
"""Representation of a Sky Remote."""
dunnmj marked this conversation as resolved.
Show resolved Hide resolved

_attr_has_entity_name = True
_attr_name = None

def __init__(self, remote: RemoteControl, unique_id: str) -> None:
"""Initialize the Sky Remote."""
self._remote = remote
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
manufacturer="SKY",
model="Sky Box",
name=remote.host,
)

def turn_on(self, activity: str | None = None, **kwargs: Any) -> None:
"""Send the power on command."""
self.send_command(["sky"])

Check warning on line 52 in homeassistant/components/sky_remote/remote.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/sky_remote/remote.py#L52

Added line #L52 was not covered by tests

def turn_off(self, activity: str | None = None, **kwargs: Any) -> None:
"""Send the power command."""
self.send_command(["power"])

Check warning on line 56 in homeassistant/components/sky_remote/remote.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/sky_remote/remote.py#L56

Added line #L56 was not covered by tests

def send_command(self, command: Iterable[str], **kwargs: Any) -> None:
"""Send a list of commands to the device."""
for cmd in command:
if cmd not in VALID_KEYS:
raise ServiceValidationError(
f"{cmd} is not in Valid Keys: {VALID_KEYS}"
)
try:
dunnmj marked this conversation as resolved.
Show resolved Hide resolved
self._remote.send_keys(command)
Copy link
Member

Choose a reason for hiding this comment

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

What can an user send with this integration?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Commands that can be sent are listed in the docs for the integration - https://github.com/home-assistant/home-assistant.io/pull/34419/files

except ValueError as err:
_LOGGER.error("Invalid command: %s. Error: %s", command, err)
dunnmj marked this conversation as resolved.
Show resolved Hide resolved
return

Check warning on line 69 in homeassistant/components/sky_remote/remote.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/sky_remote/remote.py#L67-L69

Added lines #L67 - L69 were not covered by tests
_LOGGER.debug("Successfully sent command %s", command)
21 changes: 21 additions & 0 deletions homeassistant/components/sky_remote/strings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"config": {
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"step": {
"user": {
"title": "Add Sky Remote",
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "Hostname or IP address of your Sky device"
}
}
}
}
}
1 change: 1 addition & 0 deletions homeassistant/generated/config_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,7 @@
"simplefin",
"simplepush",
"simplisafe",
"sky_remote",
"skybell",
"slack",
"sleepiq",
Expand Down
21 changes: 16 additions & 5 deletions homeassistant/generated/integrations.json
Original file line number Diff line number Diff line change
Expand Up @@ -5489,11 +5489,22 @@
"config_flow": false,
"iot_class": "local_push"
},
"sky_hub": {
"name": "Sky Hub",
"integration_type": "hub",
"config_flow": false,
"iot_class": "local_polling"
"sky": {
"name": "Sky",
"integrations": {
"sky_hub": {
"integration_type": "hub",
"config_flow": false,
"iot_class": "local_polling",
"name": "Sky Hub"
},
"sky_remote": {
"integration_type": "device",
"config_flow": true,
"iot_class": "assumed_state",
"name": "Sky Remote Control"
}
}
},
"skybeacon": {
"name": "Skybeacon",
Expand Down
3 changes: 3 additions & 0 deletions requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2609,6 +2609,9 @@ simplisafe-python==2024.01.0
# homeassistant.components.sisyphus
sisyphus-control==3.1.3

# homeassistant.components.sky_remote
skyboxremote==0.0.6

# homeassistant.components.slack
slackclient==2.5.0

Expand Down
3 changes: 3 additions & 0 deletions requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2058,6 +2058,9 @@ simplepush==2.2.3
# homeassistant.components.simplisafe
simplisafe-python==2024.01.0

# homeassistant.components.sky_remote
skyboxremote==0.0.6

# homeassistant.components.slack
slackclient==2.5.0

Expand Down
13 changes: 13 additions & 0 deletions tests/components/sky_remote/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Tests for the Sky Remote component."""

from homeassistant.core import HomeAssistant

from tests.common import MockConfigEntry


async def setup_mock_entry(hass: HomeAssistant, entry: MockConfigEntry):
"""Initialize a mock config entry."""
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)

await hass.async_block_till_done()
47 changes: 47 additions & 0 deletions tests/components/sky_remote/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Test mocks and fixtures."""

from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from homeassistant.components.sky_remote.const import DEFAULT_PORT, DOMAIN
from homeassistant.const import CONF_HOST, CONF_PORT

from tests.common import MockConfigEntry

SAMPLE_CONFIG = {CONF_HOST: "example.com", CONF_PORT: DEFAULT_PORT}


@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Mock a config entry."""
return MockConfigEntry(domain=DOMAIN, data=SAMPLE_CONFIG)


@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Stub out setup function."""
with patch(
"homeassistant.components.sky_remote.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry


@pytest.fixture
def mock_remote_control(request: pytest.FixtureRequest) -> Generator[MagicMock]:
"""Mock skyboxremote library."""
with (
patch(
"homeassistant.components.sky_remote.RemoteControl"
) as mock_remote_control,
patch(
"homeassistant.components.sky_remote.config_flow.RemoteControl",
mock_remote_control,
),
):
mock_remote_control._instance_mock = MagicMock(host="example.com")
mock_remote_control._instance_mock.check_connectable = AsyncMock(True)
mock_remote_control.return_value = mock_remote_control._instance_mock
yield mock_remote_control
Loading