From d3657cfd2b9b5e92ce904dd8234a2bd6be189edb Mon Sep 17 00:00:00 2001 From: FabioMich66 Date: Mon, 1 Jun 2026 12:53:22 +0200 Subject: [PATCH] first commit --- README.md | 56 ++++ dlna_receiver.py | 665 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 721 insertions(+) create mode 100644 README.md create mode 100644 dlna_receiver.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..4aaa3d3 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +🔥 COME ABBIAMO SBLOCCATO MACOS PER PERMETTERE IL MULTICAST SSDP +macOS blocca il multicast UDP in uscita per processi non autorizzati, +anche se il firewall è disattivato. + +Per questo il tuo Python diceva “SSDP sent”, ma non usciva nulla sulla rete. + +✔️ 1. Abbiamo autorizzato Python nel firewall di macOS +Se usi Python di sistema: + +Codice +sudo /usr/libexec/ApplicationFirewall/socketfilterfw --add /usr/bin/python3 +sudo /usr/libexec/ApplicationFirewall/socketfilterfw --unblockapp /usr/bin/python3 + +Questo è il passaggio fondamentale: +senza questa autorizzazione, macOS non invia multicast. + +✔️ 2. Abbiamo abilitato le app firmate a inviare multicast +macOS ha due flag nascosti che, se disattivati, bloccano il multicast: + +Codice +sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setallowsigned on +sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setallowsignedapp on +Questi due comandi dicono al firewall: + +“Lascia passare il traffico multicast delle app firmate (come Python).” +✔️ 3. Abbiamo verificato con tcpdump +Dopo la patch: + +Codice +sudo tcpdump -i en0 udp port 1900 +E finalmente hai visto: + +Codice +IP 192.168.1.2 > 239.255.255.250:1900 UDP +→ Conferma che macOS ora lascia uscire i pacchetti SSDP. + +DIPENDENZE minime + +pip install flask netifaces + +DIPENDENZE per altri test DLNA + +lxml → per fare parsing XML più robusto (prima di passare a parsing manuale) +requests → per testare SOAP verso TV reali +flask → già incluso, ma reinstallato + +pip install flask lxml requests + +COME AVVIARE + +python3 dlna_receiver.py + +ma va avviato anche VLC in modalità player DLNA + +/Applications/VLC.app/Contents/MacOS/VLC --extraintf rc --rc-host localhost:9999 + diff --git a/dlna_receiver.py b/dlna_receiver.py new file mode 100644 index 0000000..4b4b79a --- /dev/null +++ b/dlna_receiver.py @@ -0,0 +1,665 @@ +import socket +import struct +import threading +import time +import subprocess +import netifaces +from flask import Flask, request, Response + +# ========================================================= +# LG webOS TV OLED65C1 — DLNA MediaRenderer (PARTE 1/3) +# ========================================================= + +DEVICE_NAME = "LG webOS TV OLED65C1" +HTTP_PORT = 8200 +SSDP_PORT = 1900 +UUID = "uuid:lg-webos-oled65c1-1234" + +# --------------------------------------------------------- +# IP DETECTION +# --------------------------------------------------------- +def get_ip(): + for iface in netifaces.interfaces(): + addrs = netifaces.ifaddresses(iface) + if netifaces.AF_INET in addrs: + for addr in addrs[netifaces.AF_INET]: + ip = addr['addr'] + if not ip.startswith("127."): + return ip + return "127.0.0.1" + +LOCAL_IP = get_ip() +print("IP rilevato:", LOCAL_IP) + +# --------------------------------------------------------- +# SSDP ANNOUNCE (LG webOS style) +# --------------------------------------------------------- +def ssdp_announce(): + NOTIFY_TEMPLATES = [ + # rootdevice + ( + "NOTIFY * HTTP/1.1\r\n" + "HOST: 239.255.255.250:1900\r\n" + "CACHE-CONTROL: max-age=1800\r\n" + f"LOCATION: http://{LOCAL_IP}:{HTTP_PORT}/device.xml\r\n" + "NT: upnp:rootdevice\r\n" + "NTS: ssdp:alive\r\n" + "SERVER: webOS/4.0 UPnP/1.0 LGDLNA/1.0\r\n" + f"USN: {UUID}::upnp:rootdevice\r\n" + "\r\n" + ), + # MediaRenderer + ( + "NOTIFY * HTTP/1.1\r\n" + "HOST: 239.255.255.250:1900\r\n" + "CACHE-CONTROL: max-age=1800\r\n" + f"LOCATION: http://{LOCAL_IP}:{HTTP_PORT}/device.xml\r\n" + "NT: urn:schemas-upnp-org:device:MediaRenderer:1\r\n" + "NTS: ssdp:alive\r\n" + "SERVER: webOS/4.0 UPnP/1.0 LGDLNA/1.0\r\n" + f"USN: {UUID}::urn:schemas-upnp-org:device:MediaRenderer:1\r\n" + "\r\n" + ), + # AVTransport + ( + "NOTIFY * HTTP/1.1\r\n" + "HOST: 239.255.255.250:1900\r\n" + "CACHE-CONTROL: max-age=1800\r\n" + f"LOCATION: http://{LOCAL_IP}:{HTTP_PORT}/device.xml\r\n" + "NT: urn:schemas-upnp-org:service:AVTransport:1\r\n" + "NTS: ssdp:alive\r\n" + "SERVER: webOS/4.0 UPnP/1.0 LGDLNA/1.0\r\n" + f"USN: {UUID}::urn:schemas-upnp-org:service:AVTransport:1\r\n" + "\r\n" + ), + # RenderingControl + ( + "NOTIFY * HTTP/1.1\r\n" + "HOST: 239.255.255.250:1900\r\n" + "CACHE-CONTROL: max-age=1800\r\n" + f"LOCATION: http://{LOCAL_IP}:{HTTP_PORT}/device.xml\r\n" + "NT: urn:schemas-upnp-org:service:RenderingControl:1\r\n" + "NTS: ssdp:alive\r\n" + "SERVER: webOS/4.0 UPnP/1.0 LGDLNA/1.0\r\n" + f"USN: {UUID}::urn:schemas-upnp-org:service:RenderingControl:1\r\n" + "\r\n" + ), + # ⭐ ConnectionManager + ( + "NOTIFY * HTTP/1.1\r\n" + "HOST: 239.255.255.250:1900\r\n" + "CACHE-CONTROL: max-age=1800\r\n" + f"LOCATION: http://{LOCAL_IP}:{HTTP_PORT}/device.xml\r\n" + "NT: urn:schemas-upnp-org:service:ConnectionManager:1\r\n" + "NTS: ssdp:alive\r\n" + "SERVER: webOS/4.0 UPnP/1.0 LGDLNA/1.0\r\n" + f"USN: {UUID}::urn:schemas-upnp-org:service:ConnectionManager:1\r\n" + "\r\n" + ), + ] + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 4) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(LOCAL_IP)) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1) + + while True: + for msg in NOTIFY_TEMPLATES: + sock.sendto(msg.encode("utf-8"), ("239.255.255.250", SSDP_PORT)) + print("SSDP NOTIFY sent (LG webOS style)") + time.sleep(3) + +# --------------------------------------------------------- +# SSDP LISTENER +# --------------------------------------------------------- +def ssdp_listener(): + MCAST_GRP = "239.255.255.250" + MCAST_PORT = 1900 + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + except: + pass + + sock.bind(("", MCAST_PORT)) + + mreq = struct.pack("4s4s", socket.inet_aton(MCAST_GRP), socket.inet_aton(LOCAL_IP)) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) + + print("SSDP listener attivo su 239.255.255.250:1900") + + while True: + data, addr = sock.recvfrom(2048) + text = data.decode(errors="ignore").upper() + + if "M-SEARCH" in text: + if "MEDIAR" in text or "ROOTDEVICE" in text or "AVTRANSPORT" in text or "RENDERINGCONTROL" in text or "CONNECTIONMANAGER" in text: + print("M-SEARCH ricevuto da", addr) + + responses = [ + # rootdevice + ( + "HTTP/1.1 200 OK\r\n" + f"LOCATION: http://{LOCAL_IP}:{HTTP_PORT}/device.xml\r\n" + "EXT:\r\n" + "ST: upnp:rootdevice\r\n" + "SERVER: webOS/4.0 UPnP/1.0 LGDLNA/1.0\r\n" + f"USN: {UUID}::upnp:rootdevice\r\n" + "\r\n" + ), + # MediaRenderer + ( + "HTTP/1.1 200 OK\r\n" + f"LOCATION: http://{LOCAL_IP}:{HTTP_PORT}/device.xml\r\n" + "EXT:\r\n" + "ST: urn:schemas-upnp-org:device:MediaRenderer:1\r\n" + "SERVER: webOS/4.0 UPnP/1.0 LGDLNA/1.0\r\n" + f"USN: {UUID}::urn:schemas-upnp-org:device:MediaRenderer:1\r\n" + "\r\n" + ), + ] + + for r in responses: + sock.sendto(r.encode("utf-8"), addr) + print("Risposte M-SEARCH inviate a", addr) + +# --------------------------------------------------------- +# FLASK SERVER (device.xml + SCPD) +# --------------------------------------------------------- +app = Flask(__name__) + +@app.route("/device.xml") +def device_desc(): + xml = f""" + + 10 + + urn:schemas-upnp-org:device:MediaRenderer:1 + {DEVICE_NAME} + LG Electronics + OLED65C1 + {UUID} + + + + urn:schemas-upnp-org:service:AVTransport:1 + urn:upnp-org:serviceId:AVTransport + /upnp/control/avtransport + /upnp/event/avtransport + /avtransport.xml + + + + urn:schemas-upnp-org:service:RenderingControl:1 + urn:upnp-org:serviceId:RenderingControl + /upnp/control/renderingcontrol + /upnp/event/renderingcontrol + /renderingcontrol.xml + + + + + urn:schemas-upnp-org:service:ConnectionManager:1 + urn:upnp-org:serviceId:ConnectionManager + /upnp/control/connectionmanager + /upnp/event/connectionmanager + /connectionmanager.xml + + + + +""" + return Response(xml, mimetype="text/xml") + +@app.route("/avtransport.xml") +def avtransport_scpd(): + xml = """ + + 10 + + SetAVTransportURI + Play + Pause + Stop + Seek + GetPositionInfo + GetTransportInfo + GetMediaInfo + + +""" + return Response(xml, mimetype="text/xml") + +@app.route("/renderingcontrol.xml") +def rendering_scpd(): + xml = """ + + 10 + + GetVolume + SetVolume + GetMute + SetMute + + +""" + return Response(xml, mimetype="text/xml") + +# ⭐ ConnectionManager SCPD +@app.route("/connectionmanager.xml") +def connectionmanager_scpd(): + xml = """ + + 10 + + GetProtocolInfo + + + + SourceProtocolInfo + string + + + SinkProtocolInfo + string + + + CurrentConnectionIDs + string + + + +""" + return Response(xml, mimetype="text/xml") +# ========================================================= +# LG webOS TV OLED65C1 — DLNA MediaRenderer (PARTE 2/3) +# ========================================================= + +# --------------------------------------------------------- +# VLC RC CONTROL (localhost:9999) +# --------------------------------------------------------- +VLC_RC_HOST = "127.0.0.1" +VLC_RC_PORT = 9999 + +def vlc_rc_send(cmd, expect_reply=False): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(1.0) + s.connect((VLC_RC_HOST, VLC_RC_PORT)) + + # consume banner + try: + s.recv(4096) + except: + pass + + s.sendall((cmd + "\n").encode("utf-8")) + + if expect_reply: + data = s.recv(4096).decode("utf-8", errors="ignore") + s.close() + return data + + s.close() + except Exception as e: + print("VLC RC error:", e) + return "" + +def vlc_play_uri(uri): + print("VLC PLAY URI:", uri) + vlc_rc_send("clear") + vlc_rc_send(f"add {uri}") + +def vlc_stop(): + print("VLC STOP") + vlc_rc_send("stop") + +def vlc_pause(): + print("VLC PAUSE") + vlc_rc_send("pause") + +def vlc_seek_abs(seconds): + print("VLC SEEK:", seconds) + vlc_rc_send(f"seek {int(seconds)}") + +def vlc_set_volume(vol): + v = max(0, min(100, int(vol))) + vlc_rc_send(f"volume {v}") + +def vlc_get_time(): + out = vlc_rc_send("get_time", expect_reply=True) + try: + for line in out.splitlines(): + if line.strip().isdigit(): + return int(line.strip()) + except: + pass + return 0 + +def vlc_get_length(): + out = vlc_rc_send("get_length", expect_reply=True) + try: + for line in out.splitlines(): + if line.strip().isdigit(): + return int(line.strip()) + except: + pass + return 0 + +# --------------------------------------------------------- +# DLNA STATE +# --------------------------------------------------------- +current_uri = "" +current_metadata = "" +current_volume = 50 +current_mute = False + +# --------------------------------------------------------- +# SOAP ENVELOPE +# --------------------------------------------------------- +def soap_envelope(body_xml): + return f""" + + + {body_xml} + + +""" + +# --------------------------------------------------------- +# AVTRANSPORT HANDLER +# --------------------------------------------------------- +def handle_avtransport(body): + global current_uri, current_metadata + + b = body + + # ----------------------------- + # SetAVTransportURI + # ----------------------------- + if "SetAVTransportURI" in b: + import re + uri = "" + meta = "" + + m = re.search(r"(.*?)", b, re.DOTALL) + if m: + uri = m.group(1) + + m2 = re.search(r"(.*?)", b, re.DOTALL) + if m2: + meta = m2.group(1) + + current_uri = uri + current_metadata = meta + + print("SetAVTransportURI:", current_uri) + + if current_uri: + vlc_play_uri(current_uri) + + return soap_envelope( + '' + ) + + # ----------------------------- + # Play + # ----------------------------- + if "Play" in b: + print("AVTransport Play") + vlc_rc_send("play") + return soap_envelope( + '' + ) + + # ----------------------------- + # Pause + # ----------------------------- + if "Pause" in b: + print("AVTransport Pause") + vlc_pause() + return soap_envelope( + '' + ) + + # ----------------------------- + # Stop + # ----------------------------- + if "Stop" in b: + print("AVTransport Stop") + vlc_stop() + return soap_envelope( + '' + ) + + # ----------------------------- + # Seek + # ----------------------------- + if "Seek" in b: + import re + m = re.search(r"(.*?)", b) + if m: + target = m.group(1) + parts = target.split(":") + if len(parts) == 3: + h, m_, s = parts + seconds = int(h) * 3600 + int(m_) * 60 + int(s) + vlc_seek_abs(seconds) + + return soap_envelope( + '' + ) + + # ----------------------------- + # GetPositionInfo + # ----------------------------- + if "GetPositionInfo" in b: + rel_time = vlc_get_time() + dur = vlc_get_length() + + def fmt(t): + h = t // 3600 + m_ = (t % 3600) // 60 + s = t % 60 + return f"{h:02d}:{m_:02d}:{s:02d}" + + rel_str = fmt(rel_time) + dur_str = fmt(dur) + + body_xml = f""" + + 1 + {dur_str} + {current_metadata} + {current_uri} + {rel_str} + {rel_str} + 0 + 0 + +""" + return soap_envelope(body_xml) + + # ----------------------------- + # GetTransportInfo + # ----------------------------- + if "GetTransportInfo" in b: + state = "STOPPED" + if vlc_get_time() > 0: + state = "PLAYING" + + body_xml = f""" + + {state} + OK + 1 + +""" + return soap_envelope(body_xml) + + # ----------------------------- + # GetMediaInfo + # ----------------------------- + if "GetMediaInfo" in b: + dur = vlc_get_length() + + h = dur // 3600 + m_ = (dur % 3600) // 60 + s = dur % 60 + dur_str = f"{h:02d}:{m_:02d}:{s:02d}" + + body_xml = f""" + + 1 + {dur_str} + {current_uri} + {current_metadata} + + + NETWORK + NOT_IMPLEMENTED + NOT_IMPLEMENTED + +""" + return soap_envelope(body_xml) + + # ----------------------------- + # Default + # ----------------------------- + return soap_envelope( + '' + ) +# ========================================================= +# LG webOS TV OLED65C1 — DLNA MediaRenderer (PARTE 3/3) +# ========================================================= + +# --------------------------------------------------------- +# RENDERINGCONTROL HANDLER +# --------------------------------------------------------- +def handle_rendering_control(body): + global current_volume, current_mute + + b = body + + # ----------------------------- + # SetVolume + # ----------------------------- + if "SetVolume" in b: + import re + m = re.search(r"(.*?)", b) + if m: + try: + v = int(m.group(1)) + current_volume = max(0, min(100, v)) + print("SetVolume:", current_volume) + if not current_mute: + vlc_set_volume(current_volume) + except: + pass + + return soap_envelope( + '' + ) + + # ----------------------------- + # GetVolume + # ----------------------------- + if "GetVolume" in b: + body_xml = f""" + + {current_volume} + +""" + return soap_envelope(body_xml) + + # ----------------------------- + # SetMute + # ----------------------------- + if "SetMute" in b: + current_mute = "true" in b.lower() + print("SetMute:", current_mute) + + if current_mute: + vlc_set_volume(0) + else: + vlc_set_volume(current_volume) + + return soap_envelope( + '' + ) + + # ----------------------------- + # GetMute + # ----------------------------- + if "GetMute" in b: + val = "1" if current_mute else "0" + body_xml = f""" + + {val} + +""" + return soap_envelope(body_xml) + + # ----------------------------- + # Default + # ----------------------------- + return soap_envelope( + '' + ) + +# --------------------------------------------------------- +# CONNECTIONMANAGER HANDLER +# --------------------------------------------------------- +def handle_connection_manager(body): + # Risposta minimale ma valida per AVES + sink = ",".join([ + "http-get:*:video/mp4:*", + "http-get:*:video/x-matroska:*", + "http-get:*:image/jpeg:*", + "http-get:*:image/png:*", + ]) + + body_xml = f""" + + + {sink} + 0 + +""" + return soap_envelope(body_xml) + +# --------------------------------------------------------- +# FLASK ROUTES FINALI +# --------------------------------------------------------- +@app.route("/upnp/control/avtransport", methods=["POST"]) +def avtransport_control(): + body = request.data.decode() + resp = handle_avtransport(body) + return Response(resp, mimetype="text/xml") + +@app.route("/upnp/control/renderingcontrol", methods=["POST"]) +def rendering_control(): + body = request.data.decode() + resp = handle_rendering_control(body) + return Response(resp, mimetype="text/xml") + +@app.route("/upnp/control/connectionmanager", methods=["POST"]) +def connectionmanager_control(): + body = request.data.decode() + resp = handle_connection_manager(body) + return Response(resp, mimetype="text/xml") + +# --------------------------------------------------------- +# MAIN +# --------------------------------------------------------- +if __name__ == "__main__": + threading.Thread(target=ssdp_announce, daemon=True).start() + threading.Thread(target=ssdp_listener, daemon=True).start() + + print("LG webOS TV OLED65C1 — DLNA Renderer attivo su:", LOCAL_IP) + print("Assicurati che VLC sia avviato con:") + print("/Applications/VLC.app/Contents/MacOS/VLC --extraintf rc --rc-host localhost:9999") + + app.run(host="0.0.0.0", port=HTTP_PORT)