first commit
This commit is contained in:
@@ -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.
@@ -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
|
||||
@@ -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 ""
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user