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
+81
View File
@@ -0,0 +1,81 @@
# Miracast-Sender für Linux
Spiegelt den Bildschirm auf einen Fernseher, der im Screen-Mirroring-Modus auf
eine Verbindung wartet. Am Fernseher muss nichts eingetippt werden.
```bash
sudo ./miracast-cast check # Voraussetzungen prüfen
sudo ./miracast-cast scan # Fernseher suchen
sudo ./miracast-cast cast # verbinden und spiegeln
```
## Warum das hier bessere Chancen hat als die Android-App
Damit ein Fernseher eine Miracast-Sitzung annimmt, muss sich der Sender als
*Wi-Fi-Display-Quelle* ausweisen ein besonderes Kennzeichen in den
WLAN-Suchpaketen. Auf Android ist das `CONFIGURE_WIFI_DISPLAY` und bleibt
Systemapps vorbehalten; genau daran scheitert die App dort.
Unter Linux lässt sich dasselbe über `wpa_supplicant` setzen:
```
SET wifi_display 1
WFD_SUBELEM_SET 0 0006 0010 1c44 0032
│ │ └── 50 Mbit/s Durchsatz
│ └─────── Port 7236 für die Steuerverbindung
└──────────── Typ „Quelle“, Sitzung verfügbar
```
Deshalb sollte der Fernseher uns hier als das erkennen, was wir sind.
## Voraussetzungen
* WLAN-Karte, die Wi-Fi Direct beherrscht (`iw list` muss `P2P-GO` und
`P2P-client` aufführen)
* `wpa_supplicant` mit erreichbarem Control-Socket
* `ffmpeg`
* Rootrechte (der Control-Socket gehört root)
* Ein DHCP-Client (`dhclient`, `udhcpc` oder `dhcpcd`) in der
Wi-Fi-Direct-Gruppe holt sich der Rechner darüber seine Adresse
Verwaltet NetworkManager die WLAN-Karte, muss er sie loslassen:
```bash
nmcli device set wlan0 managed no
wpa_supplicant -B -i wlan0 -c /etc/wpa_supplicant/wpa_supplicant.conf -C /run/wpa_supplicant
```
## Nützliche Schalter
| Schalter | Wirkung |
|---|---|
| `--source test` | schickt ein Testbild statt des Bildschirms gut zum Ausprobieren |
| `--source x11` / `--display :0` | bestimmter X11-Bildschirm |
| `--source kms` | für Wayland-Sitzungen (braucht erweiterte Rechte) |
| `--bitrate 12` | Bitrate in Mbit/s |
| `--max-height 720` | begrenzt die Auflösung, spart Funkbandbreite |
| `--peer AA:BB:…` | direkt eine bekannte MAC ansprechen, ohne Suche |
| `-v` | ausführliche Ausgabe samt RTSP-Verkehr |
## Wie es abläuft
1. `wpa_supplicant` meldet uns als Wi-Fi-Display-Quelle an
2. Wi-Fi Direct sucht Geräte; Fernseher melden sich als „Bildschirm“
3. **Zuerst** öffnet sich der Steuerkanal auf Port 7236 der Fernseher meldet
sich nämlich von sich aus, sobald die Gruppe steht
4. `P2P_CONNECT` baut die Direktverbindung auf, DHCP liefert die Adresse
5. Der Fernseher ruft an; es folgt der Handshake M1M7 mit Formatabsprache
6. `ffmpeg` nimmt auf, wandelt in H.264, verpackt in MPEG-TS und schickt es
als RTP hinüber
## Tests
```bash
python3 -m unittest discover -s tests -v
```
Der Durchlauf in `tests/test_end_to_end.py` baut einen Fernseher nach, der sich
wie ein echtes Gerät verhält: Er ruft an, führt den kompletten Handshake und
empfängt anschließend echtes Video, das mit `ffprobe` geprüft wird. Nicht
abgedeckt ist die Wi-Fi-Direct-Verbindung selbst dafür braucht es
WLAN-Hardware.
+308
View File
@@ -0,0 +1,308 @@
#!/usr/bin/env python3
"""Bildschirm auf einen wartenden Fernseher spiegeln (Miracast-Quelle).
sudo ./miracast-cast check Voraussetzungen prüfen
sudo ./miracast-cast scan Fernseher suchen
sudo ./miracast-cast cast verbinden und spiegeln
Ohne Argumente verhält sich das Programm wie "cast".
"""
import argparse
import logging
import os
import shutil
import signal
import sys
import threading
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from wfd import p2p, rtsp, wpa # noqa: E402
from wfd.formats import VideoFormat # noqa: E402
from wfd.streamer import Streamer # noqa: E402
log = logging.getLogger("miracast")
def setup_logging(verbose: bool):
logging.basicConfig(
level=logging.DEBUG if verbose else logging.INFO,
format="%(asctime)s %(message)s",
datefmt="%H:%M:%S",
)
# --------------------------------------------------------------------- check
def command_check(args) -> int:
print("Voraussetzungen für Miracast\n")
ok = True
wireless = wpa.wireless_interfaces()
if wireless:
print(f" [ok] WLAN-Gerät: {', '.join(wireless)}")
else:
print(" [FEHLT] Kein WLAN-Gerät gefunden.")
print(" Miracast läuft über Wi-Fi Direct - ohne WLAN-Karte geht es nicht.")
ok = False
sockets = wpa.find_interfaces()
if sockets:
print(f" [ok] wpa_supplicant erreichbar: {', '.join(sockets)}")
else:
print(" [FEHLT] Kein Control-Socket von wpa_supplicant gefunden.")
print(" Als root ausführen. Falls NetworkManager das Gerät verwaltet:")
print(" nmcli device set <wlan> managed no")
print(" wpa_supplicant -B -i <wlan> -c /etc/wpa_supplicant/wpa_supplicant.conf \\")
print(" -C /run/wpa_supplicant")
ok = False
if os.geteuid() == 0:
print(" [ok] Läuft mit Rootrechten")
else:
print(" [Hinweis] Ohne root fehlt meist der Zugriff auf wpa_supplicant")
if shutil.which("ffmpeg"):
print(" [ok] ffmpeg vorhanden")
else:
print(" [FEHLT] ffmpeg fehlt - ohne es gibt es kein Bild (apt install ffmpeg)")
ok = False
if any(shutil.which(tool) for tool in ("dhclient", "udhcpc", "dhcpcd")):
print(" [ok] DHCP-Client vorhanden")
else:
print(" [Hinweis] Kein DHCP-Client - in der Gruppe fehlt dann evtl. die Adresse")
session = os.environ.get("XDG_SESSION_TYPE", "unbekannt")
if os.environ.get("DISPLAY"):
print(f" [ok] Bildquelle: X11 auf {os.environ['DISPLAY']} (Sitzung: {session})")
else:
print(f" [Hinweis] Kein DISPLAY gesetzt (Sitzung: {session}).")
print(" Bei Wayland mit '--source kms' arbeiten oder '--source test' zum Prüfen.")
# Auf einer WFD-Quelle muss Port 7236 frei sein.
import socket as socketlib
probe = socketlib.socket(socketlib.AF_INET, socketlib.SOCK_STREAM)
probe.setsockopt(socketlib.SOL_SOCKET, socketlib.SO_REUSEADDR, 1)
try:
probe.bind(("0.0.0.0", 7236))
print(" [ok] Port 7236 ist frei")
except OSError as error:
print(f" [FEHLT] Port 7236 belegt: {error}")
ok = False
finally:
probe.close()
print("\n" + ("Alles bereit." if ok else "Es fehlt noch etwas - siehe oben."))
return 0 if ok else 1
# ---------------------------------------------------------------- Verbindung
def open_client(interface: str = "") -> wpa.WpaClient:
sockets = wpa.find_interfaces()
if not sockets:
raise SystemExit("Kein wpa_supplicant erreichbar. './miracast-cast check' zeigt, was fehlt.")
if interface:
matching = [path for path in sockets if os.path.basename(path) == interface]
if not matching:
raise SystemExit(f"Interface {interface} hat keinen Control-Socket. Vorhanden: {sockets}")
return wpa.WpaClient(matching[0])
# p2p-dev-* ist der Steuerkanal für Wi-Fi Direct und hat Vorrang.
sockets.sort(key=lambda path: 0 if "p2p-dev" in path else 1)
return wpa.WpaClient(sockets[0])
def discover(manager: p2p.P2pManager, seconds: int, only_sinks: bool = True):
manager.find(seconds)
deadline = time.monotonic() + seconds
seen = {}
while time.monotonic() < deadline:
for peer in manager.peers():
if only_sinks and not peer.is_sink:
continue
if peer.address not in seen:
seen[peer.address] = peer
print(f" gefunden: {peer.describe()}")
time.sleep(2)
manager.stop_find()
return list(seen.values())
def command_scan(args) -> int:
client = open_client(args.interface)
client.attach()
manager = p2p.P2pManager(client)
manager.enable_wfd_source()
print(f"Suche {args.timeout} Sekunden nach Fernsehern ...")
peers = discover(manager, args.timeout, only_sinks=not args.all)
if not peers:
print("Nichts gefunden. Steht am Fernseher der Screen-Mirroring-Bildschirm offen?")
client.close()
return 0 if peers else 1
def command_cast(args) -> int:
client = open_client(args.interface)
client.attach()
manager = p2p.P2pManager(client)
if not manager.enable_wfd_source(control_port=args.port):
print("Warnung: Das WFD-Kennzeichen liess sich nicht setzen. "
"Der Fernseher erkennt uns dann möglicherweise nicht als Quelle.")
# Fernseher auswählen
peer = None
if args.peer:
peer = p2p.Peer(address=args.peer, name=args.peer, is_sink=True,
session_available=True, control_port=args.port)
else:
print(f"Suche {args.timeout} Sekunden nach Fernsehern ...")
peers = discover(manager, args.timeout, only_sinks=not args.all)
if not peers:
print("Kein Fernseher gefunden.")
client.close()
return 1
if len(peers) == 1:
peer = peers[0]
print(f"Nehme {peer.describe()}")
else:
for index, candidate in enumerate(peers, 1):
print(f" {index}) {candidate.describe()}")
choice = input("Welcher Fernseher? ").strip()
peer = peers[int(choice) - 1] if choice.isdigit() else peers[0]
streamer = Streamer(source=args.source, display=args.display, bitrate_mbit=args.bitrate)
finished = threading.Event()
state = {"format": None, "group": None}
def on_play(host: str, port: int, fmt: VideoFormat):
state["format"] = fmt
print(f"\n>>> Übertragung läuft: {fmt} an {host}:{port}\n")
streamer.start(host, port, fmt)
def on_stopped(reason: str):
print(f"\n>>> Ende: {reason}")
finished.set()
def on_status(text: str):
print(f" {text}")
# Erst zuhören, dann verbinden: Der Fernseher meldet sich sofort, wenn
# die Gruppe steht - da muss der Kanal schon offen sein.
server = rtsp.SourceServer("0.0.0.0", args.max_height, on_play, on_stopped, on_status,
ports=(args.port, 8554))
opened = server.start()
if not opened:
print("Konnte keinen Steuerkanal öffnen (Port 7236 belegt?).")
client.close()
return 1
print(f"Verbinde mit {peer.describe()} ... am Fernseher ggf. bestätigen.")
manager.connect(peer, go_intent=args.go_intent)
group = manager.wait_for_group(timeout=args.connect_timeout)
if not group:
print("Die Direktverbindung kam nicht zustande.")
server.stop(); client.close()
return 1
print(f"Gruppe steht auf {group.interface} (Rolle: {group.role})")
address = p2p.obtain_address(group.interface)
if address:
group.local_address = address
print(f"Eigene Adresse in der Gruppe: {address}")
# Für die Präsentations-URL im Handshake
if server.session:
server.session.local_address = address
gateway = p2p.default_gateway(group.interface)
if gateway:
group.go_address = gateway
print(f"Fernseher vermutlich unter {gateway}")
server.try_outgoing(gateway, peer.control_port)
else:
print("Keine Adresse erhalten - läuft ein DHCP-Client?")
print("Warte darauf, dass der Fernseher die Sitzung aufbaut ...")
def keep_alive():
while not finished.is_set():
if finished.wait(25):
return
if server.session:
server.session.keep_alive()
threading.Thread(target=keep_alive, daemon=True, name="keepalive").start()
def shutdown(signum=None, frame=None):
finished.set()
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
try:
while not finished.is_set():
finished.wait(1)
if streamer.process and not streamer.is_running():
print(">>> Der Bildstrom ist beendet.")
break
finally:
print("Räume auf ...")
streamer.stop()
server.stop()
manager.remove_group(state["group"].interface if state["group"] else "")
manager.disable_wfd()
client.close()
return 0
def main() -> int:
parser = argparse.ArgumentParser(
description="Bildschirm auf einen Miracast-Fernseher spiegeln",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__)
parser.add_argument("-v", "--verbose", action="store_true", help="ausführliche Ausgabe")
parser.add_argument("-i", "--interface", default="", help="WLAN-Interface (sonst automatisch)")
sub = parser.add_subparsers(dest="command")
sub.add_parser("check", help="Voraussetzungen prüfen")
scan = sub.add_parser("scan", help="Fernseher suchen")
scan.add_argument("-t", "--timeout", type=int, default=20, help="Suchdauer in Sekunden")
scan.add_argument("--all", action="store_true", help="auch Geräte zeigen, die keine Bildschirme sind")
cast = sub.add_parser("cast", help="verbinden und spiegeln")
cast.add_argument("--peer", default="", help="MAC-Adresse des Fernsehers")
cast.add_argument("-t", "--timeout", type=int, default=20, help="Suchdauer in Sekunden")
cast.add_argument("--all", action="store_true", help="auch Nicht-Bildschirme anbieten")
cast.add_argument("--source", default="auto", choices=["auto", "x11", "kms", "test"],
help="Bildquelle; 'test' schickt ein Testbild")
cast.add_argument("--display", default=None, help="X11-Display, z.B. :0")
cast.add_argument("--bitrate", type=int, default=8, help="Bitrate in Mbit/s")
cast.add_argument("--max-height", type=int, default=1080, help="größte erlaubte Bildhöhe")
cast.add_argument("--port", type=int, default=7236, help="Port für die Steuerverbindung")
cast.add_argument("--go-intent", type=int, default=0,
help="0 = der Fernseher führt die Gruppe (Standard)")
cast.add_argument("--connect-timeout", type=int, default=60,
help="Wartezeit auf die Direktverbindung")
args = parser.parse_args()
setup_logging(args.verbose)
if args.command == "check":
return command_check(args)
if args.command == "scan":
return command_scan(args)
if args.command in (None, "cast"):
if args.command is None:
args = parser.parse_args(["cast"])
setup_logging(False)
return command_cast(args)
parser.print_help()
return 1
if __name__ == "__main__":
sys.exit(main())
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+222
View File
@@ -0,0 +1,222 @@
"""Der volle Durchlauf gegen einen nachgebauten Miracast-Fernseher.
Der simulierte Fernseher verhält sich wie ein echtes Gerät: Er baut die
Steuerverbindung zu uns auf (so schreibt es der Standard vor), führt den
Handshake M1 bis M7 und empfängt anschließend den RTP-Strom. Was hier
ankommt, wird mit ffprobe auf Dekodierbarkeit geprüft.
Nicht abgedeckt: die Wi-Fi-Direct-Verbindung selbst - dafür braucht es
WLAN-Hardware.
"""
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
import time
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from wfd import rtsp
from wfd.formats import VideoFormat
from wfd.streamer import Streamer
CONTROL_PORT = 17236
SINK_FORMATS = "00 00 02 04 0001DEFF 155557FF 00000FFF 00 0000 0000 11 none none"
class SimulatedTv(threading.Thread):
"""Spielt einen Miracast-Empfänger nach."""
def __init__(self, control_port, rtp_socket):
super().__init__(daemon=True, name="simulierter-fernseher")
self.control_port = control_port
self.rtp_socket = rtp_socket
self.transcript = []
self.error = None
self.played = threading.Event()
def run(self):
try:
self._talk()
except Exception as error: # noqa: BLE001 - im Test wollen wir alles sehen
self.error = error
def _talk(self):
sock = socket.create_connection(("127.0.0.1", self.control_port), timeout=10)
stream = sock.makefile("rwb")
requests, responses = [], []
lock = threading.Condition()
def reader():
while True:
message = rtsp.read_message(stream)
if message is None:
break
with lock:
(requests if message.is_request else responses).append(message)
lock.notify_all()
threading.Thread(target=reader, daemon=True).start()
def take(bucket, predicate=lambda m: True, timeout=15):
deadline = time.monotonic() + timeout
with lock:
while time.monotonic() < deadline:
for index, message in enumerate(bucket):
if predicate(message):
return bucket.pop(index)
lock.wait(0.2)
raise AssertionError("Nachricht blieb aus")
def write(data):
stream.write(data)
stream.flush()
# M1: Die Quelle meldet sich von sich aus.
m1 = take(requests, lambda m: m.method == "OPTIONS")
assert m1.headers.get("require") == "org.wfa.wfd1.0", m1.headers
self.transcript.append("M1 OPTIONS")
write(rtsp.build_response(m1.cseq, {
"Public": "org.wfa.wfd1.0, SETUP, TEARDOWN, PLAY, PAUSE, GET_PARAMETER, SET_PARAMETER"}))
# M2: Wir fragen zurück.
write(rtsp.build_request("OPTIONS", "*", 100, {"Require": "org.wfa.wfd1.0"}))
m2 = take(responses)
assert m2.status_code == 200, m2.status_code
self.transcript.append("M2 beantwortet")
# M3: Die Quelle fragt unsere Fähigkeiten ab.
m3 = take(requests, lambda m: m.method == "GET_PARAMETER")
assert "wfd_video_formats" in m3.body
self.transcript.append("M3 GET_PARAMETER")
write(rtsp.build_response(m3.cseq, body=(
f"wfd_video_formats: {SINK_FORMATS}\r\n"
"wfd_audio_codecs: AAC 00000007 00\r\n"
f"wfd_client_rtp_ports: RTP/AVP/UDP;unicast {self.rtp_socket.getsockname()[1]} 0 mode=play\r\n"
"wfd_content_protection: none\r\n")))
# M4: Sie legt genau ein Format fest.
m4 = take(requests, lambda m: m.method == "SET_PARAMETER" and "wfd_video_formats" in m.body)
announced = m4.param("wfd_video_formats")
cea = int(announced.split()[4], 16)
assert bin(cea).count("1") == 1, f"Es muss genau ein Format sein: {announced}"
assert cea == 1 << 5, f"Erwartet 1280x720p30, bekam Bit {cea.bit_length() - 1}"
assert "wfd_presentation_URL" in m4.body
self.transcript.append("M4 Format festgelegt")
write(rtsp.build_response(m4.cseq))
# M5: Sie bittet uns, die Sitzung einzurichten.
m5 = take(requests, lambda m: m.method == "SET_PARAMETER" and "wfd_trigger_method" in m.body)
assert m5.param("wfd_trigger_method") == "SETUP"
self.transcript.append("M5 Trigger SETUP")
write(rtsp.build_response(m5.cseq))
# M6: Wir richten die Sitzung ein und nennen unseren Empfangsport.
port = self.rtp_socket.getsockname()[1]
write(rtsp.build_request("SETUP", "rtsp://127.0.0.1/wfd1.0/streamid=0", 101,
{"Transport": f"RTP/AVP/UDP;unicast;client_port={port}"}))
m6 = take(responses)
assert m6.status_code == 200
assert m6.headers.get("session"), "Session-Kopfzeile fehlt"
self.transcript.append("M6 Sitzung eingerichtet")
# M7: Wiedergabe anfordern.
write(rtsp.build_request("PLAY", "rtsp://127.0.0.1/wfd1.0/streamid=0", 102,
{"Session": m6.session}))
m7 = take(responses)
assert m7.status_code == 200
self.transcript.append("M7 Wiedergabe")
self.played.set()
time.sleep(8)
class EndToEndTest(unittest.TestCase):
@unittest.skipUnless(shutil.which("ffmpeg"), "ffmpeg wird gebraucht")
def test_handshake_then_real_video(self):
rtp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
rtp_socket.bind(("127.0.0.1", 0))
rtp_socket.settimeout(12)
streamer = Streamer(source="test", bitrate_mbit=4)
played = {"format": None, "host": None, "port": None}
stopped = []
def on_play(host, port, fmt):
played.update(format=fmt, host=host, port=port)
streamer.start(host, port, fmt)
server = rtsp.SourceServer("127.0.0.1", 1080, on_play,
lambda reason: stopped.append(reason),
ports=(CONTROL_PORT,))
opened = server.start()
self.assertEqual([CONTROL_PORT], opened, "Der Steuerkanal muss offen sein")
tv = SimulatedTv(CONTROL_PORT, rtp_socket)
tv.start()
try:
self.assertTrue(tv.played.wait(25),
f"Handshake unvollständig: {tv.transcript} / {tv.error}")
self.assertIsNone(tv.error, f"Fernseher meldet: {tv.error}")
self.assertEqual(["M1 OPTIONS", "M2 beantwortet", "M3 GET_PARAMETER",
"M4 Format festgelegt", "M5 Trigger SETUP",
"M6 Sitzung eingerichtet", "M7 Wiedergabe"], tv.transcript)
self.assertEqual((1280, 720, 30),
(played["format"].width, played["format"].height,
played["format"].fps))
# Jetzt muss echtes Bild ankommen.
payload = bytearray()
sequences = []
gaps = 0
deadline = time.monotonic() + 12
while len(payload) < 250_000 and time.monotonic() < deadline:
try:
data, _ = rtp_socket.recvfrom(4096)
except socket.timeout:
break
self.assertEqual(2, data[0] >> 6, "RTP-Version muss 2 sein")
self.assertEqual(33, data[1] & 0x7F, "Nutzlasttyp muss 33 (MP2T) sein")
self.assertEqual(0x47, data[12], "Nutzlast muss MPEG-TS sein")
self.assertEqual(0, (len(data) - 12) % 188)
sequence = (data[2] << 8) | data[3]
if sequences and sequence != (sequences[-1] + 1) & 0xFFFF:
gaps += 1
sequences.append(sequence)
payload += data[12:]
self.assertGreater(len(payload), 200_000, "Zu wenig Bilddaten angekommen")
self.assertEqual(0, gaps, "Die RTP-Sequenz muss lückenlos sein")
# Der letzte Frame ist angeschnitten - für die Prüfung abrunden.
whole = len(payload) - (len(payload) % 188)
target = os.path.join(tempfile.gettempdir(), "wfd-endtoend.ts")
with open(target, "wb") as handle:
handle.write(payload[:whole - 188 * 40])
probe = subprocess.run(
["ffprobe", "-v", "error", "-show_entries",
"stream=codec_name,width,height", "-of", "default=noprint_wrappers=1", target],
capture_output=True, text=True, timeout=30)
self.assertIn("codec_name=h264", probe.stdout, probe.stderr)
self.assertIn("width=1280", probe.stdout)
self.assertIn("height=720", probe.stdout)
print(f"\n Handshake: {' -> '.join(tv.transcript)}")
print(f" Bild: {len(payload)} Byte in {len(sequences)} RTP-Paketen, "
f"{gaps} Lücken, dekodiert als {probe.stdout.strip().splitlines()}")
finally:
streamer.stop()
server.stop()
rtp_socket.close()
if __name__ == "__main__":
unittest.main()
+65
View File
@@ -0,0 +1,65 @@
"""Prüft die Formataushandlung nach dem Wi-Fi-Display-Standard."""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from wfd import formats
class FormatTest(unittest.TestCase):
# So antwortet ein handelsüblicher Fernseher auf M3.
TYPICAL = "00 00 02 04 0001DEFF 155557FF 00000FFF 00 0000 0000 11 none none"
def test_parses_capabilities(self):
caps = formats.parse_video_formats(self.TYPICAL)
self.assertEqual(1, len(caps))
self.assertEqual(0x02, caps[0].profile)
self.assertEqual(0x04, caps[0].level)
self.assertEqual(0x0001DEFF, caps[0].cea_mask)
def test_chooses_720p30(self):
chosen = formats.choose(formats.parse_video_formats(self.TYPICAL))
self.assertEqual((1280, 720, 30), (chosen.width, chosen.height, chosen.fps))
def test_respects_height_limit(self):
caps = formats.parse_video_formats(self.TYPICAL)
chosen = formats.choose(caps, max_height=480)
self.assertEqual(480, chosen.height)
def test_falls_back_to_small_format(self):
caps = formats.parse_video_formats(
"00 00 01 02 00000003 00000000 00000000 00 0000 0000 00 none none")
chosen = formats.choose(caps)
self.assertEqual((640, 480), (chosen.width, chosen.height))
def test_no_common_format(self):
caps = formats.parse_video_formats(
"00 00 01 02 00000000 00000000 00000000 00 0000 0000 00 none none")
self.assertIsNone(formats.choose(caps))
def test_never_picks_interlaced(self):
# Nur interlaced-Bits gesetzt: 2, 4, 9, 14
mask = (1 << 2) | (1 << 4) | (1 << 9) | (1 << 14)
caps = formats.parse_video_formats(
f"00 00 01 02 {mask:08X} 00000000 00000000 00 0000 0000 00 none none")
self.assertIsNone(formats.choose(caps))
def test_selection_announces_exactly_one_format(self):
selection = formats.build_selection(formats.CEA[5], 0x02, 0x04)
fields = selection.split()
self.assertEqual(13, len(fields))
self.assertEqual(1 << 5, int(fields[4], 16))
self.assertEqual(1, bin(int(fields[4], 16)).count("1"))
self.assertEqual(["none", "none"], fields[11:13])
def test_reads_rtp_port(self):
self.assertEqual(19000, formats.parse_rtp_port("RTP/AVP/UDP;unicast 19000 0 mode=play"))
self.assertEqual(0, formats.parse_rtp_port(None))
if __name__ == "__main__":
unittest.main()
+120
View File
@@ -0,0 +1,120 @@
"""Prüft die Anmeldung als Wi-Fi-Display-Quelle gegen einen nachgebauten
wpa_supplicant. Genau dieser Schritt ist auf Android gesperrt."""
import os
import socket
import sys
import tempfile
import threading
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from wfd import p2p, wpa
class FakeSupplicant:
"""Nimmt Befehle auf einem UNIX-Socket entgegen, wie wpa_supplicant es tut."""
def __init__(self, path, answers=None):
self.path = path
self.answers = answers or {}
self.received = []
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
self.sock.bind(path)
self.sock.settimeout(0.3)
self._stop = threading.Event()
self.thread = threading.Thread(target=self._serve, daemon=True)
self.thread.start()
def _serve(self):
while not self._stop.is_set():
try:
data, sender = self.sock.recvfrom(4096)
except socket.timeout:
continue
except OSError:
break
command = data.decode()
self.received.append(command)
reply = self.answers.get(command)
if reply is None:
for prefix, value in self.answers.items():
if command.startswith(prefix):
reply = value
break
self.sock.sendto((reply if reply is not None else "OK").encode(), sender)
def stop(self):
self._stop.set()
self.thread.join(1)
self.sock.close()
class WfdRegistrationTest(unittest.TestCase):
def setUp(self):
self.dir = tempfile.mkdtemp()
self.path = os.path.join(self.dir, "wlan0")
def test_announces_itself_as_source(self):
fake = FakeSupplicant(self.path)
try:
client = wpa.WpaClient(self.path)
manager = p2p.P2pManager(client)
self.assertTrue(manager.enable_wfd_source(control_port=7236))
client.close()
finally:
fake.stop()
self.assertIn("SET wifi_display 1", fake.received)
subelem = [c for c in fake.received if c.startswith("WFD_SUBELEM_SET")]
self.assertEqual(1, len(subelem), f"Erwartet ein WFD_SUBELEM_SET, bekam {fake.received}")
# Aufbau: "WFD_SUBELEM_SET 0 0006" + Info(4) + Port(4) + Durchsatz(4)
payload = subelem[0].split()[2]
self.assertTrue(payload.startswith("0006"), payload)
self.assertEqual(16, len(payload))
info = int(payload[4:8], 16)
port = int(payload[8:12], 16)
self.assertEqual(0b00, info & 0b11, "Gerätetyp muss 'Quelle' sein")
self.assertEqual(0b01, (info >> 4) & 0b11, "Sitzung muss als verfügbar gemeldet werden")
self.assertEqual(7236, port)
def test_reads_sink_announcement(self):
answers = {
"P2P_PEERS": "aa:bb:cc:dd:ee:ff",
"P2P_PEER": ("aa:bb:cc:dd:ee:ff\ndevice_name=[TV]Zimmer 2305\n"
"pri_dev_type=7-0050F204-1\nwfd_dev_info=0x00111c440032\n"),
}
fake = FakeSupplicant(self.path, answers)
try:
client = wpa.WpaClient(self.path)
peers = p2p.P2pManager(client).peers()
client.close()
finally:
fake.stop()
self.assertEqual(1, len(peers))
peer = peers[0]
self.assertEqual("[TV]Zimmer 2305", peer.name)
self.assertTrue(peer.is_sink, "Der Fernseher muss als Bildschirm erkannt werden")
self.assertTrue(peer.session_available, "Er wartet auf eine Verbindung")
self.assertEqual(7236, peer.control_port)
def test_connect_lets_tv_lead_the_group(self):
fake = FakeSupplicant(self.path)
try:
client = wpa.WpaClient(self.path)
peer = p2p.Peer("aa:bb:cc:dd:ee:ff", "TV", True, True, 7236)
p2p.P2pManager(client).connect(peer)
client.close()
finally:
fake.stop()
connect = [c for c in fake.received if c.startswith("P2P_CONNECT")][0]
self.assertIn("pbc", connect)
self.assertIn("go_intent=0", connect)
if __name__ == "__main__":
unittest.main()
+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