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