644 lines
27 KiB
Python
644 lines
27 KiB
Python
"""Config + options flow for the HA-MCP custom component.
|
|
|
|
One config flow serves two entry types under the shared domain, chosen from a
|
|
menu on the first step:
|
|
|
|
* ``tools`` — the privileged file / YAML services (the original component).
|
|
A single confirm step creates the entry. Single-instance, keyed on
|
|
``DOMAIN``.
|
|
* ``server`` — the in-process ha-mcp FastMCP server (issue #1527). A single
|
|
confirm step creates the entry (entry-exists = the server runs);
|
|
single-instance, keyed on ``DOMAIN-server``. Its options flow tunes the
|
|
channel / port / bind host / webhook auth / pip spec / server URL.
|
|
|
|
The two entry types are discriminated by ``entry.data[CONF_ENTRY_TYPE]``; the
|
|
options-flow dispatcher branches on it — the server entry gets the configurable
|
|
options flow, and the tools entry gets a light informational options flow
|
|
(nothing to configure yet).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
import voluptuous as vol
|
|
from homeassistant.config_entries import (
|
|
ConfigEntry,
|
|
ConfigFlow,
|
|
ConfigFlowResult,
|
|
OptionsFlow,
|
|
)
|
|
from homeassistant.const import __version__ as HA_VERSION
|
|
from homeassistant.core import HomeAssistant, callback
|
|
from homeassistant.helpers.selector import (
|
|
SelectOptionDict,
|
|
SelectSelector,
|
|
SelectSelectorConfig,
|
|
SelectSelectorMode,
|
|
)
|
|
from homeassistant.loader import async_get_integration
|
|
from packaging.version import InvalidVersion, Version
|
|
|
|
from .const import (
|
|
BIND_HOST_ALL,
|
|
BIND_HOST_LOOPBACK,
|
|
CHANNEL_DEV,
|
|
CHANNEL_STABLE,
|
|
CONF_ENTRY_TYPE,
|
|
DATA_OAUTH_CLIENT_ID,
|
|
DATA_OAUTH_CLIENT_SECRET,
|
|
DATA_OAUTH_SIGNING_KEY,
|
|
DATA_SECRET_PATH,
|
|
DATA_WEBHOOK_ID,
|
|
DEFAULT_AUTO_UPDATE,
|
|
DEFAULT_BIND_HOST,
|
|
DEFAULT_CHANNEL,
|
|
DEFAULT_ENABLE_LLM_API,
|
|
DEFAULT_LLM_API_EXPOSURE,
|
|
DEFAULT_LOOPBACK_URL,
|
|
DEFAULT_PIP_SPEC,
|
|
DEFAULT_SERVER_PORT,
|
|
DIST_NAME_DEV,
|
|
DIST_NAME_STABLE,
|
|
DOMAIN,
|
|
ENTRY_TYPE_SERVER,
|
|
ENTRY_TYPE_TOOLS,
|
|
EXPOSURE_BOTH,
|
|
EXPOSURE_FULL,
|
|
EXPOSURE_TOOL_SEARCH,
|
|
LLM_API_DOCS_URL,
|
|
MIN_EMBEDDED_HOME_ASSISTANT_VERSION,
|
|
OPT_AUTO_UPDATE,
|
|
OPT_BIND_HOST,
|
|
OPT_CHANNEL,
|
|
OPT_ENABLE_LLM_API,
|
|
OPT_ENABLE_SIDEBAR_PANEL,
|
|
OPT_ENABLE_STARTUP_NOTIFICATION,
|
|
OPT_ENABLE_WEBHOOK,
|
|
OPT_EXTERNAL_URL,
|
|
OPT_LLM_API_EXPOSURE,
|
|
OPT_OAUTH_CLIENT_ID,
|
|
OPT_OAUTH_CLIENT_SECRET,
|
|
OPT_OAUTH_REGENERATE,
|
|
OPT_PIP_SPEC,
|
|
OPT_REGENERATE_SECRETS,
|
|
OPT_SECRET_PATH_OVERRIDE,
|
|
OPT_SERVER_PORT,
|
|
OPT_SERVER_URL,
|
|
OPT_WEBHOOK_AUTH,
|
|
OPT_WEBHOOK_ID_OVERRIDE,
|
|
TOOLS_ENTRY_TITLE,
|
|
WEBHOOK_AUTH_HA,
|
|
WEBHOOK_AUTH_LEGACY,
|
|
WEBHOOK_AUTH_NONE,
|
|
)
|
|
|
|
# Title shown for the server entry in the integration tile's entry list; the
|
|
# tools entry's title lives in const.py (setup migration in __init__ needs it).
|
|
_SERVER_ENTRY_TITLE = "HA-MCP Server"
|
|
|
|
# The single-instance server entry's unique id — distinct from the tools entry's
|
|
# unique id (``DOMAIN``) so both entry types coexist under the one domain.
|
|
_SERVER_UNIQUE_ID = f"{DOMAIN}-server"
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
def _legacy_credentials_active(
|
|
hass: HomeAssistant, client_id: str, client_secret: str, signing_key: str
|
|
) -> bool:
|
|
"""Deferred-import seam for :func:`oauth_legacy.legacy_credentials_active`.
|
|
|
|
oauth_legacy pulls in the aiohttp/HTTP view layer at import time; the
|
|
config-flow module must stay importable without it (Home Assistant imports
|
|
config flows early, and the flow unit tests stub only the config-entries
|
|
surface) — same pattern as ``oauth_legacy._live_auth_mode``.
|
|
"""
|
|
from .oauth_legacy import legacy_credentials_active
|
|
|
|
return legacy_credentials_active(hass, client_id, client_secret, signing_key)
|
|
|
|
|
|
def _legacy_restart_pending(hass: HomeAssistant) -> bool:
|
|
"""Deferred-import seam for :func:`oauth_legacy.legacy_restart_pending`
|
|
(see :func:`_legacy_credentials_active` for why the import is deferred)."""
|
|
from .oauth_legacy import legacy_restart_pending
|
|
|
|
return legacy_restart_pending(hass)
|
|
|
|
|
|
def _installed_server_version() -> str | None:
|
|
"""Return the installed ha-mcp server version, or None if not installed.
|
|
|
|
Checks both channel distributions (only one is ever installed at a time).
|
|
Kept dependency-free (``importlib.metadata``) and swallow-nothing-surprising
|
|
so a read can never break the options form.
|
|
"""
|
|
import importlib.metadata
|
|
|
|
for dist in (DIST_NAME_STABLE, DIST_NAME_DEV):
|
|
try:
|
|
return importlib.metadata.version(dist)
|
|
except importlib.metadata.PackageNotFoundError:
|
|
continue
|
|
return None
|
|
|
|
|
|
class HaMcpToolsConfigFlow(ConfigFlow, domain=DOMAIN): # type: ignore[call-arg]
|
|
"""Handle the config flow for the HA-MCP custom component (both entry types)."""
|
|
|
|
VERSION = 1
|
|
|
|
@staticmethod
|
|
@callback
|
|
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
|
|
"""Return the options flow for this entry type.
|
|
|
|
The in-process server entry gets the configurable options flow (channel /
|
|
port / bind / auth / pip spec / URL). The tools services entry has
|
|
nothing to configure yet, so it gets a light informational options flow
|
|
instead of aborting.
|
|
"""
|
|
if config_entry.data.get(CONF_ENTRY_TYPE) == ENTRY_TYPE_SERVER:
|
|
return HaMcpServerOptionsFlow()
|
|
return HaMcpToolsInfoOptionsFlow()
|
|
|
|
async def async_step_user(
|
|
self, user_input: dict[str, Any] | None = None
|
|
) -> ConfigFlowResult:
|
|
"""Choose which entry type to add: the services tools or the server."""
|
|
return self.async_show_menu(
|
|
step_id="user",
|
|
menu_options=[ENTRY_TYPE_SERVER, ENTRY_TYPE_TOOLS],
|
|
)
|
|
|
|
# -- tools entry: privileged file / YAML services -----------------------
|
|
|
|
async def async_step_tools(
|
|
self, user_input: dict[str, Any] | None = None
|
|
) -> ConfigFlowResult:
|
|
"""Set up the services (tools) entry — single-instance, keyed on DOMAIN.
|
|
|
|
Plain confirm-and-create on every install type. (The add-on bootstrap
|
|
this step used to offer on Supervisor installs was removed: the
|
|
in-process server entry is the one-click way to get a server, and a
|
|
second install path only caused confusion. The add-on remains fully
|
|
supported - installed from the add-on store as always.)
|
|
"""
|
|
await self.async_set_unique_id(DOMAIN)
|
|
self._abort_if_unique_id_configured()
|
|
|
|
if user_input is not None:
|
|
return self._create_tools_entry()
|
|
return self.async_show_form(step_id="tools")
|
|
|
|
def _create_tools_entry(self) -> ConfigFlowResult:
|
|
"""Create the services (tools) config entry."""
|
|
return self.async_create_entry(
|
|
title=TOOLS_ENTRY_TITLE,
|
|
data={CONF_ENTRY_TYPE: ENTRY_TYPE_TOOLS},
|
|
)
|
|
|
|
# -- server entry: in-process MCP server (issue #1527) ------------------
|
|
|
|
async def async_step_server(
|
|
self, user_input: dict[str, Any] | None = None
|
|
) -> ConfigFlowResult:
|
|
"""Confirm and create the single in-process server entry.
|
|
|
|
Creating the entry starts the in-process server with the defaults (port
|
|
9584, LAN-reachable like the add-on, secret-URL auth); everything is
|
|
tunable afterward in the integration options.
|
|
"""
|
|
try:
|
|
supported = Version(HA_VERSION) >= Version(
|
|
MIN_EMBEDDED_HOME_ASSISTANT_VERSION
|
|
)
|
|
except InvalidVersion:
|
|
supported = False
|
|
if not supported:
|
|
return self.async_abort(
|
|
reason="unsupported_home_assistant",
|
|
description_placeholders={
|
|
"installed": HA_VERSION,
|
|
"required": MIN_EMBEDDED_HOME_ASSISTANT_VERSION,
|
|
},
|
|
)
|
|
|
|
await self.async_set_unique_id(_SERVER_UNIQUE_ID)
|
|
self._abort_if_unique_id_configured()
|
|
|
|
if user_input is not None:
|
|
return self.async_create_entry(
|
|
title=_SERVER_ENTRY_TITLE,
|
|
data={CONF_ENTRY_TYPE: ENTRY_TYPE_SERVER},
|
|
options={},
|
|
)
|
|
return self.async_show_form(step_id="server")
|
|
|
|
|
|
class HaMcpToolsInfoOptionsFlow(OptionsFlow):
|
|
"""Options flow for the tools entry: a light informational form.
|
|
|
|
The tools services entry has nothing to configure yet, but aborting the
|
|
Configure dialog reads as an error. Show an empty-schema form that explains
|
|
what the entry provides instead; submitting persists an empty options
|
|
payload.
|
|
|
|
The form uses the ``tools_info`` step id, NOT ``init``: the server options
|
|
flow already owns ``options.step.init`` in strings.json, so a shared step id
|
|
would collide. ``async_step_init`` is the required entry point (it renders
|
|
the form); HA routes the form's submit to ``async_step_tools_info``.
|
|
"""
|
|
|
|
async def async_step_init(
|
|
self, user_input: dict[str, Any] | None = None
|
|
) -> ConfigFlowResult:
|
|
"""Render the informational form under the ``tools_info`` step id."""
|
|
return self.async_show_form(step_id="tools_info", data_schema=vol.Schema({}))
|
|
|
|
async def async_step_tools_info(
|
|
self, user_input: dict[str, Any] | None = None
|
|
) -> ConfigFlowResult:
|
|
"""Persist an empty options payload once the info form is submitted."""
|
|
return self.async_create_entry(title="", data={})
|
|
|
|
|
|
class HaMcpServerOptionsFlow(OptionsFlow):
|
|
"""Options flow: configure the in-process MCP server (issue #1527)."""
|
|
|
|
async def async_step_init(
|
|
self, user_input: dict[str, Any] | None = None
|
|
) -> ConfigFlowResult:
|
|
"""Show / apply the server options."""
|
|
if user_input is not None:
|
|
return self.async_create_entry(title="", data=self._normalize(user_input))
|
|
|
|
opts = self.config_entry.options
|
|
schema = vol.Schema(
|
|
{
|
|
# Authentication mode first, directly under the connect URLs
|
|
# shown in the step description (#1875): it is the setting users
|
|
# most need to find, and legacy mode is what unblocks OAuth-only
|
|
# clients such as Google Gemini Spark and Copilot CLI.
|
|
vol.Required(
|
|
OPT_WEBHOOK_AUTH,
|
|
default=opts.get(OPT_WEBHOOK_AUTH, WEBHOOK_AUTH_NONE),
|
|
): SelectSelector(
|
|
SelectSelectorConfig(
|
|
options=[
|
|
WEBHOOK_AUTH_NONE,
|
|
WEBHOOK_AUTH_HA,
|
|
WEBHOOK_AUTH_LEGACY,
|
|
],
|
|
translation_key="server_webhook_auth",
|
|
mode=SelectSelectorMode.DROPDOWN,
|
|
)
|
|
),
|
|
vol.Required(
|
|
OPT_CHANNEL,
|
|
default=opts.get(OPT_CHANNEL, DEFAULT_CHANNEL),
|
|
): SelectSelector(
|
|
SelectSelectorConfig(
|
|
options=[CHANNEL_STABLE, CHANNEL_DEV],
|
|
translation_key="server_channel",
|
|
mode=SelectSelectorMode.DROPDOWN,
|
|
)
|
|
),
|
|
vol.Required(
|
|
OPT_AUTO_UPDATE,
|
|
default=bool(opts.get(OPT_AUTO_UPDATE, DEFAULT_AUTO_UPDATE)),
|
|
): bool,
|
|
vol.Required(
|
|
OPT_SERVER_PORT,
|
|
default=opts.get(OPT_SERVER_PORT, DEFAULT_SERVER_PORT),
|
|
): vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)),
|
|
vol.Required(
|
|
OPT_BIND_HOST,
|
|
default=opts.get(OPT_BIND_HOST, DEFAULT_BIND_HOST),
|
|
): SelectSelector(
|
|
# Inline labels: hassfest forbids dots in translation
|
|
# keys, so the IP-valued options cannot use strings.json
|
|
# selector translations.
|
|
SelectSelectorConfig(
|
|
options=[
|
|
SelectOptionDict(
|
|
value=BIND_HOST_ALL,
|
|
label="Local network (default)",
|
|
),
|
|
SelectOptionDict(
|
|
value=BIND_HOST_LOOPBACK,
|
|
label="This machine only (loopback)",
|
|
),
|
|
],
|
|
mode=SelectSelectorMode.DROPDOWN,
|
|
)
|
|
),
|
|
vol.Optional(
|
|
OPT_PIP_SPEC,
|
|
# Pre-fill via suggested_value, NOT a schema default: a
|
|
# default equal to the saved value makes the field
|
|
# impossible to clear. HA's frontend drops an emptied
|
|
# optional field from the submitted payload, so voluptuous
|
|
# re-applies the default (the old override) and clearing
|
|
# never sticks. suggested_value pre-fills the same value but
|
|
# is not re-injected on an empty submit. (Applies to every
|
|
# optional text field below.) Only a genuinely saved
|
|
# override is suggested; the normalized "no override" state
|
|
# renders an EMPTY field — the help text says "Leave empty",
|
|
# and pre-filling DEFAULT_PIP_SPEC would show the STABLE dist
|
|
# name even on the dev channel.
|
|
description={"suggested_value": opts.get(OPT_PIP_SPEC, "")},
|
|
): str,
|
|
vol.Optional(
|
|
OPT_SERVER_URL,
|
|
# Only a genuinely saved override is suggested (same
|
|
# pattern as OPT_PIP_SPEC above). Pre-filling
|
|
# DEFAULT_LOOPBACK_URL made every options save store the
|
|
# constant as an explicit override, which would pin the
|
|
# scheme/port even after issue #1890's SSL/port-aware
|
|
# loopback derivation.
|
|
description={"suggested_value": opts.get(OPT_SERVER_URL, "")},
|
|
): str,
|
|
vol.Required(
|
|
OPT_ENABLE_WEBHOOK,
|
|
default=bool(opts.get(OPT_ENABLE_WEBHOOK, True)),
|
|
): bool,
|
|
vol.Required(
|
|
OPT_ENABLE_STARTUP_NOTIFICATION,
|
|
default=bool(opts.get(OPT_ENABLE_STARTUP_NOTIFICATION, True)),
|
|
): bool,
|
|
vol.Required(
|
|
OPT_ENABLE_SIDEBAR_PANEL,
|
|
default=bool(opts.get(OPT_ENABLE_SIDEBAR_PANEL, True)),
|
|
): bool,
|
|
vol.Required(
|
|
OPT_ENABLE_LLM_API,
|
|
default=bool(opts.get(OPT_ENABLE_LLM_API, DEFAULT_ENABLE_LLM_API)),
|
|
): bool,
|
|
vol.Required(
|
|
OPT_LLM_API_EXPOSURE,
|
|
default=str(
|
|
opts.get(OPT_LLM_API_EXPOSURE, DEFAULT_LLM_API_EXPOSURE)
|
|
),
|
|
): SelectSelector(
|
|
SelectSelectorConfig(
|
|
options=[EXPOSURE_TOOL_SEARCH, EXPOSURE_FULL, EXPOSURE_BOTH],
|
|
translation_key="llm_api_exposure",
|
|
mode=SelectSelectorMode.DROPDOWN,
|
|
)
|
|
),
|
|
# suggested_value (not default) so these clear properly on an
|
|
# empty submit — see the OPT_PIP_SPEC note above.
|
|
vol.Optional(
|
|
OPT_EXTERNAL_URL,
|
|
description={"suggested_value": opts.get(OPT_EXTERNAL_URL, "")},
|
|
): str,
|
|
vol.Optional(
|
|
OPT_WEBHOOK_ID_OVERRIDE,
|
|
description={
|
|
"suggested_value": opts.get(OPT_WEBHOOK_ID_OVERRIDE, "")
|
|
},
|
|
): str,
|
|
vol.Optional(
|
|
OPT_SECRET_PATH_OVERRIDE,
|
|
description={
|
|
"suggested_value": opts.get(OPT_SECRET_PATH_OVERRIDE, "")
|
|
},
|
|
): str,
|
|
vol.Optional(
|
|
OPT_REGENERATE_SECRETS,
|
|
default=False,
|
|
): bool,
|
|
# Legacy OAuth mode (Google Gemini Spark) credential overrides —
|
|
# same shape as the webhook id/secret-path overrides above.
|
|
# Empty = auto-generate/keep the current value.
|
|
vol.Optional(
|
|
OPT_OAUTH_CLIENT_ID,
|
|
description={"suggested_value": opts.get(OPT_OAUTH_CLIENT_ID, "")},
|
|
): str,
|
|
vol.Optional(
|
|
OPT_OAUTH_CLIENT_SECRET,
|
|
description={
|
|
"suggested_value": opts.get(OPT_OAUTH_CLIENT_SECRET, "")
|
|
},
|
|
): str,
|
|
vol.Optional(
|
|
OPT_OAUTH_REGENERATE,
|
|
default=False,
|
|
): bool,
|
|
}
|
|
)
|
|
# The sidebar-panel sentence in the description is only truthful while
|
|
# the panel is registered; drop it (from the CURRENT stored options, not
|
|
# the unsaved form state) when the panel is off so the link cannot point
|
|
# at a route that 404s. The trailing space keeps the surrounding prose
|
|
# spaced correctly whether the sentence is present or empty.
|
|
panel_hint = (
|
|
"Open the [HA-MCP settings panel](/ha-mcp) for tool management and "
|
|
"server settings. "
|
|
if bool(opts.get(OPT_ENABLE_SIDEBAR_PANEL, True))
|
|
else ""
|
|
)
|
|
return self.async_show_form(
|
|
step_id="init",
|
|
data_schema=schema,
|
|
description_placeholders={
|
|
"versions": await self._versions_hint(),
|
|
"connect_url": await self._connect_url_hint(),
|
|
"oauth_creds": self._oauth_creds_hint(),
|
|
"llm_api_docs_url": LLM_API_DOCS_URL,
|
|
"panel_hint": panel_hint,
|
|
},
|
|
)
|
|
|
|
@staticmethod
|
|
def _normalize(user_input: dict[str, Any]) -> dict[str, Any]:
|
|
"""Normalize the submitted options before they are persisted.
|
|
|
|
Collapses the pip-spec field to empty when it is empty or equals
|
|
``DEFAULT_PIP_SPEC`` (the unpinned ``ha-mcp`` distribution): the field is
|
|
pre-filled with the saved override or blank, but a user may also type the
|
|
default dist name, and persisting it verbatim would read as an
|
|
intentional override and disable the stable channel's automatic updates.
|
|
Empty means "no override" (track the selected channel); any other string
|
|
is a genuine override, stored as-is. Also strips the URL / secret
|
|
override fields, and drops a blank ``server_url`` so its default applies.
|
|
"""
|
|
cleaned = dict(user_input)
|
|
if cleaned.get(OPT_PIP_SPEC, "").strip() in ("", DEFAULT_PIP_SPEC):
|
|
cleaned[OPT_PIP_SPEC] = ""
|
|
for key in (
|
|
OPT_EXTERNAL_URL,
|
|
OPT_WEBHOOK_ID_OVERRIDE,
|
|
OPT_SECRET_PATH_OVERRIDE,
|
|
OPT_OAUTH_CLIENT_ID,
|
|
OPT_OAUTH_CLIENT_SECRET,
|
|
):
|
|
cleaned[key] = str(cleaned.get(key, "") or "").strip()
|
|
cleaned[OPT_EXTERNAL_URL] = cleaned[OPT_EXTERNAL_URL].rstrip("/")
|
|
# server_url gets no _normalize-forced empty like the fields above; strip
|
|
# it and drop it entirely when blank so a whitespace-only value can't be
|
|
# stored verbatim (it would bypass the consumer's empty -> loopback
|
|
# fallback and break the HA connection). A value equal to
|
|
# DEFAULT_LOOPBACK_URL is likewise dropped: older forms pre-filled it,
|
|
# so it reaches here from users who never chose an override, and
|
|
# storing it would pin the scheme/port the #1890 loopback derivation
|
|
# exists to resolve.
|
|
server_url = str(cleaned.get(OPT_SERVER_URL, "") or "").strip().rstrip("/")
|
|
if server_url and server_url != DEFAULT_LOOPBACK_URL:
|
|
cleaned[OPT_SERVER_URL] = server_url
|
|
else:
|
|
cleaned.pop(OPT_SERVER_URL, None)
|
|
return cleaned
|
|
|
|
async def _versions_hint(self) -> str:
|
|
"""Return a one-line component + server version summary for the form.
|
|
|
|
Reads the component version from the integration manifest and the
|
|
installed server version from the channel's distribution metadata.
|
|
Failure-proof like the connect-URL hint: any read error degrades to a
|
|
best-effort string ("unknown" / "not installed yet") rather than
|
|
breaking the options form.
|
|
"""
|
|
opts = self.config_entry.options
|
|
channel = str(opts.get(OPT_CHANNEL) or DEFAULT_CHANNEL)
|
|
|
|
component_version = "unknown"
|
|
hass = getattr(self, "hass", None)
|
|
if hass is not None:
|
|
try:
|
|
integration = await async_get_integration(hass, DOMAIN)
|
|
component_version = str(integration.version)
|
|
except Exception as err:
|
|
_LOGGER.debug(
|
|
"Could not read component version for the options hint: %s", err
|
|
)
|
|
|
|
try:
|
|
# importlib.metadata scans dist-info via os.listdir (blocking I/O),
|
|
# so run it on the executor rather than the event loop.
|
|
raw_version = (
|
|
await hass.async_add_executor_job(_installed_server_version)
|
|
if hass is not None
|
|
else _installed_server_version()
|
|
)
|
|
server_version = raw_version or "not installed yet"
|
|
except Exception as err:
|
|
_LOGGER.debug("Could not read server version for the options hint: %s", err)
|
|
server_version = "not installed yet"
|
|
|
|
return (
|
|
f"Component {component_version} - "
|
|
f"Server ha-mcp {server_version} ({channel} channel)"
|
|
)
|
|
|
|
async def _connect_url_hint(self) -> str:
|
|
"""Return the connect URLs for the options form.
|
|
|
|
The Configure screen is admin-only, so it shows the real resolved
|
|
URLs (the start-up notification deliberately does not - it is visible
|
|
to every signed-in user). Falls back to a placeholder form when
|
|
resolution is unavailable.
|
|
"""
|
|
webhook_id = self.config_entry.data.get(DATA_WEBHOOK_ID)
|
|
secret_path = self.config_entry.data.get(DATA_SECRET_PATH)
|
|
if not webhook_id:
|
|
return (
|
|
"The connect URLs appear here (and in the Home Assistant log) "
|
|
"once the server has started."
|
|
)
|
|
webhook_enabled = bool(self.config_entry.options.get(OPT_ENABLE_WEBHOOK, True))
|
|
port = self.config_entry.options.get(OPT_SERVER_PORT, DEFAULT_SERVER_PORT)
|
|
hass = getattr(self, "hass", None)
|
|
if hass is not None:
|
|
try:
|
|
from .embedded_setup import async_get_lan_hosts, build_connect_urls
|
|
|
|
urls = build_connect_urls(
|
|
hass,
|
|
self.config_entry,
|
|
webhook_enabled=webhook_enabled,
|
|
extra_hosts=await async_get_lan_hosts(hass),
|
|
)
|
|
if urls:
|
|
return "Connect URL(s):\n" + "\n".join(f"- {u}" for u in urls)
|
|
except Exception as err:
|
|
# The hint is auxiliary display data: a resolution bug must not
|
|
# take down the whole options form, but the degradation should
|
|
# be visible by default - hence warning, not debug.
|
|
_LOGGER.warning(
|
|
"Falling back to the placeholder connect-URL hint: %s", err
|
|
)
|
|
if not webhook_enabled:
|
|
# Local-only mode: the webhook endpoint is never registered, so
|
|
# a webhook URL here would 404. With loopback binding the builder
|
|
# resolves no URLs at all - state that instead of inventing one.
|
|
hint = "Remote access via webhook is disabled (local-only mode)."
|
|
if secret_path:
|
|
hint += (
|
|
f"\nDirect access from the Home Assistant machine: "
|
|
f"http://127.0.0.1:{port}{secret_path}"
|
|
)
|
|
return hint
|
|
external = str(self.config_entry.options.get(OPT_EXTERNAL_URL) or "").rstrip(
|
|
"/"
|
|
)
|
|
base = external or "<your-home-assistant-url>"
|
|
hint = f"Remote connect URL: {base}/api/webhook/{webhook_id}"
|
|
if secret_path:
|
|
hint += (
|
|
f"\nLocal/LAN (when bind host is 0.0.0.0): "
|
|
f"http://<home-assistant-ip>:{port}{secret_path}"
|
|
)
|
|
return hint
|
|
|
|
def _oauth_creds_hint(self) -> str:
|
|
"""Return the resolved legacy OAuth Client ID + Secret for the options
|
|
form, or a note pointing at the mode selector when legacy mode isn't
|
|
the CONFIGURED mode. Admin-only screen (like ``_connect_url_hint``),
|
|
so showing the secret in cleartext here is acceptable — and this is
|
|
the surface the startup log points at while a rotation is pending
|
|
(``_surface_connect_urls`` withholds pending credentials from the
|
|
log, where a still-valid old-identity token could read them; an HA
|
|
admin here is trusted). A pending rotation gets a caveat so the admin
|
|
doesn't paste values that only start working after the restart.
|
|
"""
|
|
configured_mode = str(self.config_entry.options.get(OPT_WEBHOOK_AUTH) or "")
|
|
if configured_mode != WEBHOOK_AUTH_LEGACY:
|
|
return (
|
|
"Set Authentication mode to legacy OAuth above and save to "
|
|
"generate a Client ID and Client Secret."
|
|
)
|
|
client_id = self.config_entry.data.get(DATA_OAUTH_CLIENT_ID)
|
|
client_secret = self.config_entry.data.get(DATA_OAUTH_CLIENT_SECRET)
|
|
if not client_id or not client_secret:
|
|
# Not minted yet — the entry hasn't finished a bring-up cycle
|
|
# since legacy mode was selected (e.g. this save just turned it
|
|
# on). They appear after the next reload.
|
|
return (
|
|
"The Client ID and Client Secret appear here once the server "
|
|
"has started."
|
|
)
|
|
creds = f"Client ID: {client_id}\nClient Secret: {client_secret}"
|
|
signing_key = str(self.config_entry.data.get(DATA_OAUTH_SIGNING_KEY) or "")
|
|
active = _legacy_credentials_active(
|
|
self.hass, str(client_id), str(client_secret), signing_key
|
|
)
|
|
if active and not _legacy_restart_pending(self.hass):
|
|
return creds # bound and live
|
|
# Not serving these credentials yet: a mid-session first enable
|
|
# (bound but not live until the restart), a pending rotation (the old
|
|
# identity still bound), the webhook disabled, or another integration
|
|
# owning the routes. State-agnostic wording — the previous "the
|
|
# previous Client ID and Client Secret remain active" was false at a
|
|
# first enable and when nothing of ours is bound. Matches the startup
|
|
# log's first-enable caveat and the oauth_regenerate help text.
|
|
return (
|
|
f"{creds}\n"
|
|
"Legacy OAuth is not serving these yet — restart Home Assistant "
|
|
"when it asks you to, to activate them."
|
|
)
|