first commit

This commit is contained in:
duffyduck
2026-08-22 20:34:42 +02:00
commit 9546b9a4c6
1586 changed files with 172520 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Miracast-Sender für Linux: Bildschirm auf einen wartenden Fernseher."""
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+121
View File
@@ -0,0 +1,121 @@
"""Bildformate des Wi-Fi-Display-Standards und die Auswahl daraus.
Der Fernseher meldet in einer Bitmaske, welche Auflösungen er beherrscht.
Wir suchen die beste heraus, die beide Seiten können, und melden genau diese
eine zurück.
"""
from dataclasses import dataclass
from typing import List, Optional
@dataclass(frozen=True)
class VideoFormat:
cea_bit: int
width: int
height: int
fps: int
def __str__(self) -> str:
return f"{self.width}x{self.height}@{self.fps}"
# CEA-Tabelle des Standards; die Position entspricht dem Bit in der Maske.
CEA = [
VideoFormat(0, 640, 480, 60),
VideoFormat(1, 720, 480, 60),
VideoFormat(2, 720, 480, 60), # interlaced
VideoFormat(3, 720, 576, 50),
VideoFormat(4, 720, 576, 50), # interlaced
VideoFormat(5, 1280, 720, 30),
VideoFormat(6, 1280, 720, 60),
VideoFormat(7, 1920, 1080, 30),
VideoFormat(8, 1920, 1080, 60),
VideoFormat(9, 1920, 1080, 60), # interlaced
VideoFormat(10, 1280, 720, 25),
VideoFormat(11, 1280, 720, 50),
VideoFormat(12, 1920, 1080, 25),
VideoFormat(13, 1920, 1080, 50),
VideoFormat(14, 1920, 1080, 50), # interlaced
VideoFormat(15, 1280, 720, 24),
VideoFormat(16, 1920, 1080, 24),
]
INTERLACED = {2, 4, 9, 14}
# Wunschreihenfolge: flüssig und scharf, ohne zu übertreiben.
PREFERENCE = [5, 7, 6, 11, 13, 10, 12, 15, 16, 8, 0, 1, 3]
@dataclass(frozen=True)
class SinkCapability:
profile: int
level: int
cea_mask: int
vesa_mask: int
hh_mask: int
def parse_video_formats(value: str) -> List[SinkCapability]:
"""Zerlegt den Wert von wfd_video_formats.
Aufbau: native, preferred-display-mode, dann je Codec elf Felder
(profile, level, CEA, VESA, HH, latency, min-slice, slice-enc,
frame-rate-control, max-hres, max-vres).
"""
parts = value.strip().split()
if len(parts) < 3:
return []
result = []
i = 2 # native und preferred-display-mode überspringen
while i + 4 < len(parts):
try:
capability = SinkCapability(
profile=int(parts[i], 16),
level=int(parts[i + 1], 16),
cea_mask=int(parts[i + 2], 16),
vesa_mask=int(parts[i + 3], 16),
hh_mask=int(parts[i + 4], 16),
)
except ValueError:
break
result.append(capability)
i += 11
return result
def choose(capabilities: List[SinkCapability], max_height: int = 1080) -> Optional[VideoFormat]:
"""Bestes gemeinsames Format, oder None wenn es keines gibt."""
mask = 0
for capability in capabilities:
mask |= capability.cea_mask
for bit in PREFERENCE:
if bit in INTERLACED or not (mask & (1 << bit)):
continue
candidate = CEA[bit]
if candidate.height > max_height:
continue
return candidate
return None
def build_selection(fmt: VideoFormat, profile: int, level: int) -> str:
"""Antwortwert mit genau einem gesetzten Bit - damit der Fernseher weiß,
was tatsächlich kommt."""
cea = 1 << fmt.cea_bit
return (
f"{0:02X} {0:02X} {profile:02X} {level:02X} "
f"{cea:08X} {0:08X} {0:08X} {0:02X} {0:04X} {0:04X} {0:02X} none none"
)
def parse_rtp_port(value: Optional[str]) -> int:
"""Liest aus wfd_client_rtp_ports den Port, auf dem der Fernseher lauscht."""
if not value:
return 0
for part in value.strip().split():
if part.isdigit():
port = int(part)
if 1 <= port <= 65535:
return port
return 0
+215
View File
@@ -0,0 +1,215 @@
"""Wi-Fi Direct und die Anmeldung als Wi-Fi-Display-Quelle.
Hier liegt der entscheidende Unterschied zur Android-Fassung: Über
wpa_supplicant lässt sich das WFD-Informationselement wirklich setzen. Damit
erkennt der Fernseher uns als Miracast-Quelle und baut die Steuerverbindung
von sich aus auf. Auf Android ist genau das Systemapps vorbehalten.
"""
import fcntl
import logging
import re
import shutil
import socket
import struct
import subprocess
import time
from dataclasses import dataclass
from typing import Dict, List, Optional
from .wpa import WpaClient, parse_key_values
log = logging.getLogger("wfd.p2p")
# WFD-Gerätetyp in den unteren zwei Bits des Informationsfeldes.
DEVICE_TYPE_SOURCE = 0b00
DEVICE_TYPE_PRIMARY_SINK = 0b01
SESSION_AVAILABLE = 0b01 << 4
@dataclass
class Peer:
address: str
name: str
is_sink: bool
session_available: bool
control_port: int
device_type: str = ""
def describe(self) -> str:
marks = []
if self.is_sink:
marks.append("Bildschirm")
if self.session_available:
marks.append("wartet auf Verbindung")
suffix = f" ({', '.join(marks)})" if marks else ""
return f"{self.name} [{self.address}]{suffix}"
@dataclass
class Group:
interface: str
role: str # "GO" oder "client"
go_address: str = ""
local_address: str = ""
def parse_wfd_dev_info(value: str) -> Dict[str, object]:
"""Zerlegt wfd_dev_info, z.B. 0x00111c440032.
Die ersten zwei Bytes sind das Informationsfeld, dann folgt der Port für
die Steuerverbindung und der maximale Durchsatz.
"""
text = value.strip()
if text.startswith("0x"):
text = text[2:]
if len(text) < 4:
return {}
info = int(text[0:4], 16)
port = int(text[4:8], 16) if len(text) >= 8 else 7236
device_type = info & 0b11
return {
"info": info,
"device_type": device_type,
"is_sink": device_type in (DEVICE_TYPE_PRIMARY_SINK, 0b10, 0b11),
"session_available": bool((info >> 4) & 0b11),
"control_port": port or 7236,
}
def interface_ip(interface: str) -> str:
"""Eigene IPv4-Adresse eines Interfaces, ohne externe Werkzeuge."""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
packed = struct.pack("256s", interface[:15].encode())
return socket.inet_ntoa(fcntl.ioctl(sock.fileno(), 0x8915, packed)[20:24])
except OSError:
return ""
def default_gateway(interface: str) -> str:
"""Gegenstelle der Gruppe - beim Wi-Fi-Direct-Client also der Fernseher."""
try:
with open("/proc/net/route", encoding="ascii") as handle:
for line in handle.readlines()[1:]:
fields = line.split()
if fields[0] != interface or fields[1] != "00000000":
continue
return socket.inet_ntoa(struct.pack("<L", int(fields[2], 16)))
except OSError:
pass
return ""
class P2pManager:
def __init__(self, client: WpaClient):
self.client = client
# ------------------------------------------------- Als Quelle anmelden
def enable_wfd_source(self, control_port: int = 7236, max_throughput: int = 50) -> bool:
"""Kündigt uns als Miracast-Quelle an, die eine Sitzung annehmen kann."""
if not self.client.ok("SET wifi_display 1"):
log.warning("wifi_display liess sich nicht einschalten")
return False
info = DEVICE_TYPE_SOURCE | SESSION_AVAILABLE
payload = f"{info:04x}{control_port:04x}{max_throughput:04x}"
# Subelement 0 ist die Geräteinformation, davor steht ihre Länge (6 Byte).
if not self.client.ok(f"WFD_SUBELEM_SET 0 0006{payload}"):
log.warning("WFD-Kennzeichen liess sich nicht setzen")
return False
log.info("Als Wi-Fi-Display-Quelle angemeldet (Port %s)", control_port)
return True
def disable_wfd(self):
self.client.ok("SET wifi_display 0")
# ------------------------------------------------------------- Suchen
def find(self, seconds: int = 30):
self.client.ok(f"P2P_FIND {seconds}")
def stop_find(self):
self.client.ok("P2P_STOP_FIND")
def peers(self) -> List[Peer]:
raw = self.client.request("P2P_PEERS")
result = []
for address in raw.split():
if not re.fullmatch(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", address):
continue
details = parse_key_values(self.client.request(f"P2P_PEER {address}"))
wfd = parse_wfd_dev_info(details.get("wfd_dev_info", ""))
result.append(Peer(
address=address,
name=details.get("device_name", address),
is_sink=bool(wfd.get("is_sink", False)),
session_available=bool(wfd.get("session_available", False)),
control_port=int(wfd.get("control_port", 7236)),
device_type=details.get("pri_dev_type", ""),
))
return result
# ---------------------------------------------------------- Verbinden
def connect(self, peer: Peer, go_intent: int = 0, method: str = "pbc") -> str:
"""Stellt die Direktverbindung her. go_intent=0 heißt: der Fernseher
soll die Gruppe führen - dann bekommen wir von ihm eine Adresse."""
command = f"P2P_CONNECT {peer.address} {method} go_intent={go_intent}"
answer = self.client.request(command, timeout=10)
log.info("P2P_CONNECT -> %s", answer)
return answer
def wait_for_group(self, timeout: float = 60.0) -> Optional[Group]:
"""Wartet auf den Gruppenstart und liefert Interface und Rolle."""
event = self.client.wait_for_event("P2P-GROUP-STARTED", timeout=timeout)
if not event:
return None
# Beispiel: P2P-GROUP-STARTED p2p-wlan0-0 client ssid="DIRECT-xy" ...
parts = event.split()
if len(parts) < 3:
return None
interface = parts[1]
role = "GO" if parts[2].upper() == "GO" else "client"
return Group(interface=interface, role=role)
def remove_group(self, interface: str = ""):
self.client.ok(f"P2P_GROUP_REMOVE {interface or '*'}")
def obtain_address(interface: str, timeout: int = 20) -> str:
"""Holt per DHCP eine Adresse in der Gruppe.
wpa_supplicant kümmert sich nicht um IP-Adressen; als Gruppen-Gast müssen
wir selbst fragen. Wir probieren die üblichen Clients der Reihe nach.
"""
existing = interface_ip(interface)
if existing:
return existing
candidates = [
(["dhclient", "-1", "-v", interface], 25),
(["udhcpc", "-i", interface, "-q", "-n", "-t", "6"], 20),
(["dhcpcd", "-4", "-t", str(timeout), interface], timeout + 5),
]
for command, limit in candidates:
if not shutil.which(command[0]):
continue
log.info("Hole Adresse mit %s", command[0])
try:
subprocess.run(command, timeout=limit, check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except (subprocess.TimeoutExpired, OSError) as error:
log.debug("%s erfolglos: %s", command[0], error)
address = interface_ip(interface)
if address:
return address
# Letzter Ausweg: die übliche Aufteilung einer Wi-Fi-Direct-Gruppe.
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
address = interface_ip(interface)
if address:
return address
time.sleep(0.5)
return ""
+414
View File
@@ -0,0 +1,414 @@
"""Der Miracast-Handshake als Quelle (Source).
Der Wi-Fi-Display-Standard schreibt die Schritte M1 bis M7 fest. Wichtig:
Die Steuerverbindung baut **der Fernseher zu uns** auf, nicht umgekehrt -
genauso macht es Android intern (RemoteDisplay.listen). Wir hören deshalb auf
Port 7236 und warten, dass er anruft. Die RTSP-Rollen bleiben davon unberührt:
Wir stellen M1, M3, M4 und M5, der Fernseher M2, M6 und M7.
"""
import logging
import socket
import threading
from dataclasses import dataclass, field
from typing import Callable, Dict, Optional
from . import formats
log = logging.getLogger("wfd.rtsp")
DEFAULT_PORTS = (7236, 8554)
@dataclass
class RtspMessage:
is_request: bool
method: str = ""
uri: str = ""
status_code: int = 0
status_text: str = ""
headers: Dict[str, str] = field(default_factory=dict)
body: str = ""
@property
def cseq(self) -> int:
try:
return int(self.headers.get("cseq", "0").strip())
except ValueError:
return 0
@property
def session(self) -> Optional[str]:
value = self.headers.get("session")
return value.split(";")[0].strip() if value else None
def param(self, name: str) -> Optional[str]:
"""Sucht einen wfd-Parameter im Nachrichtenrumpf."""
for line in self.body.splitlines():
if line.lower().startswith(name.lower() + ":"):
return line.split(":", 1)[1].strip()
return None
def read_message(stream) -> Optional[RtspMessage]:
"""Liest genau eine Nachricht; None, wenn die Gegenseite auflegt."""
lines = []
while True:
raw = stream.readline()
if not raw:
return None
text = raw.decode("utf-8", "replace").rstrip("\r\n")
if text == "":
if not lines:
continue
break
lines.append(text)
if len(lines) > 80:
return None
headers = {}
for line in lines[1:]:
if ":" in line:
key, value = line.split(":", 1)
headers[key.strip().lower()] = value.strip()
length = int(headers.get("content-length", "0") or 0)
body = stream.read(length).decode("utf-8", "replace") if length else ""
first = lines[0]
if first.startswith("RTSP/"):
parts = first.split(" ", 2)
return RtspMessage(
is_request=False,
status_code=int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0,
status_text=parts[2] if len(parts) > 2 else "",
headers=headers,
body=body,
)
parts = first.split(" ")
return RtspMessage(
is_request=True,
method=parts[0] if parts else "",
uri=parts[1] if len(parts) > 1 else "",
headers=headers,
body=body,
)
def build_request(method: str, uri: str, cseq: int, headers=None, body: str = "") -> bytes:
out = [f"{method} {uri} RTSP/1.0", f"CSeq: {cseq}"]
for key, value in (headers or {}).items():
out.append(f"{key}: {value}")
if body:
out.append("Content-Type: text/parameters")
out.append(f"Content-Length: {len(body.encode())}")
return ("\r\n".join(out) + "\r\n\r\n" + body).encode()
def build_response(cseq: int, headers=None, body: str = "", status: str = "200 OK") -> bytes:
out = [f"RTSP/1.0 {status}", f"CSeq: {cseq}"]
for key, value in (headers or {}).items():
out.append(f"{key}: {value}")
if body:
out.append("Content-Type: text/parameters")
out.append(f"Content-Length: {len(body.encode())}")
return ("\r\n".join(out) + "\r\n\r\n" + body).encode()
class SourceSession:
"""Führt den Handshake über eine bestehende Verbindung."""
def __init__(self, sock: socket.socket, local_address: str, max_height: int,
on_play: Callable[[str, int, formats.VideoFormat], None],
on_stopped: Callable[[str], None],
on_status: Callable[[str], None] = lambda text: None):
self.sock = sock
self.local_address = local_address
self.max_height = max_height
self.on_play = on_play
self.on_stopped = on_stopped
self.on_status = on_status
self.stream = sock.makefile("rwb")
self.peer = sock.getpeername()[0]
self.running = True
self._cseq = 1
self._pending = {} # CSeq -> Schrittname
self._options_answered = False
self._peer_options_seen = False
self._capabilities_requested = False
self._format: Optional[formats.VideoFormat] = None
self._sink_rtp_port = 0
self._session_id = "1"
self._lock = threading.Lock()
self.local_rtp_port = 0
# ------------------------------------------------------------- Ablauf
def run(self):
try:
# M1: Wir melden uns und fragen, was die Gegenseite kann.
self._send_request("OPTIONS", "*", {"Require": "org.wfa.wfd1.0"}, step="M1")
while self.running:
message = read_message(self.stream)
if message is None:
break
if message.is_request:
self._handle_request(message)
else:
self._handle_response(message)
self.on_stopped("Verbindung beendet")
except Exception as error: # noqa: BLE001 - alles melden, nichts verschlucken
if self.running:
self.on_stopped(f"Fehler: {error}")
finally:
self.running = False
try:
self.sock.close()
except OSError:
pass
def stop(self):
self.running = False
try:
self.sock.close()
except OSError:
pass
def keep_alive(self):
"""Lebenszeichen, sonst trennt der Fernseher nach etwa einer Minute."""
if self.running:
self._send_request("GET_PARAMETER", f"rtsp://{self.local_address}/wfd1.0")
# --------------------------------------------- Anfragen des Fernsehers
def _handle_request(self, message: RtspMessage):
method = message.method.upper()
log.info("<- %s %s", method, message.uri)
self.on_status(f"Fernseher: {method}")
if method == "OPTIONS":
# M2: Er fragt seinerseits, was wir können.
self._write(build_response(message.cseq, {
"Public": "org.wfa.wfd1.0, GET_PARAMETER, SET_PARAMETER, SETUP, PLAY, PAUSE, TEARDOWN"
}))
self._peer_options_seen = True
self._maybe_request_capabilities()
elif method == "SETUP":
# M6: Sitzung einrichten; hier nennt er den Port für das Bild.
transport = message.headers.get("transport", "")
for token in transport.split(";"):
if token.startswith("client_port="):
value = token.split("=", 1)[1].split("-")[0]
if value.isdigit():
self._sink_rtp_port = int(value)
self.on_status(f"Fernseher erwartet das Bild auf Port {self._sink_rtp_port}")
reply_transport = f"RTP/AVP/UDP;unicast;client_port={self._sink_rtp_port}"
if self.local_rtp_port:
reply_transport += f";server_port={self.local_rtp_port}"
self._write(build_response(message.cseq, {
"Session": f"{self._session_id};timeout=60",
"Transport": reply_transport,
}))
elif method == "PLAY":
# M7: Los geht's.
self._write(build_response(message.cseq, {"Session": f"{self._session_id};timeout=60"}))
if self._format and self._sink_rtp_port:
self.on_status("Wiedergabe angefordert - Bild geht raus")
self.on_play(self.peer, self._sink_rtp_port, self._format)
else:
self.on_status("PLAY kam, aber Format oder Port fehlen noch")
elif method == "TEARDOWN":
self._write(build_response(message.cseq, {"Session": self._session_id}))
self.running = False
self.on_stopped("Der Fernseher hat die Übertragung beendet")
elif method in ("GET_PARAMETER", "SET_PARAMETER", "PAUSE"):
self._write(build_response(message.cseq))
else:
self._write(build_response(message.cseq, status="501 Not Implemented"))
# ------------------------------------------ Antworten auf unsere Fragen
def _handle_response(self, message: RtspMessage):
step = self._pending.pop(message.cseq, None)
log.info("-> Antwort %s auf %s", message.status_code, step or f"CSeq {message.cseq}")
if not 200 <= message.status_code < 300:
self.running = False
self.on_stopped(f"Der Fernseher hat abgelehnt ({message.status_code} {message.status_text})")
return
if step == "M1":
self._options_answered = True
self._maybe_request_capabilities()
# Bleibt sein OPTIONS aus, fragen wir trotzdem weiter.
threading.Timer(1.5, lambda: self._maybe_request_capabilities(force=True)).start()
elif step == "M3":
self._negotiate(message)
elif step == "M4":
self._trigger_setup()
def _maybe_request_capabilities(self, force: bool = False):
with self._lock:
if self._capabilities_requested or not self._options_answered:
return
if not self._peer_options_seen and not force:
return
self._capabilities_requested = True
# M3: Fähigkeiten abfragen.
body = ("wfd_video_formats\r\n"
"wfd_audio_codecs\r\n"
"wfd_client_rtp_ports\r\n"
"wfd_content_protection\r\n")
self._send_request("GET_PARAMETER", f"rtsp://{self.local_address}/wfd1.0",
body=body, step="M3")
def _negotiate(self, message: RtspMessage):
"""M4: Format festlegen und zurückmelden."""
value = message.param("wfd_video_formats")
if not value:
self.running = False
self.on_stopped("Der Fernseher hat keine Bildformate genannt")
return
capabilities = formats.parse_video_formats(value)
chosen = formats.choose(capabilities, self.max_height)
if not chosen:
self.running = False
self.on_stopped("Kein gemeinsames Bildformat gefunden")
return
self._format = chosen
self.on_status(f"Format ausgehandelt: {chosen}")
ports_value = message.param("wfd_client_rtp_ports")
port = formats.parse_rtp_port(ports_value)
if port:
self._sink_rtp_port = port
capability = capabilities[0]
body = (
f"wfd_video_formats: {formats.build_selection(chosen, capability.profile, capability.level)}\r\n"
f"wfd_presentation_URL: rtsp://{self.local_address}/wfd1.0/streamid=0 none\r\n"
f"wfd_client_rtp_ports: {ports_value or f'RTP/AVP/UDP;unicast {self._sink_rtp_port} 0 mode=play'}\r\n"
)
self._send_request("SET_PARAMETER", f"rtsp://{self.local_address}/wfd1.0",
body=body, step="M4")
def _trigger_setup(self):
"""M5: Den Fernseher bitten, jetzt SETUP zu schicken."""
self._send_request("SET_PARAMETER", f"rtsp://{self.local_address}/wfd1.0",
body="wfd_trigger_method: SETUP\r\n", step="M5")
# ------------------------------------------------------------- Technik
def _send_request(self, method: str, uri: str, headers=None, body: str = "", step: str = ""):
with self._lock:
cseq = self._cseq
self._cseq += 1
if step:
self._pending[cseq] = step
self._write(build_request(method, uri, cseq, headers, body))
log.info("-> %s %s (%s)", method, uri, step or f"CSeq {cseq}")
def _write(self, data: bytes):
try:
self.stream.write(data)
self.stream.flush()
except OSError as error:
log.warning("Senden fehlgeschlagen: %s", error)
class SourceServer:
"""Hört auf den Fernseher und übergibt jede Verbindung an eine Sitzung."""
def __init__(self, local_address: str, max_height: int,
on_play, on_stopped, on_status=lambda text: None,
ports=DEFAULT_PORTS):
self.local_address = local_address
self.max_height = max_height
self.on_play = on_play
self.on_stopped = on_stopped
self.on_status = on_status
self.ports = ports
self._servers = []
self._session: Optional[SourceSession] = None
self._adopted = threading.Event()
def start(self) -> list:
"""Öffnet die Steuerkanäle. Gibt die tatsächlich belegten Ports zurück."""
opened = []
for port in self.ports:
try:
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("0.0.0.0", port))
server.listen(1)
self._servers.append(server)
threading.Thread(target=self._accept_loop, args=(server, port),
daemon=True, name=f"rtsp-listen-{port}").start()
opened.append(port)
self.on_status(f"Warte auf den Fernseher (Port {port})")
except OSError as error:
log.warning("Port %s nicht verfügbar: %s", port, error)
return opened
def _accept_loop(self, server: socket.socket, port: int):
try:
while not self._adopted.is_set():
sock, address = server.accept()
self.on_status(f"Der Fernseher hat sich verbunden ({address[0]})")
if not self._adopt(sock):
sock.close()
except OSError:
pass
def _adopt(self, sock: socket.socket) -> bool:
"""Die erste Verbindung gewinnt."""
if self._adopted.is_set():
return False
self._adopted.set()
for server in self._servers:
try:
server.close()
except OSError:
pass
self._servers.clear()
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
self._session = SourceSession(sock, self.local_address, self.max_height,
self.on_play, self.on_stopped, self.on_status)
threading.Thread(target=self._session.run, daemon=True, name="rtsp-session").start()
return True
def try_outgoing(self, host: str, port: int):
"""Gegenrichtung - manche Geräte erwarten es andersherum."""
def attempt():
if self._adopted.is_set():
return
try:
sock = socket.create_connection((host, port), timeout=8)
except OSError as error:
log.debug("Ausgehender Versuch auf %s:%s erfolglos: %s", host, port, error)
return
self.on_status(f"Steuerkanal zum Fernseher aufgebaut (Port {port})")
if not self._adopt(sock):
sock.close()
threading.Thread(target=attempt, daemon=True, name="rtsp-outgoing").start()
@property
def session(self) -> Optional[SourceSession]:
return self._session
def stop(self):
self._adopted.set()
for server in self._servers:
try:
server.close()
except OSError:
pass
self._servers.clear()
if self._session:
self._session.stop()
+117
View File
@@ -0,0 +1,117 @@
"""Bildschirmaufnahme und Versand als RTP-Strom.
Die eigentliche Arbeit macht ffmpeg: aufnehmen, in H.264 wandeln, in MPEG-TS
verpacken und als RTP verschicken - genau das Paketformat, das der
Wi-Fi-Display-Standard erwartet (Nutzlasttyp 33).
"""
import logging
import os
import shutil
import signal
import subprocess
from typing import List, Optional
from .formats import VideoFormat
log = logging.getLogger("wfd.streamer")
def screen_size(display: str) -> str:
"""Auflösung des Bildschirms - für die Aufnahme braucht ffmpeg sie."""
for command, pattern in (
(["xdpyinfo", "-display", display], "dimensions:"),
(["xrandr", "--display", display, "--current"], "*"),
):
if not shutil.which(command[0]):
continue
try:
output = subprocess.run(command, capture_output=True, text=True,
timeout=5).stdout
except (subprocess.SubprocessError, OSError):
continue
for line in output.splitlines():
if pattern not in line:
continue
for token in line.split():
if "x" in token and token.replace("x", "").isdigit():
return token
return "1920x1080"
class Streamer:
"""Hält den laufenden ffmpeg-Prozess."""
def __init__(self, source: str = "auto", display: Optional[str] = None,
bitrate_mbit: int = 8):
self.source = source
self.display = display or os.environ.get("DISPLAY", ":0")
self.bitrate_mbit = bitrate_mbit
self.process: Optional[subprocess.Popen] = None
self.command: List[str] = []
def build_command(self, host: str, port: int, fmt: VideoFormat) -> List[str]:
command = ["ffmpeg", "-hide_banner", "-loglevel", "warning", "-nostdin"]
mode = self.source
if mode == "auto":
mode = "x11" if os.environ.get("DISPLAY") else "test"
if mode == "x11":
command += ["-f", "x11grab", "-framerate", str(fmt.fps),
"-video_size", screen_size(self.display),
"-draw_mouse", "1", "-i", self.display]
elif mode == "kms":
# Für Wayland-Sitzungen; benötigt erweiterte Rechte.
command += ["-f", "kmsgrab", "-framerate", str(fmt.fps), "-i", "-",
"-vf", "hwdownload,format=bgr0"]
elif mode == "test":
command += ["-f", "lavfi", "-i",
f"testsrc=size={fmt.width}x{fmt.height}:rate={fmt.fps}"]
else:
raise ValueError(f"Unbekannte Bildquelle: {mode}")
# Auf die ausgehandelte Größe bringen, Seitenverhältnis erhalten.
scale = (f"scale={fmt.width}:{fmt.height}:force_original_aspect_ratio=decrease,"
f"pad={fmt.width}:{fmt.height}:(ow-iw)/2:(oh-ih)/2,format=yuv420p")
rate = self.bitrate_mbit
command += [
"-vf", scale,
"-c:v", "libx264",
"-profile:v", "baseline", # ohne B-Bilder, das versteht jeder Empfänger
"-preset", "ultrafast",
"-tune", "zerolatency",
"-x264-params",
f"keyint={fmt.fps * 2}:min-keyint={fmt.fps * 2}:scenecut=0:bframes=0:nal-hrd=cbr",
"-b:v", f"{rate}M", "-minrate", f"{rate}M", "-maxrate", f"{rate}M",
"-bufsize", f"{max(1, rate // 4)}M",
"-r", str(fmt.fps),
"-muxdelay", "0", "-muxpreload", "0",
"-f", "rtp_mpegts",
f"rtp://{host}:{port}?pkt_size=1316",
]
return command
def start(self, host: str, port: int, fmt: VideoFormat):
if self.process:
return
self.command = self.build_command(host, port, fmt)
log.info("Starte Bildstrom: %s", " ".join(self.command))
self.process = subprocess.Popen(self.command)
def is_running(self) -> bool:
return self.process is not None and self.process.poll() is None
def stop(self):
if not self.process:
return
try:
self.process.send_signal(signal.SIGINT)
self.process.wait(timeout=4)
except (subprocess.TimeoutExpired, OSError):
try:
self.process.kill()
except OSError:
pass
self.process = None
+165
View File
@@ -0,0 +1,165 @@
"""Steuerung von wpa_supplicant über seinen Control-Socket.
Das ist derselbe Kanal, den auch wpa_cli benutzt: ein UNIX-Datagramm-Socket,
über den Befehle im Klartext gehen. Ereignisse (Gerät gefunden, Gruppe
gestartet) kommen über eine zweite, angemeldete Verbindung herein.
"""
import logging
import os
import queue
import socket
import tempfile
import threading
from typing import Dict, List, Optional
log = logging.getLogger("wfd.wpa")
CONTROL_DIRS = ("/run/wpa_supplicant", "/var/run/wpa_supplicant")
def find_interfaces() -> List[str]:
"""Alle Interfaces, für die ein Control-Socket bereitsteht."""
found = []
for directory in CONTROL_DIRS:
try:
for name in sorted(os.listdir(directory)):
path = os.path.join(directory, name)
if os.path.exists(path) and path not in found:
found.append(path)
except OSError:
continue
return found
def wireless_interfaces() -> List[str]:
"""Namen der WLAN-Geräte laut Kernel."""
try:
return sorted(os.listdir("/sys/class/net")) and [
name for name in sorted(os.listdir("/sys/class/net"))
if os.path.exists(f"/sys/class/net/{name}/wireless")
or os.path.exists(f"/sys/class/net/{name}/phy80211")
]
except OSError:
return []
class WpaError(RuntimeError):
pass
class WpaClient:
"""Eine Verbindung zum Control-Socket eines Interfaces."""
def __init__(self, control_path: str):
self.control_path = control_path
self._local_path = os.path.join(
tempfile.gettempdir(), f"wfd-ctrl-{os.getpid()}-{id(self)}"
)
self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
try:
self._sock.bind(self._local_path)
self._sock.connect(control_path)
except OSError as error:
self._cleanup()
raise WpaError(
f"Kein Zugriff auf {control_path}: {error}. "
"Meist fehlen Rechte - als root ausführen oder der Gruppe netdev beitreten."
) from error
self._sock.settimeout(5)
self._lock = threading.Lock()
self.events: "queue.Queue[str]" = queue.Queue()
self._attached = False
self._reader: Optional[threading.Thread] = None
self._stop = threading.Event()
# ------------------------------------------------------------ Befehle
def request(self, command: str, timeout: float = 5.0) -> str:
"""Schickt einen Befehl und liefert die Antwort."""
with self._lock:
self._sock.settimeout(timeout)
self._sock.send(command.encode())
while True:
data = self._sock.recv(8192).decode("utf-8", "replace")
# Ereignisse können dazwischenfunken - die gehören in die Queue.
if data.startswith("<"):
self.events.put(data)
continue
return data.strip()
def ok(self, command: str) -> bool:
try:
return self.request(command).startswith("OK")
except OSError as error:
log.warning("Befehl '%s' fehlgeschlagen: %s", command, error)
return False
# ---------------------------------------------------------- Ereignisse
def attach(self):
"""Meldet sich für Ereignisse an und liest sie im Hintergrund mit."""
if self._attached:
return
if not self.request("ATTACH").startswith("OK"):
raise WpaError("wpa_supplicant nimmt keine Ereignis-Anmeldung an")
self._attached = True
self._reader = threading.Thread(target=self._read_events, daemon=True,
name="wpa-events")
self._reader.start()
def _read_events(self):
while not self._stop.is_set():
try:
with self._lock:
self._sock.settimeout(0.4)
data = self._sock.recv(8192).decode("utf-8", "replace")
except socket.timeout:
continue
except OSError:
break
for line in data.splitlines():
if line.startswith("<"):
# Prioritätsangabe wie "<3>" abtrennen
self.events.put(line.split(">", 1)[-1])
log.debug("Ereignis: %s", line)
def wait_for_event(self, *needles: str, timeout: float = 30.0) -> Optional[str]:
"""Wartet auf ein Ereignis, das eine der Zeichenketten enthält."""
import time
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
event = self.events.get(timeout=0.3)
except queue.Empty:
continue
if any(needle in event for needle in needles):
return event
return None
def close(self):
self._stop.set()
if self._reader:
self._reader.join(1.0)
try:
self._sock.close()
except OSError:
pass
self._cleanup()
def _cleanup(self):
try:
os.unlink(self._local_path)
except OSError:
pass
def parse_key_values(text: str) -> Dict[str, str]:
"""Wandelt die übliche 'schlüssel=wert'-Ausgabe in ein Wörterbuch."""
result = {}
for line in text.splitlines():
if "=" in line:
key, value = line.split("=", 1)
result[key.strip()] = value.strip()
return result