first commit
This commit is contained in:
@@ -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 M1–M7 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.
|
||||
Executable
+308
@@ -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())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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