Initial commit.
This commit is contained in:
@@ -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