Skip to content
Open

Dev #87

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
12 changes: 12 additions & 0 deletions .codex/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[shell_environment_policy]
inherit = "core"

[shell_environment_policy.set]
ANTHROPIC_BASE_URL = "http://192.168.31.109:8045"
ANTHROPIC_AUTH_TOKEN = "sk-fa21bd05103d42878e2e2b796d6198eb"
ANTHROPIC_MODEL = "gemini-3-pro-high"
ANTHROPIC_DEFAULT_SONNET_MODEL = "gemini-3-pro-high"
ANTHROPIC_SMALL_FAST_MODEL = "gemini-3-flash"
ANTHROPIC_DEFAULT_HAIKU_MODEL = "gemini-3-flash"
DISABLE_NON_ESSENTIAL_MODEL_CALLS = "1"
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1"
26 changes: 7 additions & 19 deletions custom_components/domru/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from datetime import timedelta
from typing import TYPE_CHECKING, Any

from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.const import Platform
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
Expand All @@ -34,9 +34,6 @@
DomruApiClientError,
)
from .const import (
CONF_ACCESS_TOKEN,
CONF_OPERATOR_ID,
CONF_REFRESH_TOKEN,
CONF_SIP_ENABLED,
CONF_SIP_HOST_IP,
CONF_SIP_LOCAL_IP,
Expand All @@ -52,6 +49,7 @@
from .coordinator import DomruDataUpdateCoordinator
from .data import DomruData
from .door import async_open_door
from .entry_setup import async_create_client_and_load_data
from .fcm import DomruFcmListener
from .media import async_setup_camera_audio
from .sip import DomruSipClient, SipAccount
Expand All @@ -77,23 +75,13 @@ async def async_setup_entry(
entry: DomruConfigEntry,
) -> bool:
"""Set up this integration using UI."""
client = DomruApiClient(
username=entry.data.get(CONF_USERNAME),
password=entry.data.get(CONF_PASSWORD),
session=async_get_clientsession(hass),
access_token=(
entry.data.get(CONF_ACCESS_TOKEN) or entry.data.get(CONF_REFRESH_TOKEN)
),
refresh_token=entry.data.get(CONF_REFRESH_TOKEN),
operator_id=entry.data.get(CONF_OPERATOR_ID),
# Authenticate and load IDs/FCM targets with HA-native retry and reauth errors.
client, initial_data = await async_create_client_and_load_data(
hass,
entry,
async_get_clientsession(hass),
)

# Authenticate first
await client.async_authenticate()

# Load initial data to set IDs and all FCM access-control targets.
initial_data = await client.async_get_data()

# Enable Home Assistant's optional WebRTC provider before camera entities load.
await async_setup_camera_audio(hass)

Expand Down
68 changes: 52 additions & 16 deletions custom_components/domru/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@
import uuid
from datetime import UTC, datetime
from json.decoder import JSONDecodeError
from typing import Any
from typing import TYPE_CHECKING, Any
from urllib.parse import quote, urljoin

import aiohttp
from aiohttp.client_exceptions import ClientConnectorError, ContentTypeError

if TYPE_CHECKING:
from collections.abc import Callable

try:
from async_timeout import timeout as async_timeout
except ModuleNotFoundError:
Expand Down Expand Up @@ -155,6 +158,7 @@ def __init__(
access_token: str | None = None,
refresh_token: str | None = None,
operator_id: str | int | None = None,
on_auth_update: Callable[[str, str, str | int], None] | None = None,
) -> None:
"""Initialize the API client."""
self._username = username
Expand All @@ -163,6 +167,7 @@ def __init__(
self._access_token = access_token
self._refresh_token = refresh_token
self._operator_id = operator_id
self._on_auth_update = on_auth_update
self._place_id: str | int | None = None
self._access_control_id: str | int | None = None
# Hash parameters from go-impl/pkg/auth/password.go
Expand Down Expand Up @@ -219,15 +224,14 @@ async def _set_access_token(self) -> None:
if self._access_token is not None:
return

refresh_auth_error: DomruApiClientAuthenticationError | None = None
if self._refresh_token is not None and self._operator_id is not None:
# Try to refresh token first
try:
await self._refresh_access_token()
except (
DomruApiClientError,
DomruApiClientCommunicationError,
): # pylint: disable=broad-except
_LOGGER.debug("Failed to refresh access token")
except DomruApiClientAuthenticationError as exception:
refresh_auth_error = exception
_LOGGER.debug("Stored session can no longer be refreshed")
else:
return

Expand Down Expand Up @@ -265,6 +269,8 @@ async def _set_access_token(self) -> None:
if not self._access_token:
msg = "No access token in response"
raise DomruApiClientAuthenticationError(msg)
elif refresh_auth_error is not None:
raise refresh_auth_error
else:
msg = "No credentials provided"
raise DomruApiClientAuthenticationError(msg)
Expand All @@ -284,6 +290,9 @@ async def _refresh_access_token(self) -> None:
method="GET",
headers=headers,
authenticated=False,
status_messages={
self.HTTP_UNAUTHORIZED: "Stored session has expired.",
},
)
auth_data = _auth_response_data(result)

Expand All @@ -295,6 +304,22 @@ async def _refresh_access_token(self) -> None:
msg = "No access token in refresh response"
raise DomruApiClientAuthenticationError(msg)

self._notify_auth_update()

def _notify_auth_update(self) -> None:
"""Notify the integration when the API rotates stored credentials."""
if (
self._on_auth_update is not None
and self._access_token
and self._refresh_token
and self._operator_id is not None
):
self._on_auth_update(
self._access_token,
self._refresh_token,
self._operator_id,
)

async def async_get_phone_accounts(self, phone: str) -> list[dict[str, Any]]:
"""Get accounts available for a phone number."""
escaped_phone = quote(phone, safe="")
Expand Down Expand Up @@ -395,15 +420,9 @@ async def async_get_data(self) -> dict[str, Any]:
"events": [],
}

# Get subscriber places
try:
await self._async_add_places_and_access_controls(data)
except (
DomruApiClientError,
DomruApiClientCommunicationError,
TimeoutError,
): # pylint: disable=broad-except
_LOGGER.debug("Failed to get subscriber places")
# Subscriber places are the primary discovery call. Propagate failures so
# Home Assistant can distinguish reauthentication from a temporary outage.
await self._async_add_places_and_access_controls(data)

# Get cameras
try:
Expand Down Expand Up @@ -989,6 +1008,19 @@ def _handle_authentication_error(self, message: str) -> None:
"""Handle a request-specific authentication error."""
raise DomruApiClientAuthenticationError(message)

async def _async_refresh_after_unauthorized(
self,
*,
token_refreshed: bool,
) -> None:
"""Refresh once after a 401 or raise when the replay was also rejected."""
if token_refreshed:
self._handle_authentication_error(
"Unauthorized after refreshing access token"
)
self._access_token = None
await self._set_access_token()

async def _parse_response(
self,
response: aiohttp.ClientResponse,
Expand Down Expand Up @@ -1025,6 +1057,7 @@ async def _api_wrapper(
"""Make an API request with automatic token refresh on 401."""
allowed_statuses = success_statuses or (self.HTTP_OK, self.HTTP_CREATED)
parse_statuses = (*allowed_statuses, *(status_messages or {}))
token_refreshed = False
while True:
try:
headers_to_use = headers or (
Expand All @@ -1047,7 +1080,10 @@ async def _api_wrapper(

# Handle 401 - try to refresh token and retry
if response.status == self.HTTP_UNAUTHORIZED and authenticated:
await self._set_access_token()
await self._async_refresh_after_unauthorized(
token_refreshed=token_refreshed
)
token_refreshed = True
continue # Retry the request with new token

json_response = await self._parse_response(
Expand Down
63 changes: 52 additions & 11 deletions custom_components/domru/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,29 @@ async def async_step_user(
),
)

async def async_step_reauth(
self,
_entry_data: dict,
) -> config_entries.ConfigFlowResult:
"""Start reauthentication for an entry with expired credentials."""
return await self.async_step_reauth_confirm()

async def async_step_reauth_confirm(
self,
user_input: dict | None = None,
) -> config_entries.ConfigFlowResult:
"""Confirm reauthentication before collecting fresh credentials."""
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({}),
)

entry = self._get_reauth_entry()
if entry.data.get(CONF_AUTH_METHOD) == AUTH_METHOD_PHONE:
return await self.async_step_phone()
return await self.async_step_password()

async def async_step_password(
self,
user_input: dict | None = None,
Expand All @@ -114,18 +137,12 @@ async def async_step_password(
LOGGER.exception(exception)
_errors["base"] = "unknown"
else:
await self.async_set_unique_id(
## Do NOT use this in production code
## The unique_id should never be something that can change
## https://developers.home-assistant.io/docs/config_entries_config_flow_handler#unique-ids
unique_id=slugify(user_input[CONF_USERNAME])
)
self._abort_if_unique_id_configured()
data = dict(user_input)
data[CONF_AUTH_METHOD] = AUTH_METHOD_PASSWORD
return self.async_create_entry(
return await self._async_finish_entry(
title=user_input[CONF_USERNAME],
data=data,
unique_id=slugify(user_input[CONF_USERNAME]),
)

return self.async_show_form(
Expand Down Expand Up @@ -291,9 +308,7 @@ async def async_step_sms(
_errors["base"] = ERROR_API
else:
account_id = self._selected_account.get("accountId", self._phone)
await self.async_set_unique_id(slugify(str(account_id)))
self._abort_if_unique_id_configured()
return self.async_create_entry(
return await self._async_finish_entry(
title=_account_label(self._selected_account),
data={
CONF_AUTH_METHOD: AUTH_METHOD_PHONE,
Expand All @@ -303,6 +318,7 @@ async def async_step_sms(
CONF_REFRESH_TOKEN: refresh_token,
CONF_OPERATOR_ID: operator_id,
},
unique_id=slugify(str(account_id)),
)

return self.async_show_form(
Expand Down Expand Up @@ -339,6 +355,31 @@ def _create_client(self) -> DomruApiClient:
session=async_create_clientsession(self.hass),
)

async def _async_finish_entry(
self,
*,
title: str,
data: dict,
unique_id: str,
) -> config_entries.ConfigFlowResult:
"""Create a new entry or replace credentials during reauthentication."""
await self.async_set_unique_id(unique_id)
if self.source == config_entries.SOURCE_REAUTH:
self._abort_if_unique_id_mismatch()
reauth_entry = self._get_reauth_entry()
if update_and_abort := getattr(self, "async_update_and_abort", None):
return update_and_abort(
reauth_entry,
data_updates=data,
)
return self.async_update_reload_and_abort(
reauth_entry,
data_updates=data,
)

self._abort_if_unique_id_configured()
return self.async_create_entry(title=title, data=data)

async def _async_request_phone_confirmation(self) -> tuple[str, str] | None:
"""Request SMS confirmation and return a config flow error key on failure."""
if self._phone is None or self._selected_account is None:
Expand Down
70 changes: 70 additions & 0 deletions custom_components/domru/entry_setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Config-entry setup boundary for Dom.ru authentication and API failures."""

from __future__ import annotations

from functools import partial
from typing import Any

from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady

from .api import (
DomruApiClient,
DomruApiClientAuthenticationError,
DomruApiClientCommunicationError,
DomruApiClientError,
)
from .const import CONF_ACCESS_TOKEN, CONF_OPERATOR_ID, CONF_REFRESH_TOKEN


def persist_auth_update(
hass: Any,
entry: Any,
access_token: str,
refresh_token: str,
operator_id: str | int,
) -> None:
"""Persist rotated credentials without discarding other entry data."""
data = {
**entry.data,
CONF_ACCESS_TOKEN: access_token,
CONF_REFRESH_TOKEN: refresh_token,
CONF_OPERATOR_ID: operator_id,
}
if data != entry.data:
hass.config_entries.async_update_entry(entry, data=data)


async def async_load_initial_data(client: Any) -> dict[str, Any]:
"""Authenticate and load required data with Home Assistant failure semantics."""
try:
await client.async_authenticate()
return await client.async_get_data()
except DomruApiClientAuthenticationError as exception:
raise ConfigEntryAuthFailed(exception) from exception
except (
DomruApiClientCommunicationError,
DomruApiClientError,
) as exception:
raise ConfigEntryNotReady(str(exception)) from exception


async def async_create_client_and_load_data(
hass: Any,
entry: Any,
session: Any,
) -> tuple[DomruApiClient, dict[str, Any]]:
"""Create a persistence-aware API client and load required initial data."""
client = DomruApiClient(
username=entry.data.get(CONF_USERNAME),
password=entry.data.get(CONF_PASSWORD),
session=session,
access_token=(
entry.data.get(CONF_ACCESS_TOKEN) or entry.data.get(CONF_REFRESH_TOKEN)
),
refresh_token=entry.data.get(CONF_REFRESH_TOKEN),
operator_id=entry.data.get(CONF_OPERATOR_ID),
on_auth_update=partial(persist_auth_update, hass, entry),
)
initial_data = await async_load_initial_data(client)
return client, initial_data
Loading