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