Initial commit.
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user