Add zeroconf discovery to philips_js (#147913)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Joakim Plate 2025-07-04 22:24:40 +02:00 committed by GitHub
parent 6e607ffa01
commit 470baa782e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 178 additions and 52 deletions

View File

@ -6,7 +6,13 @@ from collections.abc import Mapping
import platform
from typing import Any
from haphilipsjs import ConnectionFailure, PairingFailure, PhilipsTV
from haphilipsjs import (
DEFAULT_API_VERSION,
ConnectionFailure,
GeneralFailure,
PairingFailure,
PhilipsTV,
)
import voluptuous as vol
from homeassistant.config_entries import (
@ -18,16 +24,18 @@ from homeassistant.config_entries import (
from homeassistant.const import (
CONF_API_VERSION,
CONF_HOST,
CONF_NAME,
CONF_PASSWORD,
CONF_PIN,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import callback
from homeassistant.helpers import selector
from homeassistant.helpers.schema_config_entry_flow import (
SchemaFlowFormStep,
SchemaOptionsFlowHandler,
)
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import LOGGER
from .const import CONF_ALLOW_NOTIFY, CONF_SYSTEM, CONST_APP_ID, CONST_APP_NAME, DOMAIN
@ -54,21 +62,6 @@ OPTIONS_FLOW = {
}
async def _validate_input(
hass: HomeAssistant, host: str, api_version: int
) -> PhilipsTV:
"""Validate the user input allows us to connect."""
hub = PhilipsTV(host, api_version)
await hub.getSystem()
await hub.setTransport(hub.secured_transport)
if not hub.system:
raise ConnectionFailure("System data is empty")
return hub
class PhilipsJSConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Philips TV."""
@ -81,6 +74,38 @@ class PhilipsJSConfigFlow(ConfigFlow, domain=DOMAIN):
self._hub: PhilipsTV | None = None
self._pair_state: Any = None
async def _async_attempt_prepare(
self, host: str, api_version: int, secured_transport: bool
) -> None:
hub = PhilipsTV(
host, api_version=api_version, secured_transport=secured_transport
)
await hub.getSystem()
await hub.setTransport(hub.secured_transport)
if not hub.system or not hub.name:
raise ConnectionFailure("System data or name is empty")
self._hub = hub
self._current[CONF_HOST] = host
self._current[CONF_SYSTEM] = hub.system
self._current[CONF_API_VERSION] = hub.api_version
self.context.update({"title_placeholders": {CONF_NAME: hub.name}})
if serialnumber := hub.system.get("serialnumber"):
await self.async_set_unique_id(serialnumber)
if self.source != SOURCE_REAUTH:
self._abort_if_unique_id_configured(
updates=self._current, reload_on_update=True
)
async def _async_attempt_add(self) -> ConfigFlowResult:
assert self._hub
if self._hub.pairing_type == "digest_auth_pairing":
return await self.async_step_pair()
return await self._async_create_current()
async def _async_create_current(self) -> ConfigFlowResult:
system = self._current[CONF_SYSTEM]
if self.source == SOURCE_REAUTH:
@ -154,6 +179,43 @@ class PhilipsJSConfigFlow(ConfigFlow, domain=DOMAIN):
self._current[CONF_API_VERSION] = entry_data[CONF_API_VERSION]
return await self.async_step_user()
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
LOGGER.debug(
"Checking discovered device: {discovery_info.name} on {discovery_info.host}"
)
secured_transport = discovery_info.type == "_philipstv_s_rpc._tcp.local."
api_version = 6 if secured_transport else DEFAULT_API_VERSION
try:
await self._async_attempt_prepare(
discovery_info.host, api_version, secured_transport
)
except GeneralFailure:
LOGGER.debug("Failed to get system info from discovery", exc_info=True)
return self.async_abort(reason="discovery_failure")
return await self.async_step_zeroconf_confirm()
async def async_step_zeroconf_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initiated by zeroconf."""
if user_input is not None:
return await self._async_attempt_add()
name = self.context.get("title_placeholders", {CONF_NAME: "Philips TV"})[
CONF_NAME
]
return self.async_show_form(
step_id="zeroconf_confirm",
description_placeholders={CONF_NAME: name},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@ -162,28 +224,14 @@ class PhilipsJSConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input:
self._current = user_input
try:
hub = await _validate_input(
self.hass, user_input[CONF_HOST], user_input[CONF_API_VERSION]
await self._async_attempt_prepare(
user_input[CONF_HOST], user_input[CONF_API_VERSION], False
)
except ConnectionFailure as exc:
except GeneralFailure as exc:
LOGGER.error(exc)
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
if serialnumber := hub.system.get("serialnumber"):
await self.async_set_unique_id(serialnumber)
if self.source != SOURCE_REAUTH:
self._abort_if_unique_id_configured()
self._current[CONF_SYSTEM] = hub.system
self._current[CONF_API_VERSION] = hub.api_version
self._hub = hub
if hub.pairing_type == "digest_auth_pairing":
return await self.async_step_pair()
return await self._async_create_current()
return await self._async_attempt_add()
schema = self.add_suggested_values_to_schema(USER_SCHEMA, self._current)
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)

View File

@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/philips_js",
"iot_class": "local_polling",
"loggers": ["haphilipsjs"],
"requirements": ["ha-philipsjs==3.2.2"]
"requirements": ["ha-philipsjs==3.2.2"],
"zeroconf": ["_philipstv_s_rpc._tcp.local.", "_philipstv_rpc._tcp.local."]
}

View File

@ -1,5 +1,6 @@
{
"config": {
"flow_title": "{name}",
"step": {
"user": {
"data": {
@ -7,6 +8,10 @@
"api_version": "API Version"
}
},
"zeroconf_confirm": {
"title": "Discovered Philips TV",
"description": "Do you want to add the TV ({name}) to Home Assistant? It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen."
},
"pair": {
"title": "Pair",
"description": "Enter the PIN displayed on your TV",

View File

@ -771,6 +771,16 @@ ZEROCONF = {
"domain": "onewire",
},
],
"_philipstv_rpc._tcp.local.": [
{
"domain": "philips_js",
},
],
"_philipstv_s_rpc._tcp.local.": [
{
"domain": "philips_js",
},
],
"_plexmediasvr._tcp.local.": [
{
"domain": "plex",

View File

@ -5,6 +5,7 @@ MOCK_NAME = "Philips TV"
MOCK_USERNAME = "mock_user"
MOCK_PASSWORD = "mock_password"
MOCK_HOSTNAME = "mock_hostname"
MOCK_SYSTEM = {
"menulanguage": "English",

View File

@ -38,6 +38,7 @@ def mock_tv():
tv.application = None
tv.applications = {}
tv.system = MOCK_SYSTEM
tv.name = MOCK_NAME
tv.api_version = 1
tv.api_version_detected = None
tv.on = True

View File

@ -1,5 +1,6 @@
"""Test the Philips TV config flow."""
from ipaddress import ip_address
from unittest.mock import ANY
from haphilipsjs import PairingFailure
@ -9,10 +10,13 @@ from homeassistant import config_entries
from homeassistant.components.philips_js.const import CONF_ALLOW_NOTIFY, DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import (
MOCK_CONFIG,
MOCK_CONFIG_PAIRED,
MOCK_HOSTNAME,
MOCK_NAME,
MOCK_PASSWORD,
MOCK_SYSTEM,
MOCK_SYSTEM_UNPAIRED,
@ -33,6 +37,7 @@ async def mock_tv_pairable(mock_tv):
mock_tv.api_version = 6
mock_tv.api_version_detected = 6
mock_tv.secured_transport = True
mock_tv.name = MOCK_NAME
mock_tv.pairRequest.return_value = {}
mock_tv.pairGrant.return_value = MOCK_USERNAME, MOCK_PASSWORD
@ -102,21 +107,6 @@ async def test_form_cannot_connect(hass: HomeAssistant, mock_tv) -> None:
assert result["errors"] == {"base": "cannot_connect"}
async def test_form_unexpected_error(hass: HomeAssistant, mock_tv) -> None:
"""Test we handle unexpected exceptions."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
mock_tv.getSystem.side_effect = Exception("Unexpected exception")
result = await hass.config_entries.flow.async_configure(
result["flow_id"], MOCK_USERINPUT
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "unknown"}
async def test_pairing(hass: HomeAssistant, mock_tv_pairable, mock_setup_entry) -> None:
"""Test we get the form."""
mock_tv = mock_tv_pairable
@ -143,7 +133,13 @@ async def test_pairing(hass: HomeAssistant, mock_tv_pairable, mock_setup_entry)
)
assert result == {
"context": {"source": "user", "unique_id": "ABCDEFGHIJKLF"},
"context": {
"source": "user",
"unique_id": "ABCDEFGHIJKLF",
"title_placeholders": {
"name": "Philips TV",
},
},
"flow_id": ANY,
"type": "create_entry",
"description": None,
@ -258,3 +254,67 @@ async def test_options_flow(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.CREATE_ENTRY
assert config_entry.options == {CONF_ALLOW_NOTIFY: True}
@pytest.mark.parametrize(
("secured_transport", "discovery_type"),
[(True, "_philipstv_s_rpc._tcp.local."), (False, "_philipstv_rpc._tcp.local.")],
)
async def test_zeroconf_discovery(
hass: HomeAssistant, mock_tv_pairable, secured_transport, discovery_type
) -> None:
"""Test we can setup from zeroconf discovery."""
mock_tv_pairable.secured_transport = secured_transport
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname=MOCK_HOSTNAME,
name=MOCK_NAME,
port=None,
properties={},
type=discovery_type,
),
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] is None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
mock_tv_pairable.setTransport.assert_called_with(secured_transport)
mock_tv_pairable.pairRequest.assert_called()
async def test_zeroconf_probe_failed(
hass: HomeAssistant,
mock_tv_pairable,
) -> None:
"""Test we can setup from zeroconf discovery."""
mock_tv_pairable.system = None
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname=MOCK_HOSTNAME,
name=MOCK_NAME,
port=None,
properties={},
type="_philipstv_s_rpc._tcp.local.",
),
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "discovery_failure"