fan-control: presence-aware IPMI fan curve for the R730 PVE host
The iDRAC stock curve runs the CPU at ~72°C on the 7080 RPM floor even under load (optimises for quiet, not cool). Add a bash daemon + systemd unit that drives the chassis fans from CPU temp on two curves, picked by garage occupancy (the server is in the garage): COOL when empty (measured ~58-65°C under load), QUIET near the silent floor when the ha-sofia garage door shows someone is there (open, or <15min since last activity). Manual fan mode is backstopped: bash EXIT trap + systemd ExecStopPost hand fans back to Dell auto on stop/crash; CPU>=83°C or repeated IPMI failures do the same. Pushgateway metrics (job=fan_control). 36 unit tests cover the pure curve/hysteresis/presence/parse logic; DRY_RUN + RUN_ONCE for integration checks. Deployed and verified on 192.168.1.127 (CPU 70->58°C in cool mode, hysteresis stepping confirmed). Design: docs/plans/2026-06-04-pve-fan-control-design.md Runbook: docs/runbooks/fan-control.md [ci skip] Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c6f27fa172
commit
90ad6b9125
60 changed files with 640 additions and 9563 deletions
|
|
@ -1,125 +0,0 @@
|
|||
"""Streamed.pk extractor - fetches F1/motorsport streams via public JSON API."""
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.extractors.base import BaseExtractor
|
||||
from backend.extractors.models import ExtractedStream
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Site renamed from streamed.su → streamed.pk in 2026; the .su domain
|
||||
# stopped resolving the API host (only the marketing page is left).
|
||||
BASE_URL = "https://streamed.pk"
|
||||
USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
|
||||
class StreamedExtractor(BaseExtractor):
|
||||
"""Extracts streams from Streamed.pk's public JSON API.
|
||||
|
||||
Uses two endpoints:
|
||||
- GET /api/matches/motor-sports → list of events with sources
|
||||
- GET /api/stream/{source}/{id} → embed URL for a specific source
|
||||
"""
|
||||
|
||||
@property
|
||||
def site_key(self) -> str:
|
||||
return "streamed"
|
||||
|
||||
@property
|
||||
def site_name(self) -> str:
|
||||
return "Streamed"
|
||||
|
||||
async def extract(self) -> list[ExtractedStream]:
|
||||
"""Fetch motorsport events and resolve embed URLs for each source."""
|
||||
streams: list[ExtractedStream] = []
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=15.0,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
|
||||
) as client:
|
||||
# Get motorsport events
|
||||
resp = await client.get(f"{BASE_URL}/api/matches/motor-sports")
|
||||
if resp.status_code != 200:
|
||||
logger.warning(
|
||||
"[streamed] Events API returned HTTP %d", resp.status_code
|
||||
)
|
||||
return []
|
||||
|
||||
events = resp.json()
|
||||
if not isinstance(events, list):
|
||||
logger.warning("[streamed] Unexpected events response type")
|
||||
return []
|
||||
|
||||
logger.info("[streamed] Found %d motorsport event(s)", len(events))
|
||||
|
||||
for event in events:
|
||||
title = event.get("title", "Unknown Event")
|
||||
sources = event.get("sources", [])
|
||||
if not sources:
|
||||
continue
|
||||
|
||||
for source_info in sources:
|
||||
source_name = source_info.get("source", "")
|
||||
source_id = source_info.get("id", "")
|
||||
if not source_name or not source_id:
|
||||
continue
|
||||
|
||||
try:
|
||||
stream_resp = await client.get(
|
||||
f"{BASE_URL}/api/stream/{source_name}/{source_id}"
|
||||
)
|
||||
if stream_resp.status_code != 200:
|
||||
continue
|
||||
|
||||
stream_data = stream_resp.json()
|
||||
if not isinstance(stream_data, list):
|
||||
stream_data = [stream_data]
|
||||
|
||||
for item in stream_data:
|
||||
embed_url = item.get("embedUrl", "")
|
||||
if not embed_url:
|
||||
continue
|
||||
|
||||
language = item.get("language", "")
|
||||
hd = item.get("hd", False)
|
||||
stream_no = item.get("streamNo", 1)
|
||||
|
||||
quality = "HD" if hd else "SD"
|
||||
stream_title = f"{title}"
|
||||
if language:
|
||||
stream_title += f" ({language})"
|
||||
if stream_no > 1:
|
||||
stream_title += f" #{stream_no}"
|
||||
|
||||
streams.append(
|
||||
ExtractedStream(
|
||||
url=embed_url,
|
||||
site_key=self.site_key,
|
||||
site_name=self.site_name,
|
||||
quality=quality,
|
||||
title=stream_title,
|
||||
stream_type="embed",
|
||||
embed_url=embed_url,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"[streamed] Failed to fetch stream for %s/%s",
|
||||
source_name,
|
||||
source_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception("[streamed] Failed to fetch events")
|
||||
|
||||
logger.info("[streamed] Extracted %d stream(s)", len(streams))
|
||||
return streams
|
||||
Loading…
Add table
Add a link
Reference in a new issue