| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335 |
- # -*- 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,
- }
|