phase2a: Kern fuer zwei Instanzen (Rolle, Partner, Telegram-Zweitweg)
- kern/einstellungen.py: MC_ROLLE (box|homelab), Instanzname, Datenordner, Partner-URL - kern/zeit.py: eine Zeitzone statt sechs Kopien - app.py/steward.py: Box-Router und Warm-Set nur in der Rolle box; der Homelab-Teil laedt nichts von der Box - /api/health nennt Rolle und Instanz; Engine/Gateway/Hirn nur auf der Box - /api/partner (Lebenszeichen) und /api/partner/<pfad> (Durchreiche, ohne Schleifen und SSE) - Waechter prueft die andere Instanz (rot erst nach 5 Takten, ein Neustart ist kein Alarm) - notify.sh: Zweitweg direkt an die Telegram-Bot-API, wenn hermes send scheitert oder fehlt; Token nicht in der Prozessliste, Tests greifen nie auf die echte .env - update_verlauf erkennt "OK telegram direkt" Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
99ba6a78ab
commit
75611be9a9
+68
-54
@@ -1,13 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
Mission Control 2.0 — dünner FastAPI-Einstieg.
|
Homelab Orchestrator (vormals Mission Control 2.0) — dünner FastAPI-Einstieg.
|
||||||
|
|
||||||
Hängt die Router ein, liefert (in Prod) das gebaute React-Frontend aus und
|
Hängt die Router ein, liefert (in Prod) das gebaute React-Frontend aus und
|
||||||
setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev-
|
setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev-
|
||||||
Server (proxyt /api hierher), daher CORS für localhost offen.
|
Server (proxyt /api hierher), daher CORS für localhost offen.
|
||||||
|
|
||||||
Seit dem Box-Wart-Umbau (09/2026) ist MC2 nur noch Updater, Wächter und Modell-Radar.
|
Zwei Rollen, derselbe Code (MC_ROLLE, kern/einstellungen.py, seit 24.09.2026):
|
||||||
Ideen, Wissen, Chronik, Skills, Verbinden und die Konsole sind raus; die Schnittstellen,
|
box — die KI-Box: Box-Wart (Updater, Wächter, Modell-Radar) plus die Schnittstellen,
|
||||||
die Lucy-Desktop, OpenChamber und Hermes brauchen (Sprache, /v1, MCP-Werkzeuge), bleiben.
|
die Lucy-Desktop, OpenChamber und Hermes brauchen (Sprache, /v1, MCP-Werkzeuge)
|
||||||
|
homelab — der Proxmox-PC: der Homelab-Teil
|
||||||
|
Beide liefern dieselbe Oberfläche aus und reichen den Bereich der anderen Instanz unter
|
||||||
|
/api/partner/… durch.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -23,25 +26,13 @@ from fastapi import FastAPI, HTTPException
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from routers import (
|
from kern.einstellungen import einstellungen
|
||||||
boxwart,
|
from routers import health, partner
|
||||||
events,
|
|
||||||
gateway_proxy,
|
|
||||||
health,
|
|
||||||
hermes_ui,
|
|
||||||
maintenance,
|
|
||||||
models,
|
|
||||||
radar,
|
|
||||||
routing,
|
|
||||||
system,
|
|
||||||
voice,
|
|
||||||
zeitmaschine,
|
|
||||||
)
|
|
||||||
from routers import reminders as reminders_router
|
|
||||||
from services import reminders, warmer
|
|
||||||
from services.herkunft import erlaubt
|
from services.herkunft import erlaubt
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
ROLLE = einstellungen().rolle
|
||||||
|
|
||||||
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
|
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
|
||||||
# für alle Module (logging.getLogger(__name__)).
|
# für alle Module (logging.getLogger(__name__)).
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -51,11 +42,11 @@ logging.basicConfig(
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
def _box_hintergrund() -> list[asyncio.Task[Any]]:
|
||||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
"""Hintergrund-Tasks der KI-Box. Der Wächter läuft NICHT hier, sondern im mc2-steward
|
||||||
"""Hintergrund-Tasks an den App-Lebenszyklus binden. Der Wächter läuft NICHT hier,
|
(eigener Prozess, steward.py) — ein totes MC2 könnte sich sonst nicht selbst melden."""
|
||||||
sondern im mc2-steward (eigener Prozess, steward.py) — ein totes MC2 könnte sich
|
from services import reminders, warmer
|
||||||
sonst nicht selbst melden."""
|
|
||||||
tasks: list[asyncio.Task[Any]] = []
|
tasks: list[asyncio.Task[Any]] = []
|
||||||
if warmer.ENABLED:
|
if warmer.ENABLED:
|
||||||
tasks.append(asyncio.create_task(warmer.rewarm_loop()))
|
tasks.append(asyncio.create_task(warmer.rewarm_loop()))
|
||||||
@@ -69,6 +60,13 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
tasks.append(asyncio.create_task(reminders.reminders_loop()))
|
tasks.append(asyncio.create_task(reminders.reminders_loop()))
|
||||||
else:
|
else:
|
||||||
log.info("Probelauf: Erinnerungen bleiben beim echten MC2.")
|
log.info("Probelauf: Erinnerungen bleiben beim echten MC2.")
|
||||||
|
return tasks
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
|
"""Hintergrund-Tasks an den App-Lebenszyklus binden."""
|
||||||
|
tasks = _box_hintergrund() if ROLLE == "box" else []
|
||||||
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client
|
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client
|
||||||
# pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
|
# pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
|
||||||
app.state.gw_client = httpx.AsyncClient(
|
app.state.gw_client = httpx.AsyncClient(
|
||||||
@@ -83,7 +81,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
await app.state.gw_client.aclose()
|
await app.state.gw_client.aclose()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Mission Control 2.0", version=VERSION, lifespan=lifespan)
|
app = FastAPI(title="Homelab Orchestrator", version=VERSION, lifespan=lifespan)
|
||||||
|
|
||||||
# Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf.
|
# Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf.
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -99,7 +97,7 @@ app.add_middleware(
|
|||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def herkunft(request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]):
|
async def herkunft(request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]):
|
||||||
if not erlaubt(request.method, request.url.path, request.headers.get("origin"), request.headers.get("host")):
|
if not erlaubt(request.method, request.url.path, request.headers.get("origin"), request.headers.get("host")):
|
||||||
return JSONResponse({"detail": "Diese Aktion geht nur von der Box-Wart-Seite selbst aus."}, status_code=403)
|
return JSONResponse({"detail": "Diese Aktion geht nur von der Orchestrator-Seite selbst aus."}, status_code=403)
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
@@ -113,34 +111,50 @@ async def no_cache(
|
|||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
app.include_router(health.router)
|
def _box_router(app: FastAPI) -> None:
|
||||||
app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan
|
"""Die Schnittstellen der KI-Box (Box-Wart und alles, was Lucy, OpenChamber und Hermes brauchen)."""
|
||||||
app.include_router(radar.router) # Modell-Radar: Kandidaten, Nachttests, Übernehmen/Verwerfen
|
from routers import (
|
||||||
app.include_router(models.router)
|
boxwart,
|
||||||
app.include_router(routing.router)
|
events,
|
||||||
app.include_router(system.router)
|
gateway_proxy,
|
||||||
|
hermes_ui,
|
||||||
|
maintenance,
|
||||||
|
models,
|
||||||
|
radar,
|
||||||
|
routing,
|
||||||
|
system,
|
||||||
|
voice,
|
||||||
|
zeitmaschine,
|
||||||
|
)
|
||||||
|
from routers import reminders as reminders_router
|
||||||
|
|
||||||
app.include_router(
|
app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan
|
||||||
voice.router
|
app.include_router(radar.router) # Modell-Radar: Kandidaten, Nachttests, Übernehmen/Verwerfen
|
||||||
) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab)
|
app.include_router(models.router)
|
||||||
app.include_router(
|
app.include_router(routing.router)
|
||||||
reminders_router.router
|
app.include_router(system.router)
|
||||||
) # Erinnerungen/Routinen (A3) — feuern in den Briefkasten
|
app.include_router(voice.router) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat
|
||||||
# /v1-Datenpfad: Nach dem Gateway-Auszug (UMBAU v3 P1) läuft der eigentliche Gateway als
|
app.include_router(reminders_router.router) # Erinnerungen/Routinen — feuern in den Briefkasten
|
||||||
# eigener Prozess (mc2-gateway, Loopback :9010) — MC2 reicht /v1 dann nur roh durch, damit
|
# /v1-Datenpfad: Nach dem Gateway-Auszug (UMBAU v3 P1) läuft der eigentliche Gateway als
|
||||||
# LAN-Clients (IDE-Lane) weiter über :9001 kommen. Ohne MC_V1_UPSTREAM (vor dem ersten
|
# eigener Prozess (mc2-gateway, Loopback :9010) — MC2 reicht /v1 dann nur roh durch, damit
|
||||||
# Deploy der neuen Unit / nach Rollback) bedient MC2 /v1 wie bisher selbst.
|
# LAN-Clients (IDE-Lane) weiter über :9001 kommen. Ohne MC_V1_UPSTREAM (vor dem ersten
|
||||||
if V1_UPSTREAM:
|
# Deploy der neuen Unit / nach Rollback) bedient MC2 /v1 wie bisher selbst.
|
||||||
from routers import gateway_forward
|
if V1_UPSTREAM:
|
||||||
app.include_router(gateway_forward.router) # dünner Roh-Weiterleiter → mc2-gateway
|
from routers import gateway_forward
|
||||||
else:
|
app.include_router(gateway_forward.router) # dünner Roh-Weiterleiter → mc2-gateway
|
||||||
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
|
else:
|
||||||
app.include_router(maintenance.router)
|
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
|
||||||
app.include_router(events.router) # SSE-Strom /api/stream — Messwerte + Invalidation
|
app.include_router(maintenance.router)
|
||||||
app.include_router(zeitmaschine.router) # Sicherungen ansehen + zurückspielen (detached)
|
app.include_router(events.router) # SSE-Strom /api/stream — Messwerte + Invalidation
|
||||||
app.include_router(
|
app.include_router(zeitmaschine.router) # Sicherungen ansehen + zurückspielen (detached)
|
||||||
hermes_ui.router
|
# Eingebaute Hermes-Web-GUI (hermes dashboard) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all
|
||||||
) # Eingebaute Hermes-Web-GUI (hermes dashboard) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all
|
app.include_router(hermes_ui.router)
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(health.router)
|
||||||
|
app.include_router(partner.router) # zweite Instanz: Lebenszeichen + Durchreiche
|
||||||
|
if ROLLE == "box":
|
||||||
|
_box_router(app)
|
||||||
|
|
||||||
|
|
||||||
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
|
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Kern des Homelab Orchestrators (Phase 2, ab 24.09.2026): was beide Instanzen teilen —
|
||||||
|
Einstellungen, Zeitzone, Partner-Instanz. Neue Module lesen ihre Einstellungen hier; ältere
|
||||||
|
Module ziehen nach und nach um."""
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Zentrale Einstellungen einer Instanz des Homelab Orchestrators.
|
||||||
|
|
||||||
|
Zwei Rollen, derselbe Code (User-Entscheid 24.09.2026, „zwei Instanzen, eine Oberfläche“):
|
||||||
|
box — auf der KI-Box: Box-Wart (Updates, Wächter, Modelle, Radar, Gateway für Lucy)
|
||||||
|
homelab — auf dem Proxmox-PC: Homelab-Teil (Proxmox-Host, Container, Arcane)
|
||||||
|
Beide kennen die jeweils andere Instanz als Partner: die Oberfläche zeigt beide Bereiche, und
|
||||||
|
jede Instanz prüft, ob die andere noch antwortet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROLLEN = ("box", "homelab")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Einstellungen:
|
||||||
|
rolle: str # "box" | "homelab"
|
||||||
|
instanz: str # Anzeigename dieser Instanz
|
||||||
|
daten_dir: Path # Zustandsdateien dieser Instanz
|
||||||
|
partner_url: str # Basis-URL der anderen Instanz, leer = keine
|
||||||
|
partner_name: str # Anzeigename der anderen Instanz
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def einstellungen() -> Einstellungen:
|
||||||
|
rolle = os.environ.get("MC_ROLLE", "box").strip().lower() or "box"
|
||||||
|
if rolle not in ROLLEN:
|
||||||
|
raise ValueError(f"MC_ROLLE muss eine von {ROLLEN} sein, nicht {rolle!r}.")
|
||||||
|
box = rolle == "box"
|
||||||
|
standard_daten = os.environ.get("MC_MODELS_DIR", "/srv/models") if box else "/var/lib/mc2"
|
||||||
|
return Einstellungen(
|
||||||
|
rolle=rolle,
|
||||||
|
instanz=os.environ.get("MC_INSTANZ", "Box-Wart" if box else "Homelab"),
|
||||||
|
daten_dir=Path(os.environ.get("MC_DATEN_DIR", standard_daten)),
|
||||||
|
partner_url=os.environ.get("MC_PARTNER_URL", "").strip().rstrip("/"),
|
||||||
|
partner_name=os.environ.get("MC_PARTNER_NAME", "Homelab" if box else "Box-Wart"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Die Partner-Instanz (User-Entscheid 24.09.2026: zwei Instanzen, eine Oberfläche).
|
||||||
|
|
||||||
|
Box-Wart läuft auf der KI-Box, der Homelab-Teil in einem Container auf dem Proxmox-PC. Jede
|
||||||
|
Instanz kennt die andere über MC_PARTNER_URL. Sie fragt deren Lebenszeichen ab (für den Wächter
|
||||||
|
und die Kopfzeile der Oberfläche) und reicht Anfragen der Oberfläche unter /api/partner/… durch,
|
||||||
|
damit eine Seite beide Bereiche zeigt — egal, auf welcher Instanz man sie öffnet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from kern.einstellungen import einstellungen
|
||||||
|
|
||||||
|
ZEITLIMIT_S = 5.0
|
||||||
|
CACHE_S = 15.0
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_cache: dict = {"ts": 0.0, "daten": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _kurz(exc: Exception) -> str:
|
||||||
|
if isinstance(exc, httpx.TimeoutException):
|
||||||
|
return "antwortet nicht rechtzeitig"
|
||||||
|
if isinstance(exc, httpx.ConnectError):
|
||||||
|
return "nicht erreichbar"
|
||||||
|
if isinstance(exc, httpx.HTTPStatusError):
|
||||||
|
return f"antwortet mit HTTP {exc.response.status_code}"
|
||||||
|
return f"{exc.__class__.__name__}: {exc}"[:160]
|
||||||
|
|
||||||
|
|
||||||
|
def status(frisch: bool = False) -> dict:
|
||||||
|
"""Lebenszeichen der Partner-Instanz. Ohne MC_PARTNER_URL: {"eingerichtet": False}."""
|
||||||
|
e = einstellungen()
|
||||||
|
if not e.partner_url:
|
||||||
|
return {"eingerichtet": False}
|
||||||
|
with _lock:
|
||||||
|
if not frisch and _cache["daten"] and time.time() - _cache["ts"] < CACHE_S:
|
||||||
|
return dict(_cache["daten"])
|
||||||
|
daten: dict = {"eingerichtet": True, "name": e.partner_name, "url": e.partner_url,
|
||||||
|
"erreichbar": False, "rolle": None, "instanz": None, "version": None, "fehler": None}
|
||||||
|
try:
|
||||||
|
r = httpx.get(f"{e.partner_url}/api/health", timeout=ZEITLIMIT_S)
|
||||||
|
r.raise_for_status()
|
||||||
|
h = r.json()
|
||||||
|
daten.update(erreichbar=True, rolle=h.get("rolle"), instanz=h.get("instanz"), version=h.get("version"))
|
||||||
|
except Exception as exc:
|
||||||
|
daten["fehler"] = _kurz(exc)
|
||||||
|
daten["geprueft"] = time.time()
|
||||||
|
with _lock:
|
||||||
|
_cache.update(ts=time.time(), daten=daten)
|
||||||
|
return dict(daten)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Die eine Zeitzone für alles, was Zeiten anzeigt oder naive Zeiten deutet (Projektregel:
|
||||||
|
nie datetime.now() ohne Zone). Vorher stand dieselbe Zeile in sieben Modulen."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
||||||
@@ -13,15 +13,14 @@ Box-Wart-Schnittstellen (Umbau 09/2026): alles, was der Cockpit-Startbildschirm
|
|||||||
Dünn: die Logik liegt in services/waechter.py, modell_nutzung.py, zeitplan.py, update_verlauf.py.
|
Dünn: die Logik liegt in services/waechter.py, modell_nutzung.py, zeitplan.py, update_verlauf.py.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
from config import MODELS_DIR
|
from config import MODELS_DIR
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from kern.zeit import LOCAL_TZ
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from services import (
|
from services import (
|
||||||
aufraeumen,
|
aufraeumen,
|
||||||
@@ -36,7 +35,6 @@ from services import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["boxwart"])
|
router = APIRouter(prefix="/api", tags=["boxwart"])
|
||||||
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
|
||||||
|
|
||||||
_updates_lock = threading.Lock()
|
_updates_lock = threading.Lock()
|
||||||
_updates_cache: dict = {"ts": 0.0, "daten": None, "laeuft": False, "stand": -1}
|
_updates_cache: dict = {"ts": 0.0, "daten": None, "laeuft": False, "stand": -1}
|
||||||
|
|||||||
+24
-15
@@ -1,9 +1,12 @@
|
|||||||
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0."""
|
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check einer Instanz.
|
||||||
|
|
||||||
|
Seit 24.09.2026 nennt er auch Rolle und Namen der Instanz: Die Partner-Instanz liest daran ab,
|
||||||
|
wer ihr antwortet. Engine, Gateway und Hirn gibt es nur auf der KI-Box."""
|
||||||
|
|
||||||
from config import VERSION
|
from config import VERSION
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
from kern.einstellungen import einstellungen
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from services import gateway, llamaswap
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
@@ -11,19 +14,25 @@ router = APIRouter(prefix="/api")
|
|||||||
class HealthResponse(BaseModel):
|
class HealthResponse(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
version: str
|
version: str
|
||||||
engine_reachable: bool
|
rolle: str
|
||||||
gateway_reachable: bool
|
instanz: str
|
||||||
brain: dict
|
engine_reachable: bool | None = None
|
||||||
|
gateway_reachable: bool | None = None
|
||||||
|
brain: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health", response_model=HealthResponse)
|
@router.get("/health", response_model=HealthResponse, response_model_exclude_none=True)
|
||||||
def health() -> dict:
|
def health() -> dict:
|
||||||
return {
|
e = einstellungen()
|
||||||
"status": "ok",
|
antwort: dict = {"status": "ok", "version": VERSION, "rolle": e.rolle, "instanz": e.instanz}
|
||||||
"version": VERSION,
|
if e.rolle == "box":
|
||||||
"engine_reachable": llamaswap.engine_reachable(),
|
from services import gateway, llamaswap
|
||||||
"gateway_reachable": gateway.gateway_reachable(),
|
|
||||||
# Echte Hirn-Bereitschaft: Engine kann erreichbar sein, das Agent-Hirn ('fast') aber tot
|
antwort.update(
|
||||||
# (Crash/OOM nach Engine-Update). Das wäre sonst ein silent fail (App-Fehler statt Status).
|
engine_reachable=llamaswap.engine_reachable(),
|
||||||
"brain": llamaswap.brain_status(),
|
gateway_reachable=gateway.gateway_reachable(),
|
||||||
}
|
# Echte Hirn-Bereitschaft: Engine kann erreichbar sein, das Agent-Hirn aber tot
|
||||||
|
# (Crash/OOM nach Engine-Update). Das wäre sonst ein silent fail.
|
||||||
|
brain=llamaswap.brain_status(),
|
||||||
|
)
|
||||||
|
return antwort
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Partner-Instanz: Lebenszeichen und Durchreiche (zwei Instanzen, eine Oberfläche, 24.09.2026).
|
||||||
|
|
||||||
|
Die Oberfläche fragt den Bereich der anderen Instanz über /api/partner/<pfad> ab; hier wird
|
||||||
|
daraus <partner>/api/<pfad>. Die Herkunftsprüfung (services/herkunft.py) greift wie bei jeder
|
||||||
|
anderen schreibenden Anfrage schon hier, bevor etwas weitergereicht wird.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException, Request, Response
|
||||||
|
from kern import partner
|
||||||
|
from kern.einstellungen import einstellungen
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["partner"])
|
||||||
|
|
||||||
|
# Nicht durchreichen: den Partner-Weg des Partners (sonst reichen sich zwei Instanzen eine Anfrage
|
||||||
|
# endlos zu) und den Live-Strom (SSE hält die Verbindung offen; der Partner-Bereich fragt ab).
|
||||||
|
_GESPERRT = ("partner", "stream")
|
||||||
|
_ZEITLIMIT = httpx.Timeout(30.0, connect=5.0)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/partner")
|
||||||
|
def partner_status() -> dict:
|
||||||
|
return partner.status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route("/partner/{pfad:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"], include_in_schema=False)
|
||||||
|
async def weiterreichen(pfad: str, request: Request) -> Response:
|
||||||
|
e = einstellungen()
|
||||||
|
if not e.partner_url:
|
||||||
|
raise HTTPException(status_code=404, detail="Es ist keine zweite Instanz eingerichtet.")
|
||||||
|
if pfad.split("/", 1)[0] in _GESPERRT:
|
||||||
|
raise HTTPException(status_code=404, detail="Diese Schnittstelle wird nicht weitergereicht.")
|
||||||
|
kopf = {"X-MC-Von": e.instanz}
|
||||||
|
if request.headers.get("content-type"):
|
||||||
|
kopf["Content-Type"] = request.headers["content-type"]
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_ZEITLIMIT) as c:
|
||||||
|
r = await c.request(request.method, f"{e.partner_url}/api/{pfad}",
|
||||||
|
params=request.query_params, content=await request.body(), headers=kopf)
|
||||||
|
except httpx.HTTPError:
|
||||||
|
raise HTTPException(status_code=502, detail=f"{e.partner_name} ist gerade nicht erreichbar.") from None
|
||||||
|
return Response(content=r.content, status_code=r.status_code,
|
||||||
|
media_type=r.headers.get("content-type", "application/json"))
|
||||||
@@ -20,11 +20,11 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
from kern.zeit import LOCAL_TZ
|
||||||
|
|
||||||
CACHE_S = 600
|
CACHE_S = 600
|
||||||
TAGE = 7
|
TAGE = 7
|
||||||
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
|
||||||
|
|
||||||
_STANDARD_NAMEN = {
|
_STANDARD_NAMEN = {
|
||||||
"127.0.0.1": "Lucy und OpenChamber über MC2",
|
"127.0.0.1": "Lucy und OpenChamber über MC2",
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ from datetime import date, datetime, timedelta
|
|||||||
from datetime import time as uhrzeit
|
from datetime import time as uhrzeit
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import psutil
|
import psutil
|
||||||
from config import HF_DOWNLOAD_ENV, LLAMA_SWAP_URL, MODELS_DIR
|
from config import HF_DOWNLOAD_ENV, LLAMA_SWAP_URL, MODELS_DIR
|
||||||
|
from kern.zeit import LOCAL_TZ
|
||||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||||
|
|
||||||
from services import discover, geheimnisse, hf, llamaswap
|
from services import discover, geheimnisse, hf, llamaswap
|
||||||
@@ -64,7 +64,6 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# --- Grenzen (jede mit Env-Override) ----------------------------------------------------
|
# --- Grenzen (jede mit Env-Override) ----------------------------------------------------
|
||||||
|
|
||||||
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
|
||||||
FENSTER_START = os.environ.get("MC_RADAR_FENSTER_START", "00:30") # Tests nur nachts …
|
FENSTER_START = os.environ.get("MC_RADAR_FENSTER_START", "00:30") # Tests nur nachts …
|
||||||
FENSTER_ENDE = os.environ.get("MC_RADAR_FENSTER_ENDE", "02:30") # … um 03:00 kommt NerdQuiz
|
FENSTER_ENDE = os.environ.get("MC_RADAR_FENSTER_ENDE", "02:30") # … um 03:00 kommt NerdQuiz
|
||||||
KANDIDATEN_PRO_WOCHE = int(os.environ.get("MC_RADAR_PRO_WOCHE", "1"))
|
KANDIDATEN_PRO_WOCHE = int(os.environ.get("MC_RADAR_PRO_WOCHE", "1"))
|
||||||
|
|||||||
@@ -21,16 +21,15 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from config import MODELS_DIR
|
from config import MODELS_DIR
|
||||||
|
from kern.zeit import LOCAL_TZ
|
||||||
|
|
||||||
from services import announce
|
from services import announce
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
STORE_PATH = Path(os.environ.get("MC_REMINDERS_STORE", str(MODELS_DIR / "mc2-reminders.json")))
|
STORE_PATH = Path(os.environ.get("MC_REMINDERS_STORE", str(MODELS_DIR / "mc2-reminders.json")))
|
||||||
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
|
||||||
INTERVAL = int(os.environ.get("MC_REMINDERS_INTERVAL", "20")) # Wecker-Tick (Sekunden)
|
INTERVAL = int(os.environ.get("MC_REMINDERS_INTERVAL", "20")) # Wecker-Tick (Sekunden)
|
||||||
MAX_ITEMS = int(os.environ.get("MC_REMINDERS_MAX", "100"))
|
MAX_ITEMS = int(os.environ.get("MC_REMINDERS_MAX", "100"))
|
||||||
REPEATS = ("", "daily", "weekdays", "weekly")
|
REPEATS = ("", "daily", "weekdays", "weekly")
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ import re
|
|||||||
import threading
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
from kern.zeit import LOCAL_TZ
|
||||||
|
|
||||||
NOTIFY_LOG = Path(os.environ.get("MC_NOTIFY_LOG", str(Path.home() / "mc2-notify.log")))
|
NOTIFY_LOG = Path(os.environ.get("MC_NOTIFY_LOG", str(Path.home() / "mc2-notify.log")))
|
||||||
PINS_FILE = Path(os.environ.get("MC_PINS_FILE", "/srv/models/mc2-pins.json"))
|
PINS_FILE = Path(os.environ.get("MC_PINS_FILE", "/srv/models/mc2-pins.json"))
|
||||||
LESE_MAX = 3_000_000 # Bytes vom Ende des Protokolls — reicht für Monate
|
LESE_MAX = 3_000_000 # Bytes vom Ende des Protokolls — reicht für Monate
|
||||||
@@ -39,7 +39,7 @@ _BAUSTEIN_IM_TEXT = (
|
|||||||
# Kopfzeile eines Protokolleintrags (notify.sh): Zeitstempel, Zustellweg, Nachricht.
|
# Kopfzeile eines Protokolleintrags (notify.sh): Zeitstempel, Zustellweg, Nachricht.
|
||||||
_EINTRAG = re.compile(
|
_EINTRAG = re.compile(
|
||||||
r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) "
|
r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) "
|
||||||
r"(?:OK telegram|QUEUED für Morgen-Digest|FALLBACK \(.*?\)): ?(.*)$")
|
r"(?:OK telegram(?: direkt)?|QUEUED für Morgen-Digest|FALLBACK \(.*?\)): ?(.*)$")
|
||||||
|
|
||||||
_ZUSAMMENFASSUNG = re.compile(r"Wochenpflege der Box ist durch")
|
_ZUSAMMENFASSUNG = re.compile(r"Wochenpflege der Box ist durch")
|
||||||
_UPDATE_MELDUNG = re.compile(
|
_UPDATE_MELDUNG = re.compile(
|
||||||
|
|||||||
@@ -42,11 +42,15 @@ from pathlib import Path
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import psutil
|
import psutil
|
||||||
from config import HERMES_API_KEY, HERMES_API_URL, HERMES_HOME, MODELS_DIR, VOICE_SERVICE_URL
|
from config import HERMES_API_KEY, HERMES_API_URL, HERMES_HOME, VOICE_SERVICE_URL
|
||||||
|
from kern import partner
|
||||||
|
from kern.einstellungen import einstellungen
|
||||||
|
|
||||||
from services import announce, llamaswap, maintenance, update_verlauf
|
from services import announce, llamaswap, maintenance, update_verlauf
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
ROLLE = einstellungen().rolle
|
||||||
|
DATEN_DIR = einstellungen().daten_dir
|
||||||
|
|
||||||
# MC_SENTRY_ENABLED bleibt als Rückfall-Schalter gültig: Die bestehenden Units setzen ihn.
|
# MC_SENTRY_ENABLED bleibt als Rückfall-Schalter gültig: Die bestehenden Units setzen ihn.
|
||||||
ENABLED = os.environ.get("MC_WAECHTER_ENABLED", os.environ.get("MC_SENTRY_ENABLED", "1")) != "0"
|
ENABLED = os.environ.get("MC_WAECHTER_ENABLED", os.environ.get("MC_SENTRY_ENABLED", "1")) != "0"
|
||||||
@@ -57,10 +61,13 @@ REMIND_S = int(os.environ.get("MC_WAECHTER_REMIND_S", "21600")) # Telegram
|
|||||||
AUTO_MAX_PRO_STUNDE = int(os.environ.get("MC_WAECHTER_AUTO_MAX", "2"))
|
AUTO_MAX_PRO_STUNDE = int(os.environ.get("MC_WAECHTER_AUTO_MAX", "2"))
|
||||||
DISK_ROT_PCT = float(os.environ.get("MC_WAECHTER_DISK_ROT", "90"))
|
DISK_ROT_PCT = float(os.environ.get("MC_WAECHTER_DISK_ROT", "90"))
|
||||||
DISK_GELB_PCT = float(os.environ.get("MC_WAECHTER_DISK_GELB", "80"))
|
DISK_GELB_PCT = float(os.environ.get("MC_WAECHTER_DISK_GELB", "80"))
|
||||||
STORE_PATH = Path(os.environ.get("MC_WAECHTER_STORE", str(MODELS_DIR / "mc2-waechter.json")))
|
STORE_PATH = Path(os.environ.get("MC_WAECHTER_STORE", str(DATEN_DIR / "mc2-waechter.json")))
|
||||||
# „Ausblenden bis zum nächsten Lauf“ (24.09.2026): Hinweis-ID → Start des Laufs, der ausgeblendet ist.
|
# „Ausblenden bis zum nächsten Lauf“ (24.09.2026): Hinweis-ID → Start des Laufs, der ausgeblendet ist.
|
||||||
# Schreibt nur MC2 (Knopf), der Steward liest. Ein neuer Lauf macht den Eintrag wirkungslos.
|
# Schreibt nur MC2 (Knopf), der Steward liest. Ein neuer Lauf macht den Eintrag wirkungslos.
|
||||||
QUITTIERT_PATH = Path(os.environ.get("MC_WAECHTER_QUITTIERT", str(MODELS_DIR / "mc2-quittiert.json")))
|
QUITTIERT_PATH = Path(os.environ.get("MC_WAECHTER_QUITTIERT", str(DATEN_DIR / "mc2-quittiert.json")))
|
||||||
|
# Die andere Instanz darf einen Neustart lang schweigen (Sonntags-Update der Box, Container-Update
|
||||||
|
# auf dem Proxmox-PC), bevor daraus ein roter Hinweis wird.
|
||||||
|
PARTNER_TAKTE = int(os.environ.get("MC_WAECHTER_PARTNER_TAKTE", "5"))
|
||||||
VERLAUF_MAX = 200
|
VERLAUF_MAX = 200
|
||||||
# Trockenlauf (Probelauf neben dem echten Betrieb): prüft und führt Hinweise, meldet aber
|
# Trockenlauf (Probelauf neben dem echten Betrieb): prüft und führt Hinweise, meldet aber
|
||||||
# nichts an Telegram/Lucy und repariert nichts selbst — das macht weiter der echte Wächter.
|
# nichts an Telegram/Lucy und repariert nichts selbst — das macht weiter der echte Wächter.
|
||||||
@@ -112,6 +119,7 @@ class Befund:
|
|||||||
aktionen: list[dict] = field(default_factory=list)
|
aktionen: list[dict] = field(default_factory=list)
|
||||||
auto: str | None = None # Name der Selbstreparatur, falls erlaubt
|
auto: str | None = None # Name der Selbstreparatur, falls erlaubt
|
||||||
sofort: bool = False # ohne FAIL_AFTER-Wartezeit (dauerhafte Befunde)
|
sofort: bool = False # ohne FAIL_AFTER-Wartezeit (dauerhafte Befunde)
|
||||||
|
nach_takten: int = 0 # eigene Wartezeit statt FAIL_AFTER (0 = Standard)
|
||||||
|
|
||||||
|
|
||||||
def _aktion(aid: str, label: str, **extra: str) -> dict:
|
def _aktion(aid: str, label: str, **extra: str) -> dict:
|
||||||
@@ -407,7 +415,7 @@ def pruefe_kern() -> list[Befund]:
|
|||||||
|
|
||||||
def pruefe_platte() -> list[Befund]:
|
def pruefe_platte() -> list[Befund]:
|
||||||
try:
|
try:
|
||||||
pct = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()).percent
|
pct = psutil.disk_usage(str(DATEN_DIR) if DATEN_DIR.exists() else os.getcwd()).percent
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
if pct >= DISK_ROT_PCT:
|
if pct >= DISK_ROT_PCT:
|
||||||
@@ -433,8 +441,31 @@ def pruefe_festgehalten() -> list[Befund]:
|
|||||||
for p in update_verlauf.festgehalten()]
|
for p in update_verlauf.festgehalten()]
|
||||||
|
|
||||||
|
|
||||||
PRUEFUNGEN = (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte,
|
def pruefe_partner() -> list[Befund]:
|
||||||
pruefe_festgehalten)
|
"""Die andere Instanz (User-Entscheid 24.09.2026): Box und Homelab prüfen sich gegenseitig —
|
||||||
|
fällt eine aus, meldet die andere es."""
|
||||||
|
stand = partner.status(frisch=True)
|
||||||
|
if not stand.get("eingerichtet") or stand.get("erreichbar"):
|
||||||
|
return []
|
||||||
|
grund = stand.get("fehler") or "nicht erreichbar"
|
||||||
|
if ROLLE == "homelab":
|
||||||
|
text = f"Die KI-Box ({stand['url']}) ist {grund}. Lucy, die Modelle und der Box-Wart sind so lange weg."
|
||||||
|
else:
|
||||||
|
text = (f"Der Homelab-Teil auf dem Proxmox-PC ({stand['url']}) ist {grund}. "
|
||||||
|
"Updates im Homelab lassen sich so lange nicht anstoßen.")
|
||||||
|
return [Befund(id="partner", stufe="rot", titel=f"{stand['name']} antwortet nicht", text=text,
|
||||||
|
quelle="partner", nach_takten=PARTNER_TAKTE)]
|
||||||
|
|
||||||
|
|
||||||
|
# Was jede Rolle prüft. Die KI-Box kennt Dienste, Timer, Hermes und ihren Kern; der Homelab-Teil
|
||||||
|
# bekommt seine Prüfungen mit den Homelab-Bausteinen.
|
||||||
|
PRUEFUNGEN = {
|
||||||
|
"box": (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte,
|
||||||
|
pruefe_festgehalten, pruefe_partner),
|
||||||
|
"homelab": (pruefe_platte, pruefe_partner),
|
||||||
|
}[ROLLE]
|
||||||
|
BETREFF_PROBLEM, BETREFF_OK = {"box": ("[Box-Problem]", "[Box wieder ok]"),
|
||||||
|
"homelab": ("[Homelab-Problem]", "[Homelab wieder ok]")}[ROLLE]
|
||||||
|
|
||||||
|
|
||||||
# --- Zustand, Takt, Selbstreparatur -----------------------------------------------
|
# --- Zustand, Takt, Selbstreparatur -----------------------------------------------
|
||||||
@@ -525,7 +556,7 @@ def takt() -> None:
|
|||||||
k["takte"] += 1
|
k["takte"] += 1
|
||||||
if not update and b.auto:
|
if not update and b.auto:
|
||||||
_selbst_beheben(b, jetzt)
|
_selbst_beheben(b, jetzt)
|
||||||
if not (b.sofort or k["takte"] >= FAIL_AFTER):
|
if not (b.sofort or k["takte"] >= (b.nach_takten or FAIL_AFTER)):
|
||||||
continue
|
continue
|
||||||
h = hinweise.get(b.id)
|
h = hinweise.get(b.id)
|
||||||
neu = h is None
|
neu = h is None
|
||||||
@@ -537,7 +568,7 @@ def takt() -> None:
|
|||||||
_verlauf("neu", b.id, b.titel)
|
_verlauf("neu", b.id, b.titel)
|
||||||
if b.stufe == "rot" and (neu or jetzt - h.get("gemeldet", 0.0) >= REMIND_S):
|
if b.stufe == "rot" and (neu or jetzt - h.get("gemeldet", 0.0) >= REMIND_S):
|
||||||
vorsatz = "" if neu else "Immer noch: "
|
vorsatz = "" if neu else "Immer noch: "
|
||||||
_telegram("[Box-Problem]", f"{vorsatz}{b.titel}. {b.text}")
|
_telegram(BETREFF_PROBLEM, f"{vorsatz}{b.titel}. {b.text}")
|
||||||
h["gemeldet"] = jetzt
|
h["gemeldet"] = jetzt
|
||||||
|
|
||||||
# Nicht mehr festgestellt → erledigt. Während eines Updates bleiben Dienst- und
|
# Nicht mehr festgestellt → erledigt. Während eines Updates bleiben Dienst- und
|
||||||
@@ -550,7 +581,7 @@ def takt() -> None:
|
|||||||
h = hinweise.pop(hid)
|
h = hinweise.pop(hid)
|
||||||
_verlauf("erledigt", hid, h.get("titel", hid))
|
_verlauf("erledigt", hid, h.get("titel", hid))
|
||||||
if h.get("stufe") == "rot" and h.get("gemeldet"):
|
if h.get("stufe") == "rot" and h.get("gemeldet"):
|
||||||
_telegram("[Box wieder ok]", f"Erledigt: {h.get('titel', hid)}.")
|
_telegram(BETREFF_OK, f"Erledigt: {h.get('titel', hid)}.")
|
||||||
for kid in list(kandidaten):
|
for kid in list(kandidaten):
|
||||||
if kid not in aktuell and not (update and kid.startswith(("dienst:", "kern:"))):
|
if kid not in aktuell and not (update and kid.startswith(("dienst:", "kern:"))):
|
||||||
kandidaten.pop(kid)
|
kandidaten.pop(kid)
|
||||||
|
|||||||
@@ -7,16 +7,15 @@ Anfragen). Alles nur lesend.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
from kern.zeit import LOCAL_TZ
|
||||||
|
|
||||||
from services import modell_nutzung, waechter
|
from services import modell_nutzung, waechter
|
||||||
|
|
||||||
TIMER = {"mc2-backup.timer": "Sicherung", "mc2-radar.timer": "Modell-Radar",
|
TIMER = {"mc2-backup.timer": "Sicherung", "mc2-radar.timer": "Modell-Radar",
|
||||||
"mc2-morgenmeldung.timer": "Morgenmeldung", "mc2-autoupdate.timer": "Updates am Sonntag"}
|
"mc2-morgenmeldung.timer": "Morgenmeldung", "mc2-autoupdate.timer": "Updates am Sonntag"}
|
||||||
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_iso(wert) -> datetime | None:
|
def _parse_iso(wert) -> datetime | None:
|
||||||
|
|||||||
+3
-1
@@ -31,6 +31,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from config import CONFIG_PATH
|
from config import CONFIG_PATH
|
||||||
|
from kern.einstellungen import einstellungen
|
||||||
from services import waechter, warmer
|
from services import waechter, warmer
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -61,7 +62,8 @@ async def config_watch() -> None:
|
|||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
tasks: list[asyncio.Task] = []
|
tasks: list[asyncio.Task] = []
|
||||||
if warmer.ENABLED:
|
# Warm-Set und llama-swap gibt es nur auf der KI-Box; der Homelab-Teil hat nur den Wächter.
|
||||||
|
if einstellungen().rolle == "box" and warmer.ENABLED:
|
||||||
tasks.append(asyncio.create_task(warmer.rewarm_loop()))
|
tasks.append(asyncio.create_task(warmer.rewarm_loop()))
|
||||||
tasks.append(asyncio.create_task(config_watch()))
|
tasks.append(asyncio.create_task(config_watch()))
|
||||||
log.info("Re-Warm-Wächter aktiv (Intervall %ss, Config-Watch %ss)",
|
log.info("Re-Warm-Wächter aktiv (Intervall %ss, Config-Watch %ss)",
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ def umgebung(tmp_path):
|
|||||||
"MC_NIGHT_QUEUE": (tmp_path / "night-queue.txt").as_posix(),
|
"MC_NIGHT_QUEUE": (tmp_path / "night-queue.txt").as_posix(),
|
||||||
"MC_NOTIFY_HERMES": stub.as_posix(),
|
"MC_NOTIFY_HERMES": stub.as_posix(),
|
||||||
"MC_NOTIFY_NO_ANNOUNCE": "1",
|
"MC_NOTIFY_NO_ANNOUNCE": "1",
|
||||||
|
"MC_NOTIFY_OHNE_WALL": "1",
|
||||||
|
# Auf der Box läge unter ~/.hermes/.env das echte Bot-Token — der Zweitweg darf in Tests
|
||||||
|
# nie dorthin greifen, sonst schickt der Deploy-Testlauf echte Telegram-Nachrichten.
|
||||||
|
"MC_TELEGRAM_ENV": (tmp_path / "keine.env").as_posix(),
|
||||||
|
"MC_TELEGRAM_API": "http://127.0.0.1:9",
|
||||||
"STUB_PROTOKOLL": protokoll.as_posix(),
|
"STUB_PROTOKOLL": protokoll.as_posix(),
|
||||||
"MC_MORGENMELDUNG_PAUSE": "0",
|
"MC_MORGENMELDUNG_PAUSE": "0",
|
||||||
}
|
}
|
||||||
@@ -118,3 +123,88 @@ def test_morgenmeldung_verliert_nichts_wenn_telegram_klemmt(umgebung):
|
|||||||
assert "2 Meldungen" in gesendet
|
assert "2 Meldungen" in gesendet
|
||||||
assert gesendet.index("Erste Nacht") < gesendet.index("Zweite Nacht")
|
assert gesendet.index("Erste Nacht") < gesendet.index("Zweite Nacht")
|
||||||
assert not (tmp / "night-queue.txt.senden").exists()
|
assert not (tmp / "night-queue.txt.senden").exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Zweitweg direkt an die Bot-API (seit 24.09.2026) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def bot_api():
|
||||||
|
"""Ein Ersatz für api.telegram.org, der mitschreibt, was ankommt."""
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
empfangen: list[dict] = []
|
||||||
|
antwort = {"code": 200}
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def do_POST(self):
|
||||||
|
laenge = int(self.headers.get("Content-Length", 0))
|
||||||
|
felder = parse_qs(self.rfile.read(laenge).decode("utf-8"))
|
||||||
|
empfangen.append({"pfad": self.path, **{k: v[0] for k, v in felder.items()}})
|
||||||
|
self.send_response(antwort["code"])
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"ok": true}')
|
||||||
|
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||||
|
yield f"http://127.0.0.1:{server.server_port}", empfangen, antwort
|
||||||
|
server.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def _env_datei(tmp: Path, inhalt: str) -> str:
|
||||||
|
datei = tmp / "hermes.env"
|
||||||
|
datei.write_text(inhalt, encoding="utf-8", newline="\n")
|
||||||
|
return datei.as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
def test_zweitweg_springt_ein_wenn_hermes_scheitert(umgebung, bot_api):
|
||||||
|
tmp, env, _ = umgebung
|
||||||
|
url, empfangen, _ = bot_api
|
||||||
|
env = {**env, "STUB_EXIT": "1", "MC_TELEGRAM_API": url, "MC_TELEGRAM_ENV": _env_datei(
|
||||||
|
tmp, 'OPENAI_API_KEY=egal\nexport TELEGRAM_BOT_TOKEN="123:abc"\nTELEGRAM_HOME_CHANNEL=4711\n'
|
||||||
|
"TELEGRAM_HOME_CHANNEL_THREAD_ID=\n")}
|
||||||
|
lauf = _notify(env, "-s", "[Alarm]", "Hermes steht", stunde="12")
|
||||||
|
assert lauf.returncode == 0
|
||||||
|
assert empfangen == [{"pfad": "/bot123:abc/sendMessage", "chat_id": "4711",
|
||||||
|
"text": "[Alarm]\nHermes steht"}]
|
||||||
|
assert "OK telegram direkt: Hermes steht" in (tmp / "notify.log").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_zweitweg_nimmt_den_thread_mit(umgebung, bot_api):
|
||||||
|
tmp, env, _ = umgebung
|
||||||
|
url, empfangen, _ = bot_api
|
||||||
|
env = {**env, "STUB_EXIT": "1", "MC_TELEGRAM_API": url, "MC_TELEGRAM_ENV": _env_datei(
|
||||||
|
tmp, "TELEGRAM_BOT_TOKEN='9:x'\r\nTELEGRAM_HOME_CHANNEL=-100\r\nTELEGRAM_HOME_CHANNEL_THREAD_ID=7\r\n")}
|
||||||
|
assert _notify(env, "Ohne Betreff", stunde="12").returncode == 0
|
||||||
|
assert empfangen == [{"pfad": "/bot9:x/sendMessage", "chat_id": "-100", "text": "Ohne Betreff",
|
||||||
|
"message_thread_id": "7"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_hermes_zuerst_zweitweg_bleibt_still(umgebung, bot_api):
|
||||||
|
tmp, env, protokoll = umgebung
|
||||||
|
url, empfangen, _ = bot_api
|
||||||
|
env = {**env, "MC_TELEGRAM_API": url,
|
||||||
|
"MC_TELEGRAM_ENV": _env_datei(tmp, "TELEGRAM_BOT_TOKEN=1:a\nTELEGRAM_HOME_CHANNEL=1\n")}
|
||||||
|
assert _notify(env, "Normal", stunde="12").returncode == 0
|
||||||
|
assert "Normal" in _gesendet(protokoll)
|
||||||
|
assert empfangen == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("inhalt, code", [
|
||||||
|
("", 200), # keine Zugangsdaten
|
||||||
|
("TELEGRAM_BOT_TOKEN=1:a\n", 200), # Chat fehlt
|
||||||
|
("TELEGRAM_BOT_TOKEN=1:a\nTELEGRAM_HOME_CHANNEL=1\n", 500), # Telegram lehnt ab
|
||||||
|
])
|
||||||
|
def test_letzter_ausweg_wenn_auch_der_zweitweg_scheitert(umgebung, bot_api, inhalt, code):
|
||||||
|
tmp, env, _ = umgebung
|
||||||
|
url, _, antwort = bot_api
|
||||||
|
antwort["code"] = code
|
||||||
|
env = {**env, "STUB_EXIT": "1", "MC_NOTIFY_STRICT": "1", "MC_TELEGRAM_API": url,
|
||||||
|
"MC_TELEGRAM_ENV": _env_datei(tmp, inhalt)}
|
||||||
|
lauf = _notify(env, "-s", "[Test]", "Geht nirgends hin", stunde="12")
|
||||||
|
assert lauf.returncode == 1
|
||||||
|
assert "FALLBACK (telegram fehlgeschlagen" in (tmp / "notify.log").read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""Zwei Instanzen, eine Oberfläche (24.09.2026): Partner-Lebenszeichen, Durchreiche, Wächter, Rollen."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from kern import einstellungen as einstellungen_mod
|
||||||
|
from kern import partner
|
||||||
|
from routers import partner as partner_router
|
||||||
|
from services import waechter
|
||||||
|
|
||||||
|
BACKEND = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def partner_server():
|
||||||
|
"""Eine Partner-Instanz zum Anfassen: /api/health antwortet, alles andere wird mitgeschrieben."""
|
||||||
|
empfangen: list[dict] = []
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def _antworte(self, code: int, daten: dict) -> None:
|
||||||
|
roh = json.dumps(daten).encode()
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(roh)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(roh)
|
||||||
|
|
||||||
|
def _merke(self) -> None:
|
||||||
|
laenge = int(self.headers.get("Content-Length") or 0)
|
||||||
|
empfangen.append({"methode": self.command, "pfad": self.path,
|
||||||
|
"koerper": self.rfile.read(laenge).decode() if laenge else "",
|
||||||
|
"von": self.headers.get("X-MC-Von")})
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == "/api/health":
|
||||||
|
self._antworte(200, {"status": "ok", "version": "t", "rolle": "homelab", "instanz": "Homelab"})
|
||||||
|
return
|
||||||
|
self._merke()
|
||||||
|
self._antworte(200, {"pfad": self.path})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
self._merke()
|
||||||
|
self._antworte(202, {"angenommen": True})
|
||||||
|
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||||
|
yield f"http://127.0.0.1:{server.server_port}", empfangen
|
||||||
|
server.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mit_partner(monkeypatch):
|
||||||
|
"""Setzt MC_PARTNER_URL und leert die Caches davor und danach."""
|
||||||
|
def setzen(url: str) -> None:
|
||||||
|
monkeypatch.setenv("MC_PARTNER_URL", url)
|
||||||
|
einstellungen_mod.einstellungen.cache_clear()
|
||||||
|
partner._cache.update(ts=0.0, daten=None)
|
||||||
|
|
||||||
|
yield setzen
|
||||||
|
monkeypatch.delenv("MC_PARTNER_URL", raising=False)
|
||||||
|
einstellungen_mod.einstellungen.cache_clear()
|
||||||
|
partner._cache.update(ts=0.0, daten=None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_partner_nichts_eingerichtet(mit_partner):
|
||||||
|
mit_partner("")
|
||||||
|
assert partner.status() == {"eingerichtet": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_partner_antwortet(mit_partner, partner_server):
|
||||||
|
url, _ = partner_server
|
||||||
|
mit_partner(url)
|
||||||
|
stand = partner.status()
|
||||||
|
assert stand["erreichbar"] is True and stand["rolle"] == "homelab" and stand["name"] == "Homelab"
|
||||||
|
|
||||||
|
|
||||||
|
def test_partner_weg(mit_partner):
|
||||||
|
mit_partner("http://127.0.0.1:9")
|
||||||
|
stand = partner.status()
|
||||||
|
assert stand["erreichbar"] is False and stand["fehler"] == "nicht erreichbar"
|
||||||
|
|
||||||
|
|
||||||
|
def _client() -> TestClient:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(partner_router.router)
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_durchreiche_get_und_post(mit_partner, partner_server):
|
||||||
|
url, empfangen = partner_server
|
||||||
|
mit_partner(url)
|
||||||
|
c = _client()
|
||||||
|
r = c.get("/api/partner/homelab/ziele", params={"frisch": "1"})
|
||||||
|
assert r.status_code == 200 and r.json() == {"pfad": "/api/homelab/ziele?frisch=1"}
|
||||||
|
r = c.post("/api/partner/homelab/update", json={"ziel": "ct:101"})
|
||||||
|
assert r.status_code == 202
|
||||||
|
letzte = empfangen[-1]
|
||||||
|
assert (letzte["methode"], letzte["pfad"], letzte["von"]) == ("POST", "/api/homelab/update", "Box-Wart")
|
||||||
|
assert json.loads(letzte["koerper"]) == {"ziel": "ct:101"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("pfad", ["partner/health", "partner", "stream"])
|
||||||
|
def test_durchreiche_sperrt_schleifen_und_strom(mit_partner, partner_server, pfad):
|
||||||
|
url, empfangen = partner_server
|
||||||
|
mit_partner(url)
|
||||||
|
assert _client().get(f"/api/partner/{pfad}").status_code == 404
|
||||||
|
assert empfangen == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_durchreiche_ohne_partner_und_bei_ausfall(mit_partner):
|
||||||
|
mit_partner("")
|
||||||
|
assert _client().get("/api/partner/health").status_code == 404
|
||||||
|
mit_partner("http://127.0.0.1:9")
|
||||||
|
r = _client().get("/api/partner/health")
|
||||||
|
assert r.status_code == 502 and "nicht erreichbar" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_waechter_meldet_den_partner_erst_nach_geduld(monkeypatch, tmp_path):
|
||||||
|
"""Ein Neustart der anderen Instanz (Sonntags-Update) ist kein Alarm; erst nach PARTNER_TAKTE."""
|
||||||
|
monkeypatch.setattr(waechter, "STORE_PATH", tmp_path / "waechter.json")
|
||||||
|
monkeypatch.setattr(waechter, "_stand", {"hinweise": {}, "kandidaten": {}, "verlauf": [], "auto": {}, "stand": 0.0})
|
||||||
|
monkeypatch.setattr(waechter, "_update_laeuft", lambda: False)
|
||||||
|
monkeypatch.setattr(waechter, "PRUEFUNGEN", (waechter.pruefe_partner,))
|
||||||
|
monkeypatch.setattr(waechter, "PARTNER_TAKTE", 3)
|
||||||
|
meldungen: list[str] = []
|
||||||
|
monkeypatch.setattr(waechter, "_telegram", lambda betreff, text: meldungen.append(text))
|
||||||
|
erreichbar = [False]
|
||||||
|
monkeypatch.setattr(partner, "status", lambda frisch=False: {
|
||||||
|
"eingerichtet": True, "name": "Homelab", "url": "http://pve:9001",
|
||||||
|
"erreichbar": erreichbar[0], "fehler": None if erreichbar[0] else "nicht erreichbar"})
|
||||||
|
|
||||||
|
waechter.takt()
|
||||||
|
waechter.takt()
|
||||||
|
assert waechter._stand["hinweise"] == {} and meldungen == []
|
||||||
|
waechter.takt()
|
||||||
|
assert waechter._stand["hinweise"]["partner"]["titel"] == "Homelab antwortet nicht"
|
||||||
|
assert len(meldungen) == 1 and "Proxmox-PC (http://pve:9001) ist nicht erreichbar" in meldungen[0]
|
||||||
|
|
||||||
|
erreichbar[0] = True
|
||||||
|
waechter.takt()
|
||||||
|
assert waechter._stand["hinweise"] == {}
|
||||||
|
assert meldungen[-1] == "Erledigt: Homelab antwortet nicht."
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_partner_kein_befund(monkeypatch):
|
||||||
|
monkeypatch.setattr(partner, "status", lambda frisch=False: {"eingerichtet": False})
|
||||||
|
assert waechter.pruefe_partner() == []
|
||||||
|
|
||||||
|
|
||||||
|
def _starte(rolle: str, tmp_path: Path, code: str) -> dict:
|
||||||
|
"""Die App frisch in einem eigenen Interpreter laden — die Rolle wird beim Import festgelegt."""
|
||||||
|
env = {**os.environ, "MC_ROLLE": rolle, "MC_MODELS_DIR": str(tmp_path), "MC_DATEN_DIR": str(tmp_path),
|
||||||
|
"MC_PARTNER_URL": ""}
|
||||||
|
lauf = subprocess.run([sys.executable, "-c", code], cwd=BACKEND, env=env, capture_output=True, text=True,
|
||||||
|
timeout=120)
|
||||||
|
assert lauf.returncode == 0, lauf.stderr[-2000:]
|
||||||
|
return json.loads(lauf.stdout.strip().splitlines()[-1])
|
||||||
|
|
||||||
|
|
||||||
|
_PROBE = """
|
||||||
|
import json, sys
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
import app
|
||||||
|
pfade = sorted(app.app.openapi()["paths"])
|
||||||
|
gesund = TestClient(app.app).get("/api/health").json()
|
||||||
|
box_module = [m for m in ("services.llamaswap", "services.maintenance", "routers.boxwart") if m in sys.modules]
|
||||||
|
print(json.dumps({"pfade": pfade, "gesund": gesund, "box_module": box_module}))
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_homelab_rolle_laedt_nichts_von_der_box(tmp_path):
|
||||||
|
ergebnis = _starte("homelab", tmp_path, _PROBE)
|
||||||
|
assert ergebnis["gesund"] == {"status": "ok", "version": ergebnis["gesund"]["version"],
|
||||||
|
"rolle": "homelab", "instanz": "Homelab"}
|
||||||
|
assert "/api/start" not in ergebnis["pfade"] and "/api/partner" in ergebnis["pfade"]
|
||||||
|
assert ergebnis["box_module"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_box_rolle_bleibt_wie_sie_war(tmp_path):
|
||||||
|
ergebnis = _starte("box", tmp_path, _PROBE.replace('.get("/api/health").json()', '.get("/api/partner").json()'))
|
||||||
|
assert {"/api/start", "/api/health", "/api/partner"} <= set(ergebnis["pfade"])
|
||||||
|
assert ergebnis["gesund"] == {"eingerichtet": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekannte_rolle_bricht_ab(monkeypatch):
|
||||||
|
monkeypatch.setenv("MC_ROLLE", "keller")
|
||||||
|
einstellungen_mod.einstellungen.cache_clear()
|
||||||
|
try:
|
||||||
|
with pytest.raises(ValueError, match="MC_ROLLE"):
|
||||||
|
einstellungen_mod.einstellungen()
|
||||||
|
finally:
|
||||||
|
monkeypatch.delenv("MC_ROLLE")
|
||||||
|
einstellungen_mod.einstellungen.cache_clear()
|
||||||
@@ -146,3 +146,11 @@ def test_waechter_meldet_festgehaltene_bausteine(tmp_path, monkeypatch):
|
|||||||
assert (b.id, b.stufe, b.sofort) == ("pin:engine", "gelb", True)
|
assert (b.id, b.stufe, b.sofort) == ("pin:engine", "gelb", True)
|
||||||
assert "Seit 13.09. auf 10819 festgehalten" in b.text
|
assert "Seit 13.09. auf 10819 festgehalten" in b.text
|
||||||
assert b.aktionen[0]["id"] == "freigeben" and b.aktionen[0]["baustein"] == "engine"
|
assert b.aktionen[0]["id"] == "freigeben" and b.aktionen[0]["baustein"] == "engine"
|
||||||
|
|
||||||
|
|
||||||
|
def test_zweitweg_zaehlt_wie_telegram():
|
||||||
|
"""notify.sh schreibt „OK telegram direkt“, wenn Hermes stand und die Bot-API direkt ansprang."""
|
||||||
|
text = ("2026-09-27 04:40:00 OK telegram direkt: Router (llama-swap) aktualisiert: 256 → 257. "
|
||||||
|
"Stack-Check grün, alles läuft.\n")
|
||||||
|
laeufe = update_verlauf.laeufe_aus(text)
|
||||||
|
assert [(z["baustein"], z["ergebnis"]) for z in laeufe[0]["zeilen"]] == [("llama-swap", "eingespielt")]
|
||||||
|
|||||||
+44
-4
@@ -1,7 +1,9 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Meldeweg der Box (Autonomie E1): eine Nachricht an den User schicken.
|
# Meldeweg der Box (Autonomie E1): eine Nachricht an den User schicken.
|
||||||
# Primär: hermes send → Telegram (nutzt Gateway-Credentials, kein LLM nötig).
|
# Primär: hermes send → Telegram (nutzt Gateway-Credentials, kein LLM nötig).
|
||||||
# Fallback: Logfile + wall (falls Telegram/hermes nicht erreichbar).
|
# Zweitweg (seit 24.09.2026): direkt an die Telegram-Bot-API mit den Zugangsdaten aus Hermes' .env —
|
||||||
|
# greift, wenn Hermes steht oder fehlt (die Homelab-Instanz hat gar kein Hermes).
|
||||||
|
# Letzter Ausweg: Logfile + wall.
|
||||||
#
|
#
|
||||||
# Nutzung: notify.sh "Nachricht" (oder via stdin: echo msg | notify.sh)
|
# Nutzung: notify.sh "Nachricht" (oder via stdin: echo msg | notify.sh)
|
||||||
# notify.sh -s "[Update]" "Text" (Betreffzeile voranstellen)
|
# notify.sh -s "[Update]" "Text" (Betreffzeile voranstellen)
|
||||||
@@ -15,12 +17,16 @@
|
|||||||
#
|
#
|
||||||
# Exit 0 = zugestellt (Telegram, Fallback-Log oder Nacht-Warteschlange).
|
# Exit 0 = zugestellt (Telegram, Fallback-Log oder Nacht-Warteschlange).
|
||||||
# MC_NOTIFY_STRICT=1: Exit 1, wenn Telegram scheitert (für morgenmeldung.sh, damit nichts verloren geht).
|
# MC_NOTIFY_STRICT=1: Exit 1, wenn Telegram scheitert (für morgenmeldung.sh, damit nichts verloren geht).
|
||||||
# Test-Schalter: MC_NOTIFY_STUNDE (Stunde vortäuschen), MC_NIGHT_QUEUE, MC_NOTIFY_HERMES (hermes-Befehl).
|
# Test-Schalter: MC_NOTIFY_STUNDE (Stunde vortäuschen), MC_NIGHT_QUEUE, MC_NOTIFY_HERMES (hermes-Befehl),
|
||||||
|
# MC_TELEGRAM_ENV (Datei mit TELEGRAM_BOT_TOKEN/TELEGRAM_HOME_CHANNEL), MC_TELEGRAM_API (Bot-API-Adresse),
|
||||||
|
# MC_NOTIFY_OHNE_WALL=1 (kein wall im letzten Ausweg).
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
LOG="${MC_NOTIFY_LOG:-$HOME/mc2-notify.log}"
|
LOG="${MC_NOTIFY_LOG:-$HOME/mc2-notify.log}"
|
||||||
NIGHT_QUEUE="${MC_NIGHT_QUEUE:-$HOME/.hermes/night-queue.txt}"
|
NIGHT_QUEUE="${MC_NIGHT_QUEUE:-$HOME/.hermes/night-queue.txt}"
|
||||||
HERMES_BIN="${MC_NOTIFY_HERMES:-hermes}"
|
HERMES_BIN="${MC_NOTIFY_HERMES:-hermes}"
|
||||||
|
TG_ENV="${MC_TELEGRAM_ENV:-$HOME/.hermes/.env}"
|
||||||
|
TG_API="${MC_TELEGRAM_API:-https://api.telegram.org}"
|
||||||
SUBJECT=""
|
SUBJECT=""
|
||||||
DRINGEND=0
|
DRINGEND=0
|
||||||
|
|
||||||
@@ -85,9 +91,43 @@ if [ $? -eq 0 ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Fallback: Logfile + wall — Meldung darf nie verloren gehen.
|
# Zweitweg: direkt an die Bot-API. Das Token geht über --config auf stdin an curl, damit es nicht
|
||||||
|
# in der Prozessliste steht.
|
||||||
|
env_wert() {
|
||||||
|
local wert
|
||||||
|
wert="$(grep -E "^(export +)?$1=" "$TG_ENV" 2>/dev/null | tail -n 1)"
|
||||||
|
wert="${wert#*=}"
|
||||||
|
wert="${wert%$'\r'}"
|
||||||
|
wert="${wert%\"}"; wert="${wert#\"}"
|
||||||
|
wert="${wert%\'}"; wert="${wert#\'}"
|
||||||
|
printf '%s' "$wert"
|
||||||
|
}
|
||||||
|
telegram_direkt() {
|
||||||
|
local token chat thread text
|
||||||
|
[ -r "$TG_ENV" ] || return 1
|
||||||
|
token="$(env_wert TELEGRAM_BOT_TOKEN)"
|
||||||
|
chat="$(env_wert TELEGRAM_HOME_CHANNEL)"
|
||||||
|
thread="$(env_wert TELEGRAM_HOME_CHANNEL_THREAD_ID)"
|
||||||
|
[ -n "$token" ] && [ -n "$chat" ] || return 1
|
||||||
|
text="$MSG"
|
||||||
|
[ -n "$SUBJECT" ] && text="$SUBJECT
|
||||||
|
$MSG"
|
||||||
|
printf 'url = "%s/bot%s/sendMessage"\n' "$TG_API" "$token" \
|
||||||
|
| curl -sf -m 15 -o /dev/null --config - \
|
||||||
|
--data-urlencode "chat_id=$chat" --data-urlencode "text=$text" \
|
||||||
|
${thread:+--data-urlencode "message_thread_id=$thread"}
|
||||||
|
}
|
||||||
|
if telegram_direkt; then
|
||||||
|
# „OK telegram direkt“: services/update_verlauf.py erkennt beide Wege.
|
||||||
|
echo "$TS OK telegram direkt: $MSG" >> "$LOG"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Letzter Ausweg: Logfile + wall — Meldung darf nie verloren gehen.
|
||||||
echo "$TS FALLBACK (telegram fehlgeschlagen: $SEND_OUT): $MSG" >> "$LOG"
|
echo "$TS FALLBACK (telegram fehlgeschlagen: $SEND_OUT): $MSG" >> "$LOG"
|
||||||
printf '%s\n' "MC2-Meldung: ${SUBJECT:+$SUBJECT }$MSG" | wall 2>/dev/null || true
|
if [ "${MC_NOTIFY_OHNE_WALL:-0}" != "1" ]; then
|
||||||
|
printf '%s\n' "MC2-Meldung: ${SUBJECT:+$SUBJECT }$MSG" | wall 2>/dev/null || true
|
||||||
|
fi
|
||||||
echo "WARN: Telegram fehlgeschlagen, in $LOG protokolliert" >&2
|
echo "WARN: Telegram fehlgeschlagen, in $LOG protokolliert" >&2
|
||||||
[ "${MC_NOTIFY_STRICT:-0}" = "1" ] && exit 1
|
[ "${MC_NOTIFY_STRICT:-0}" = "1" ] && exit 1
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
Reference in New Issue
Block a user