Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
123 changes: 5 additions & 118 deletions homeassistant/components/cloudflare/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,86 +2,19 @@

from __future__ import annotations

import asyncio
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
import socket

import pycfdns

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_TOKEN, CONF_ZONE
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util.location import async_detect_location_info
from homeassistant.util.network import is_ipv4_address

from .const import CONF_RECORDS, DEFAULT_UPDATE_INTERVAL, DOMAIN, SERVICE_UPDATE_RECORDS

_LOGGER = logging.getLogger(__name__)

type CloudflareConfigEntry = ConfigEntry[CloudflareRuntimeData]


@dataclass
class CloudflareRuntimeData:
"""Runtime data for Cloudflare config entry."""

client: pycfdns.Client
dns_zone: pycfdns.ZoneModel
from .const import DOMAIN, SERVICE_UPDATE_RECORDS
from .coordinator import CloudflareConfigEntry, CloudflareCoordinator


async def async_setup_entry(hass: HomeAssistant, entry: CloudflareConfigEntry) -> bool:
"""Set up Cloudflare from a config entry."""
session = async_get_clientsession(hass)
client = pycfdns.Client(
api_token=entry.data[CONF_API_TOKEN],
client_session=session,
)

try:
dns_zones = await client.list_zones()
dns_zone = next(
zone for zone in dns_zones if zone["name"] == entry.data[CONF_ZONE]
)
except pycfdns.AuthenticationException as error:
raise ConfigEntryAuthFailed from error
except pycfdns.ComunicationException as error:
raise ConfigEntryNotReady from error

entry.runtime_data = CloudflareRuntimeData(client, dns_zone)
entry.runtime_data = await CloudflareCoordinator(hass, entry).init()

async def update_records(now: datetime) -> None:
"""Set up recurring update."""
try:
await _async_update_cloudflare(hass, entry)
except (
pycfdns.AuthenticationException,
pycfdns.ComunicationException,
) as error:
_LOGGER.error("Error updating zone %s: %s", entry.data[CONF_ZONE], error)

async def update_records_service(call: ServiceCall) -> None:
async def update_records_service(_: ServiceCall) -> None:
"""Set up service for manual trigger."""
try:
await _async_update_cloudflare(hass, entry)
except (
pycfdns.AuthenticationException,
pycfdns.ComunicationException,
) as error:
_LOGGER.error("Error updating zone %s: %s", entry.data[CONF_ZONE], error)

update_interval = timedelta(minutes=DEFAULT_UPDATE_INTERVAL)
entry.async_on_unload(
async_track_time_interval(hass, update_records, update_interval)
)
await entry.runtime_data.async_request_refresh()

hass.services.async_register(DOMAIN, SERVICE_UPDATE_RECORDS, update_records_service)

Expand All @@ -92,49 +25,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: CloudflareConfigEntry)
"""Unload Cloudflare config entry."""

return True


async def _async_update_cloudflare(
hass: HomeAssistant,
entry: CloudflareConfigEntry,
) -> None:
client = entry.runtime_data.client
dns_zone = entry.runtime_data.dns_zone
target_records: list[str] = entry.data[CONF_RECORDS]

_LOGGER.debug("Starting update for zone %s", dns_zone["name"])

records = await client.list_dns_records(zone_id=dns_zone["id"], type="A")
_LOGGER.debug("Records: %s", records)

session = async_get_clientsession(hass, family=socket.AF_INET)
location_info = await async_detect_location_info(session)

if not location_info or not is_ipv4_address(location_info.ip):
raise HomeAssistantError("Could not get external IPv4 address")

filtered_records = [
record
for record in records
if record["name"] in target_records and record["content"] != location_info.ip
]

if len(filtered_records) == 0:
_LOGGER.debug("All target records are up to date")
return

await asyncio.gather(
*[
client.update_dns_record(
zone_id=dns_zone["id"],
record_id=record["id"],
record_content=location_info.ip,
record_name=record["name"],
record_type=record["type"],
record_proxied=record["proxied"],
)
for record in filtered_records
]
)

_LOGGER.debug("Update for zone %s is complete", dns_zone["name"])
121 changes: 121 additions & 0 deletions homeassistant/components/cloudflare/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Contains the Coordinator for updating the IP addresses of your Cloudflare DNS records."""

from __future__ import annotations

import asyncio
from datetime import timedelta
from logging import getLogger
import socket

import pycfdns

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_TOKEN, CONF_ZONE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.location import async_detect_location_info
from homeassistant.util.network import is_ipv4_address

from .const import CONF_RECORDS, DEFAULT_UPDATE_INTERVAL

_LOGGER = getLogger(__name__)

type CloudflareConfigEntry = ConfigEntry[CloudflareCoordinator]


class CloudflareCoordinator(DataUpdateCoordinator[None]):
"""Coordinates records updates."""

config_entry: CloudflareConfigEntry
client: pycfdns.Client
zone: pycfdns.ZoneModel

def __init__(
self, hass: HomeAssistant, config_entry: CloudflareConfigEntry
) -> None:
"""Initialize an coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=config_entry.title,
update_interval=timedelta(minutes=DEFAULT_UPDATE_INTERVAL),
)

async def _async_setup(self) -> None:
"""Set up the coordinator."""
self.client = pycfdns.Client(
api_token=self.config_entry.data[CONF_API_TOKEN],
client_session=async_get_clientsession(self.hass),
)

try:
self.zone = next(
zone
for zone in await self.client.list_zones()
if zone["name"] == self.config_entry.data[CONF_ZONE]
)
except pycfdns.AuthenticationException as e:
raise ConfigEntryAuthFailed from e
except pycfdns.ComunicationException as e:
raise ConfigEntryNotReady from e

async def _async_update_data(self) -> None:
"""Update records."""
_LOGGER.debug("Starting update for zone %s", self.zone["name"])
try:
records = await self.client.list_dns_records(
zone_id=self.zone["id"], type="A"
)
_LOGGER.debug("Records: %s", records)

target_records: list[str] = self.config_entry.data[CONF_RECORDS]

location_info = await async_detect_location_info(
async_get_clientsession(self.hass, family=socket.AF_INET)
)

if not location_info or not is_ipv4_address(location_info.ip):
raise UpdateFailed("Could not get external IPv4 address")

filtered_records = [
record
for record in records
if record["name"] in target_records
and record["content"] != location_info.ip
]

if len(filtered_records) == 0:
_LOGGER.debug("All target records are up to date")
return

await asyncio.gather(
*[
self.client.update_dns_record(
zone_id=self.zone["id"],
record_id=record["id"],
record_content=location_info.ip,
record_name=record["name"],
record_type=record["type"],
record_proxied=record["proxied"],
)
for record in filtered_records
]
)

_LOGGER.debug("Update for zone %s is complete", self.zone["name"])

except (
pycfdns.AuthenticationException,
pycfdns.ComunicationException,
) as e:
raise UpdateFailed(
f"Error updating zone {self.config_entry.data[CONF_ZONE]}"
) from e

async def init(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think we need this method. It doesn't add much value and makes the code logic harder to follow. One of the common things that's checked is whether the async_setup_entry method is executing the async_config_entry_first_refresh method , and now that's not done explicitly, but through this additional method. This should be moved to async_setup_entry.

https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/test-before-setup

Copy link
Member

Choose a reason for hiding this comment

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

there's also _async_setup in the coordinator for one time setup tasks automatically called during async_config_entry_first_refresh

Copy link
Contributor Author

@davidrapan davidrapan Nov 18, 2025

Choose a reason for hiding this comment

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

Well, I do use _async_setup 😉 I'm just wrapping async_config_entry_first_refresh together w/ return self so I can "one-line it" in async_setup_entry.

It's coming from my template which I use when creating custom components, etc. (and changes in this PR are semi-based on it too) But sure, I can remove it. :)

"""Asynchronously initialize an coordinator."""
await super().async_config_entry_first_refresh()
return self
2 changes: 1 addition & 1 deletion tests/components/cloudflare/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def cfupdate() -> Generator[MagicMock]:
"""Mock the CloudflareUpdater for easier testing."""
mock_cfupdate = get_mock_client()
with patch(
"homeassistant.components.cloudflare.pycfdns.Client",
"homeassistant.components.cloudflare.coordinator.pycfdns.Client",
return_value=mock_cfupdate,
) as mock_api:
yield mock_api
Expand Down
Loading
Loading