Initial commit.

This commit is contained in:
2025-04-21 21:03:30 -04:00
commit d4f58c985f
2077 changed files with 75132 additions and 0 deletions
@@ -0,0 +1,27 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import DOMAIN
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the Cloudflare Tunnel component."""
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Cloudflare Tunnel from a config entry."""
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = entry.data
await hass.config_entries.async_forward_entry_setups(entry, ["sensor"])
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_forward_entry_unload(entry, "sensor")
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
else:
_LOGGER.error("Failed to unload entry: %s", entry.entry_id)
return unload_ok
@@ -0,0 +1,76 @@
import aiohttp
import async_timeout
import voluptuous as vol
from homeassistant import config_entries, exceptions
from .const import DOMAIN, CONF_API_KEY, CONF_ACCOUNT_ID, LABEL_API_KEY, LABEL_ACCOUNT_ID, PLACEHOLDER_API_KEY, PLACEHOLDER_ACCOUNT_ID
# Constants
URL = "https://api.cloudflare.com/client/v4/user/tokens/verify"
TIMEOUT = 10
# Custom exceptions
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth."""
DATA_SCHEMA = vol.Schema({
vol.Required(CONF_ACCOUNT_ID, description={LABEL_ACCOUNT_ID}): str,
vol.Required(CONF_API_KEY, description={LABEL_API_KEY}): str
})
async def validate_credentials(hass, data):
"""Validate the provided credentials are correct."""
api_key = data["api_key"]
headers = {
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
}
try:
async with aiohttp.ClientSession() as session:
async with async_timeout.timeout(TIMEOUT):
async with session.get(URL, headers=headers) as response:
if response.status == 200:
return True
elif response.status == 401:
raise InvalidAuth
else:
raise CannotConnect
except aiohttp.ClientError:
raise CannotConnect
except async_timeout.TimeoutError:
raise CannotConnect
class CloudflareConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Cloudflare config flow."""
VERSION = 1
async def async_step_user(self, user_input=None):
"""Handle a flow initiated by the user."""
errors = {}
if user_input is not None:
try:
await validate_credentials(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception:
errors["base"] = "unknown"
else:
return self.async_create_entry(title="Cloudflare Tunnel Monitor", data=user_input)
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
errors=errors,
description_placeholders={
CONF_API_KEY: PLACEHOLDER_API_KEY,
CONF_ACCOUNT_ID: PLACEHOLDER_ACCOUNT_ID,
}
)
@@ -0,0 +1,14 @@
DOMAIN = "cloudflare_tunnel_monitor"
# Constants for user data keys
CONF_ACCOUNT_ID = "account_id"
CONF_API_KEY = "api_key"
# Constants for labels displayed in the user interface
LABEL_ACCOUNT_ID = "Account ID"
LABEL_API_KEY = "API Token"
# Constants for placeholders or suggested values
PLACEHOLDER_API_KEY = "api-token"
PLACEHOLDER_ACCOUNT_ID = "your-account-id"
@@ -0,0 +1,249 @@
import logging
import aiohttp
import asyncio
import async_timeout
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.helpers.entity import Entity
from homeassistant.exceptions import PlatformNotReady
from datetime import timedelta
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
# Constants
URL = "https://api.cloudflare.com/client/v4/accounts/{}/cfd_tunnel?is_deleted=false"
TIMEOUT = 10
RETRY_DELAY = 20
MAX_RETRIES = 5
def create_headers(api_key):
"""Create headers for API requests."""
return {
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
}
async def fetch_tunnels(api_key, account_id, hass, entry_id, retries=0):
"""Retrieve Cloudflare tunnel status using aiohttp."""
headers = create_headers(api_key)
url = URL.format(account_id)
_LOGGER.debug(f"Attempt {retries + 1} to fetch tunnels from URL: {url}")
try:
async with aiohttp.ClientSession() as session:
async with async_timeout.timeout(TIMEOUT):
async with session.get(url, headers=headers) as response:
_LOGGER.debug(f"Response status: {response.status}")
if response.status == 200:
json_response = await response.json()
_LOGGER.debug(f"Received data: {json_response}")
return json_response['result']
elif response.status == 401:
raise UpdateFailed("Unauthorized access - check your API key")
else:
raise UpdateFailed(f"Error fetching Cloudflare tunnels: {response.status}, {response.reason}")
except aiohttp.ClientError as err:
_LOGGER.error(f"Client error fetching data: {err}")
raise UpdateFailed("Client error occurred while fetching data") from err
except asyncio.TimeoutError:
_LOGGER.error("Timeout error fetching data")
raise UpdateFailed("Timeout error occurred while fetching data")
except Exception as err:
_LOGGER.error(f"Unexpected error fetching data: {err}")
if retries < MAX_RETRIES:
_LOGGER.info(f"Retrying in {RETRY_DELAY} seconds...")
await asyncio.sleep(RETRY_DELAY)
return await fetch_tunnels(api_key, account_id, hass, entry_id, retries + 1)
else:
_LOGGER.error("Maximum number of retries reached, scheduling integration reload")
await schedule_integration_reload(hass, entry_id)
raise UpdateFailed("Maximum retries reached, integration reload scheduled")
class CloudflareTunnelsDevice(Entity):
"""Representation of the Cloudflare Tunnels device."""
def __init__(self, account_id, domain):
"""Initialize the Cloudflare Tunnels device."""
self._account_id = account_id
self._domain = domain
@property
def unique_id(self):
"""Return a unique ID."""
return f"{self._domain}_cloudflare_tunnels_{self._account_id}"
@property
def name(self):
"""Return the name of the device."""
return "Cloudflare Tunnels"
@property
def device_info(self):
"""Return device information."""
return {
"identifiers": {(self._domain, self.unique_id)},
"name": self.name,
"manufacturer": "Cloudflare",
}
class CloudflareTunnelSensor(SensorEntity):
"""Representation of a Cloudflare tunnel sensor."""
def __init__(self, tunnel, coordinator, device):
"""Initialize the Cloudflare tunnel sensor."""
self.coordinator = coordinator
self._tunnel = tunnel
self._device = device
@property
def name(self):
"""Return the name of the sensor."""
return f"Cloudflare Tunnel {self._tunnel['name']}"
@property
def unique_id(self):
"""Return a unique ID."""
return f"{self._device._domain}_{self._tunnel['id']}"
@property
def state(self):
"""Return the state of the sensor."""
return self._tunnel['status']
@property
def icon(self):
"""Return the icon of the sensor."""
return 'mdi:cloud-check' if self._tunnel['status'] == 'healthy' else 'mdi:cloud-off-outline'
@property
def options(self):
"""Return the possible values of the sensor."""
return ["inactive", "degraded", "healthy", "down"]
@property
def device_class(self):
"""Return the device class of the sensor."""
return SensorDeviceClass.ENUM
@property
def device_info(self):
"""Return device information."""
return {
"identifiers": {(self._device._domain, self._device.unique_id)},
"name": self._device.name,
"manufacturer": "Cloudflare",
}
async def async_update(self):
"""Update the state of the sensor."""
_LOGGER.debug(f"Requesting refresh for tunnel {self._tunnel['id']}")
await self.coordinator.async_request_refresh()
if self.coordinator.data is not None:
_LOGGER.debug(f"Coordinator data is not None. Searching for updated tunnel data for {self._tunnel['id']}")
updated_tunnel = next((tunnel for tunnel in self.coordinator.data if tunnel.get('id') == self._tunnel.get('id')), None)
if updated_tunnel is not None:
_LOGGER.debug(f"Found updated data for tunnel {self._tunnel['id']}")
self._tunnel = updated_tunnel
_LOGGER.debug("Tunnel updated data: %s", self._tunnel)
else:
_LOGGER.error("Tunnel with ID %s not found in the updated data", self._tunnel.get('id'))
else:
_LOGGER.error("No data received in coordinator during update, maintaining previous state")
class CloudflareTunnelManager:
"""Manages Cloudflare Tunnel Sensor entities."""
def __init__(self, hass, async_add_entities, coordinator, device):
self._hass = hass
self._async_add_entities = async_add_entities
self._coordinator = coordinator
self._device = device
self._sensors = {}
async def update_sensors(self, new_tunnels, removed_tunnels):
"""Update sensor entities based on the tunnel changes."""
_LOGGER.debug(f"Updating sensors. New: {new_tunnels}, Removed: {removed_tunnels}")
for tunnel in new_tunnels:
sensor_id = f"{self._device._domain}_{tunnel['id']}"
if sensor_id not in self._sensors:
_LOGGER.info(f"Adding new sensor for tunnel: {tunnel['id']}")
sensor = CloudflareTunnelSensor(tunnel, self._coordinator, self._device)
self._sensors[sensor_id] = sensor
self._async_add_entities([sensor], True)
for tunnel in removed_tunnels:
sensor_id = f"{self._device._domain}_{tunnel['id']}"
if sensor_id in self._sensors:
_LOGGER.info(f"Removing sensor for tunnel: {sensor_id}")
try:
sensor = self._sensors.pop(sensor_id)
await sensor.async_remove()
except Exception as e:
_LOGGER.error(f"Error removing sensor for tunnel {sensor_id}: {e}")
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Cloudflare tunnel sensor."""
api_key = config_entry.data["api_key"]
account_id = config_entry.data["account_id"]
device = CloudflareTunnelsDevice(account_id, DOMAIN)
async def async_update_data():
"""Fetch data from API endpoint and detect changes in tunnels."""
_LOGGER.debug("Fetching new tunnel data from Cloudflare")
new_data = await fetch_tunnels(api_key, account_id, hass, config_entry.entry_id)
if new_data is None:
new_data = []
if coordinator.data is None:
coordinator.data = []
current_ids = {tunnel['id'] for tunnel in coordinator.data}
new_ids = {tunnel['id'] for tunnel in new_data}
added_tunnels = [tunnel for tunnel in new_data if tunnel['id'] not in current_ids]
removed_tunnels = [tunnel for tunnel in coordinator.data if tunnel['id'] not in new_ids]
_LOGGER.debug(f"Added tunnels: {added_tunnels}, Removed tunnels: {removed_tunnels}")
if added_tunnels or removed_tunnels:
await tunnel_manager.update_sensors(added_tunnels, removed_tunnels)
return new_data
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name="cloudflare_tunnel",
update_method=async_update_data,
update_interval=timedelta(minutes=1),
)
tunnel_manager = CloudflareTunnelManager(hass, async_add_entities, coordinator, device)
await coordinator.async_config_entry_first_refresh()
if not hasattr(tunnel_manager, 'initialized') or not tunnel_manager.initialized:
_LOGGER.debug("Creating initial sensor entities")
for tunnel in coordinator.data:
sensor_id = f"{device._domain}_{tunnel['id']}"
if sensor_id not in tunnel_manager._sensors:
sensor = CloudflareTunnelSensor(tunnel, coordinator, device)
tunnel_manager._sensors[sensor_id] = sensor
async_add_entities([sensor], True)
tunnel_manager.initialized = True
hass.bus.async_listen_once("homeassistant_stop", async_shutdown)
async def async_shutdown(event):
"""Close aiohttp session when Home Assistant stops."""
_LOGGER.debug("Cloudflare Tunnel Monitor - aiohttp session closed")
async def schedule_integration_reload(hass, entry_id):
"""Schedule a reload of the integration."""
_LOGGER.info(f"Scheduling reload of integration with entry_id {entry_id}")
await hass.config_entries.async_reload(entry_id)