Skip to content

Commit

Permalink
Add Sky remote integration (#124507)
Browse files Browse the repository at this point in the history
Co-authored-by: Kyle Cooke <[email protected]>
Co-authored-by: Joost Lekkerkerker <[email protected]>
  • Loading branch information
3 people authored Nov 13, 2024
1 parent f6bc5f0 commit 72b976f
Show file tree
Hide file tree
Showing 17 changed files with 530 additions and 5 deletions.
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -1344,6 +1344,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:
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):
"""Representation of a Sky Remote."""

_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"])

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

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:
self._remote.send_keys(command)
except ValueError as err:
_LOGGER.error("Invalid command: %s. Error: %s", command, err)
return
_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 @@ -537,6 +537,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 @@ -5608,11 +5608,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 @@ -2673,6 +2673,9 @@ simplisafe-python==2024.01.0
# homeassistant.components.sisyphus
sisyphus-control==3.1.4

# 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 @@ -2131,6 +2131,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

0 comments on commit 72b976f

Please sign in to comment.