# -*- coding: utf-8 -*- """ Online-Service — Konnektivitaets-Ueberwachung. Prueft periodisch ob das System online ist: - Online: alle 5 Minuten pruefen - Offline: jede Minute pruefen Stellt einen HttpClient mit integriertem Offline-Cache bereit. Events: - online_status_changed: {"online": bool, "since": datetime} """ from __future__ import annotations import asyncio import socket import time from dataclasses import dataclass from datetime import datetime from typing import TYPE_CHECKING from trixy_core.online.cache import ResponseCache from trixy_core.online.client import HttpClient from trixy_core.service import IService, ServicePriority, ServiceGroup from trixy_core.utils.debug import pinfo, pdebug, perror if TYPE_CHECKING: from trixy_core.application import BaseApplication @dataclass class OnlineServiceConfig: """Konfiguration fuer den Online-Service.""" # Pruef-Intervalle (Sekunden) check_interval_online: float = 300.0 # 5 Minuten check_interval_offline: float = 60.0 # 1 Minute # Pruef-Methode: DNS-Lookup auf bekannte Hosts check_hosts: list[str] | None = None # Default: Google DNS + Cloudflare # Timeout fuer einzelne Pruefung check_timeout: float = 5.0 # HTTP-Client Defaults http_timeout: float = 30.0 # Cache-Verzeichnis (relativ zum Source-Dir) cache_dir: str = "cache" class OnlineService(IService): """ Konnektivitaets-Service. Ueberwacht die Internet-Verbindung und stellt einen HTTP-Client mit Offline-Cache bereit. Beispiel: ```python online = app.services.get("online") # Status pruefen if online.is_online: resp = await online.http.get("https://api.example.com") # Request mit Cache (funktioniert auch offline) resp = await online.http.get( "https://api.weather.com/forecast", cache_ttl=3600, ) if resp.from_cache: print("Offline — verwende gecachte Daten") ``` """ PRIORITY = ServicePriority.CORE GROUP = ServiceGroup.NETWORK DEPENDENCIES = [] def __init__( self, application: "BaseApplication", config: OnlineServiceConfig | None = None, ) -> None: super().__init__(application) self._config = config or OnlineServiceConfig() # Status self._online = False self._online_since: datetime | None = None self._offline_since: datetime | None = datetime.now() self._last_check: float = 0.0 self._check_count = 0 # Check-Hosts self._check_hosts = self._config.check_hosts or [ "8.8.8.8", # Google DNS "1.1.1.1", # Cloudflare DNS "9.9.9.9", # Quad9 DNS ] # Cache + HTTP-Client source_path = "" if hasattr(application, "config_manager"): source_path = getattr(application.config_manager, "base_path", "") if not source_path: source_path = "." cache_path = f"{source_path}/{self._config.cache_dir}" self._cache = ResponseCache(cache_path) self._http = HttpClient( cache=self._cache, is_online_fn=lambda: self._online, timeout=self._config.http_timeout, ) # Task self._check_task: asyncio.Task | None = None # === Properties === @property def is_online(self) -> bool: """True wenn Internet-Verbindung besteht.""" return self._online @property def is_offline(self) -> bool: """True wenn keine Internet-Verbindung besteht.""" return not self._online @property def online_since(self) -> datetime | None: """Zeitpunkt seit dem online. None wenn offline.""" return self._online_since @property def offline_since(self) -> datetime | None: """Zeitpunkt seit dem offline. None wenn online.""" return self._offline_since @property def online_duration_seconds(self) -> float: """Wie lange online in Sekunden. 0 wenn offline.""" if self._online and self._online_since: return (datetime.now() - self._online_since).total_seconds() return 0.0 @property def offline_duration_seconds(self) -> float: """Wie lange offline in Sekunden. 0 wenn online.""" if not self._online and self._offline_since: return (datetime.now() - self._offline_since).total_seconds() return 0.0 @property def http(self) -> HttpClient: """HTTP/HTTPS-Client mit Offline-Cache.""" return self._http def ftp(self, config: "FTPConfig") -> "FTPClient": """ Erstellt einen FTP/SFTP-Client. Nutzung als Context-Manager: ```python async with online.ftp(FTPConfig(host="...")) as ftp: await ftp.download("/remote/file", "/local/file") ``` """ from trixy_core.online.ftp import FTPClient return FTPClient(config) @property def cache(self) -> ResponseCache: """Zugriff auf den Response-Cache.""" return self._cache # === Service Lifecycle === async def start(self) -> None: """Startet den Online-Service.""" # Initialen Check durchfuehren online = await self._check_connectivity() self._set_status(online, emit_event=False) status = "online" if self._online else "offline" pinfo(f"Online-Service gestartet (Status: {status})") # Periodischen Check starten self._check_task = asyncio.create_task(self._check_loop()) async def stop(self) -> None: """Stoppt den Online-Service.""" if self._check_task: self._check_task.cancel() try: await self._check_task except asyncio.CancelledError: pass self._check_task = None await self._http.close() pinfo("Online-Service gestoppt") # === Konnektivitaets-Pruefung === async def _check_loop(self) -> None: """Periodischer Konnektivitaets-Check.""" try: while True: # Intervall: 5 Min online, 1 Min offline if self._online: interval = self._config.check_interval_online else: interval = self._config.check_interval_offline await asyncio.sleep(interval) online = await self._check_connectivity() self._set_status(online) except asyncio.CancelledError: pass async def _check_connectivity(self) -> bool: """ Prueft die Internet-Verbindung. Versucht einen TCP-Connect zu bekannten DNS-Servern (Port 53). Schneller und zuverlaessiger als HTTP oder ICMP. """ self._last_check = time.time() self._check_count += 1 for host in self._check_hosts: try: # TCP-Connect zu DNS-Port (schnell, kein Payload) _, writer = await asyncio.wait_for( asyncio.open_connection(host, 53), timeout=self._config.check_timeout, ) writer.close() await writer.wait_closed() return True except (OSError, asyncio.TimeoutError): continue return False def _set_status(self, online: bool, emit_event: bool = True) -> None: """ Setzt den Online-Status und loest Events aus. Events: - online_status_changed: Immer bei Statuswechsel (online/offline) - connection_online: Wenn Verbindung wiederhergestellt (war offline) - connection_offline: Wenn Verbindung verloren (war online) """ if online == self._online: return # Keine Aenderung was_online = self._online self._online = online now = datetime.now() # Offline-Dauer berechnen (fuer Event-Daten) offline_duration = 0.0 online_duration = 0.0 if online: # Wechsel: offline → online if self._offline_since: offline_duration = (now - self._offline_since).total_seconds() self._online_since = now self._offline_since = None pinfo(f"Verbindung hergestellt — ONLINE (war {offline_duration:.0f}s offline)") else: # Wechsel: online → offline if self._online_since: online_duration = (now - self._online_since).total_seconds() self._online_since = None self._offline_since = now pinfo(f"Verbindung verloren — OFFLINE (war {online_duration:.0f}s online)") if not emit_event: return events = getattr(self._application, "events", None) if not events: return event_data = { "online": online, "since": now.isoformat(), "previous": "online" if was_online else "offline", } # 1. Allgemeines Status-Change Event (immer) asyncio.create_task(events.emit("online_status_changed", event_data)) # 2. Spezifisches Event (zusaetzlich) if online: asyncio.create_task(events.emit("connection_online", { **event_data, "offline_duration_seconds": offline_duration, })) else: asyncio.create_task(events.emit("connection_offline", { **event_data, "online_duration_seconds": online_duration, })) # === Manuelle Pruefung === async def check_now(self) -> bool: """Fuehrt sofort einen Konnektivitaets-Check durch.""" online = await self._check_connectivity() self._set_status(online) return online def get_status(self) -> dict: """Gibt den aktuellen Status als Dictionary zurueck.""" return { "online": self._online, "online_since": self._online_since.isoformat() if self._online_since else None, "offline_since": self._offline_since.isoformat() if self._offline_since else None, "online_duration_seconds": self.online_duration_seconds, "offline_duration_seconds": self.offline_duration_seconds, "last_check": self._last_check, "check_count": self._check_count, "cache_entries": self._cache.size, }