DLNA, az otthoni médiahálózat alapja

Amikor itthon elkezdtem egy médiahálózatot kialakítani, akkor azonnal belefutottam a DLNA-ba, és mindig is érdekelt, hogy pontosan hogyan működik. Otthoni médiahálózatnak nevezem azt, mikor egy lokális hálózatra több eszköz is csatlakozik, mely vagy tárol, vagy lejátszik médiatartalmat, pl. képet, zenét, filmet. Elvárás ezekkel kapcsolatban, hogy a hálózaton együtt tudjanak működni.

A médiatartalmat valamilyen háttértáron (winchester) tároljuk. Ilyen lehet pl. az asztali számítógépbe, vagy laptopba épített HDD vagy SSD. Ide tartoznak a NAS-ok (Network Attached Storage), melyekben szintén ilyen eszközök dolgoznak. De költséghatékony megoldás lehet egy Raspberry PI-hoz (sőt, akár közvetlen routerhez) USB-vel csatlakoztatott külső merevlemez is.

Lejátszó eszköz lehet pl. az okostévé, okostelefon, asztali multimédia lejátszók (Google Chromecast, Apple TV, Android TV-t futtató eszközök, bár nem akartam leírni ezt a szót, de “TV-okosítók”, stb.), de lehet akár bármelyik asztali számítógép vagy laptop. (Ebből is látszik, hogy egy eszköz egyben akár médiatartalmat tároló és lejátszó is lehet.) Sőt a játékkonzolok is képesek tartalmat lejátszani.

Ha már ezek ugyanazon a hálózaton vannak elvárás lehet, hogy a médiatartalmat tárolók a médiatartalmat más eszközökkel is meg tudják osztani, és a lejátszók pedig bármelyik eszközön tárolt tartalmat le tudják játszani. És lehetőleg nekünk ezzel a legkevesebb dolgunk legyen.

Pontosan ennek a megoldására alakult meg a Digital Living Network Alliance (DLNA) szövetség, akik kidolgozták a pontosan ezt a nevet viselő szabványt is. Ez a gyakran emlegetett Universal Plug and Play (UPnP) szabványra épül, ami pedig olyan hálózati protokollok összessége, melyek lehetővé teszik, hogy a hálózatra kötött eszközök mindenféle beállítás nélkül megtalálják egymást, és együtt tudjanak működni. Ez az ún. zero-configuration networking.

Ebben a posztban protokoll szinten bemutatom a DLNA-t, Python kódokkal érthetőbbé téve.

Választott megoldásom

Nálam ez korábban úgy működött, hogy van egy Ubuntu Linuxot futtató eszköz, beépített merevlemezzel, media server szoftverrel, melyről gyakorlatilag bármely képernyővel rendelkező eszköz képes videókat lejátszani. Ezt cseréltem le egy Raspberry Pi-re.

Egy media server szoftver képes indexelni az eszközön található médiatartalmat, valamint a többi eszközt kiszolgálni, a videót streamelni. A legismertebb ezek közül a Kodi és a Plex.

Azonban ezek elég nehézsúlyú eszközök, nagyobb erőforrásigénnyel. Olyan szoftvert kerestem, ami pont annyit tud, amennyire szükségem van. Sokáig az elterjedt MiniDLNA-t használtam, amit azóta átneveztek ReadyMedia-ra. Azonban ennek a fejlesztése eléggé leállt, így másik megoldás után kellett néznem. Az egyik versenyző a Universal Media Server volt, azonban a tudat, hogy Javaban implementálták, valamint a szegényes dokumentációja elvette tőle a kedvem. A befutó a MiniDLNA lecserélésében a Gerbera lett. Nyílt forráskódú, C++ nyelven implementálták, jó a dokumentációja, van elérhető Docker image a legtöbb platformon, és JavaScriptben pluginelhető. (Ez a MediaTomb forkja, mely azóta megszűnt.)

Media playerként asztali számítógépen a VLC media playert használtam, mobilon a VLC for Mobile-t. A tévém egy LG okostévé, melynek Smart Share szolgáltatása képes a media server által szolgáltatott tartalmat lejátszani. (Ebből is látszik, hogy bizonyos cégek valamilyen fantázianév mögé bújtatják a DLNA-t.)

Médiahálózat

DLNA működésének bemutatása

Hogyan is néz ez ki technológiailag? Az egyszerűség kedvéért a tartalmat tároló eszköz legyen a DLNA szerver, a lejátszó pedig a DLNA kliens.

  • A DLNA kliens felderíti a hálózatra kötött szervereket, ehhez a UPnP SSDP (Simple Service Discovery Protocol) protokollt használja. A hálózatra kiküld egy multicast üzenetet, melyre a szerverek válaszolnak.
  • A DLNA kliens a választott szervertől lekéri annak képességeit, SCPD (Service Control Point Definition) protokollt használva. Ilyen képesség, hogy a szerver ki tudja listázni a tárolt tartalmakat. Ezeket a képességeket szolgáltatásokon keresztül lehet igénybe venni.
  • A DLNA kliens meghívja a kiválasztott szolgáltatást, pl. lekéri a tárolt tartalmak listáját. A szerver a válaszban visszaküldi a tárolt tartalmak adatait (címét, formátumát, méretét, létrehozás/módosítás dátumát), de legfőképp az elérhetőségét, mely egy url.
  • A DLNA kliens az adott url-ről lejátsza a tartalmat.

Nézzük ezt még részletesebben!

SSDP: felderítés

A DLNA kliens kiküld a 239.255.255.250 ip-re, 1900-as portra egy UDP multicast üzenetet. Ez valójában egy HTTPU (HTTP over UDP) üzenet, mely a HTTP-hez hasonló, szöveges, kérés-válasz protokoll, üzenet fejléccel és törzzsel.

Maga az üzenet tartalma a következő:

M-SEARCH * HTTP/1.1
HOST:239.255.255.250:1900
ST:upnp:rootdevice
MX:2
MAN:"ssdp:discover"
  • M-SEARCH a HTTP metódus, a * karakter vonatkozik arra, hogy nem egy erőforrást kérünk le. Majd jön a protokoll. A következő sorok alkotják az üzenet fejlécét.
  • HOST tartalmazza az üzenet címzettjét.
  • MAN az extension scope, értéke kötelezően "ssdp:discover", vigyázat, szigorúan idézőjelek között.
  • ST a search target. Itt un. root device-ot keresünk, melyek nincsenek más device-okba ágyazva.
  • MX a maximum wait, azaz mennyit várunk a válaszra, másodpercben.

Erre válaszolnak a DLNA szerverek, valami hasonló üzenettel:

HTTP/1.1 200 OK
CACHE-CONTROL: max-age=1800
DATE: Thu, 06 Jan 2022 20:49:26 GMT
EXT:
SERVER: Linux/5.10.63-v7+, UPnP/1.0, Portable SDK for UPnP devices/1.14.0
ST: upnp:rootdevice
USN: uuid:93131041-22f0-48fa-a6b5-9af718bbc5ae::upnp:rootdevice
LOCATION: http://192.168.0.145:49494/description.xml

Ezek csak a tipikus fejlécek, ezen kívül más fejlécek is előfordulnak.

  • HTTP/1.1 200 OK jelenti a sikeres választ.
  • CACHE-CONTROL visszaadott eredményt meddig cache-elheti a kliens.
  • DATE a válasz előállításának ideje.
  • EXT visszafele kompatibilitási okokból legyen benne.
  • SERVER egy szöveges leírás a szerverről.
  • ST a típusa, itt egy root device.
  • USN egyedi azonosítója.
  • LOCATION egy url, ahonnan letölthetőek a DLNA szerver részletes adatai.

És mit is ér az egész, ha nem próbáljuk ki egy Python szkripttel, hogy mi van, ha kiküldünk egy M-SEARCH kérést a hálózatra.

A Python példaprogramok megtalálhatóak a GitHubon.

import socket

msg = '''M-SEARCH * HTTP/1.1
HOST:239.255.255.250:1900
ST:upnp:rootdevice
MX:2
MAN:"ssdp:discover"

'''.replace("/n", "/r/n")

client_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
client_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
client_socket.bind(("192.168.0.213", 1901)) # 1.
client_socket.settimeout(2)
client_socket.sendto(msg.encode("utf-8"), ('239.255.255.250', 1900))

try:
    while True:
        data, addr = client_socket.recvfrom(65507)
        print(addr)
        print(data.decode("utf-8"))
except socket.timeout:
    pass

Nálam erre azonnal válaszolt a Raspberry Pi-n futó Gerbera szerver, az LG TV és a UPC routerem (!!!) is.

Amit érdemes megjegyezni, hogy a kimenő üzenetnél sorvége karakternek nem elég a \n, ki kell cserélni \r\n karakterekre. Valamint a biztonság kedvéért legyen a végén két sortörés.

Itt azonban hamar belefutottam egy problémába. Az 1.-essel jelölt sor nélkül a kérés nem arra a hálózati interfészre került elküldésre, melyre szerettem volna, így nem kapták meg a másik hálózaton található eszközök, és nem is válaszoltak rá. Mivel van Docker a gépemen, van egy vEthernet (Default Switch) és egy vEthernet (WSL) hálózati interfész, melyek a HyperV-hez, illetve a Windows Subsystem for Linux-hoz tartoznak. Az UDP üzenetet ezek egyikére küldte ki, amit csak Wireshark használatával sikerült kinyomoznom.

Gerbera elindítása Dockerben

Amennyiben nincs semmilyen DLNA server a hálózaton, használhatjuk a Gerberat. Linuxon futtassuk, ugyanis a Gerbera sajnos Windowson nem futtatható.

Azonban futtatható Docker konténerben is, ha csak ideiglenesen akarjuk kipróbálni. Sőt, én Raspberry Pi-ra nem találtam feltelepíthető csomagot, ezért azon is Docker konténerben futtatom.

Erre a következő parancs használható:

docker run -d --name my-gerbera --network host --privileged --restart unless-stopped \
  -v /home/pi/gerbera:/var/run/gerbera  -v /home/pi/Videos:/content:ro gerbera/gerbera

Itt is érdemes átnézni néhány kapcsolót. A --privileged kapcsolóval a konténer olyan jogokat kap a host gépen, mintha a processt konténeren kívül futtatnánk.

A legérdekesebb a --network host kapcsoló. Ekkor a konténer nem kap saját IP-címet, hanem a host hálózatához kapcsolódik. Látható tehát, hogy a Gerbera futtatásánál tehát nem a teljes izoláció volt a célom, csupán annyi, hogy ne kelljen telepíteni a host operációs rendszerre. Ekkor nem is kell a -p kapcsolóval a portokat kihozni.

Ez azért nagyon fontos, mert ha a konténer saját ip-t kap, akkor nem felel az UDP multicast üzenetekre.

Ezt a Gerbera fejlesztői is írják, hogy az UDP multicast nem proxy-zható, így a saját címét sem képes behazudni a válasz üzenetekben.

És itt kell kitérnem arra is, hogy sajnos Windowson Docker konténerben sem lehet futtatni a Gerberat. Ez azért van, mert a --network host kapcsoló Windows rendszeren nem működik.

A -v /home/pi/gerbera:/var/run/gerbera kapcsolóval a host /home/pi/gerbera könyvtárát mountolom a konténer /var/run/gerbera könyvtárára. Ez azért jó, mert a konfigurációs állomány, a config.xml, nem a konténerben lesz, hanem a hoston. A -v /home/pi/Videos:/content:ro kapcsolóval a host /home/pi/Videos könyvtárát mountolom a konténer /content könyvtárára, read-only módban. A Gerbera ugyanis alapértelmezésben a /content könyvtárat olvassa be.

Ekkor a szerver elérhető a http://localhost:49494 címen, vagy lokális hálózaton pl. a http://192.168.0.145 címen. Ezért volt a http://192.168.0.145:49494/description.xml cím látható az SSDP válaszüzenetben.

Gerbera

Ezután már a VLC-ben is meg fog jelenni, ha a Nézet / Lejátszólista menüpontban kiválasztjuk az Univerzális Plug’n’Play elemet.

VNC DLNA

Saját UDP szerver

Természetesen egy kis UDP szervert írtam Pythonban, ami válaszol az előbbi kliensnek.

from socket import *
from kiss_headers import parse_it
import platform

INTERFACE_IP = "192.168.0.213"

print("Running server")

udp_server = socket(AF_INET, SOCK_DGRAM)
udp_server.bind(("", 1900))
mreq = inet_aton("239.255.255.250") + inet_aton(INTERFACE_IP)
udp_server.setsockopt(IPPROTO_IP, IP_ADD_MEMBERSHIP, mreq)

while True:
    data, address = udp_server.recvfrom(1500)
    text = data.decode("utf-8")
    method = text.split()[0]
    headers = parse_it(text)
    if method == "M-SEARCH" and headers.ST == "upnp:rootdevice":
        print("Handle M-SEARCH")
        response = f"""HTTP/1.1 200 OK
EXT:
LOCATION: http://{INTERFACE_IP}:8080/rootDesc.xml
SERVER: {platform.system()}/{platform.release()}, UPnP/1.0, JTechLog UPnP Server 0.0.1
ST: upnp:rootdevice
USN: uuid:fea4bf14-6da5-11ec-90d6-0242ac120003::upnp:rootdevice

""".replace("\n", "\r\n")

        udp_server.sendto(response.encode("utf-8"), address)

Ennek futtatásához azonban kell egy külső függőség is, a kiss_headers, ezzel tudom a legegyszerűbben parse-olni a válasz fejléceket.

Persze ekkor már belefutottam abba, hogy a 1900-as portot foglalta a Spotify.

Ennek kinyomozása Windowson:

netstat -ano | findstr "1900"
tasklist | findstr "10380"

Az első parancs megkeresi, hogy melyik pid-del rendelkező folyamat foglalja az 1900-as portot, a második pedig kikeresi, hogy az adott pid-hez valójában melyik alkalmazás tartozik.

Ezt futtatva az is kiderült, hogy a lokális hálózaton egyrészt a UPC router is dobál NOTIFY UDP broadcast üzeneteket, valamint a Google Chrome is M-SEARCH üzeneteket, melyek az ún. Chrome Media Routerhez tartoznak.

SCPD, a szerver képességeinek meghatározása

A LOCATION fejlécben szereplő címet már egyszerű HTTP GET metódussal kell lekérni, és ekkor valami hasonló XML-t kapunk:

<root xmlns="urn:schemas-upnp-org:device-1-0" xmlns:sec="http://www.sec.co.kr/dlna">
    <specVersion>
        <major>1</major>
        <minor>0</minor>
    </specVersion>
    <device>
        <dlna:X_DLNADOC xmlns:dlna="urn:schemas-dlna-org:device-1-0">DMS-1.50</dlna:X_DLNADOC>
        <friendlyName>Gerbera</friendlyName>
        <manufacturer>Gerbera Contributors</manufacturer>
        <modelDescription>Free UPnP AV MediaServer, GNU GPL</modelDescription>
        <UDN>uuid:93131041-22f0-48fa-a6b5-9af718bbc5ae</UDN>
        <deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
        <presentationURL>http://192.168.0.145:49494/</presentationURL>
        <iconList>
            <icon>
                <mimetype>image/png</mimetype>
                <width>120</width>
                <height>120</height>
                <depth>24</depth>
                <url>/icons/mt-icon120.png</url>
            </icon>

        </iconList>
        <serviceList>
            <service>
                <serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType>
                <serviceId>urn:upnp-org:serviceId:ContentDirectory</serviceId>
                <SCPDURL>cds.xml</SCPDURL>
                <controlURL>/upnp/control/cds</controlURL>
                <eventSubURL>/upnp/event/cds</eventSubURL>
            </service>
        </serviceList>
    </device>
    <URLBase>http://192.168.0.145:49494/</URLBase>
</root>

Ebből pár lényegtelen taget eltávolítottam. Ami talán egyértelműen látszik, hogy ez az XML írja le a DLNA serverünket, ez jelenik meg pl. a VLC-ben, vagy az okostévén. A neve a <friendlyName> tagen belül található, átküldésre kerül az ikonjának az elérhetősége is, de a legfontosabb talán a <serviceList> tagen belüli szolgáltatás leírások. Ebből látható, hogy a ContentDirectory szolgáltatás leírása elérhető a cds.xml címen. Így a http://192.168.0.145:49494/cds.xml címen a következő XML-t kapjuk vissza.

<scpd xmlns="urn:schemas-upnp-org:service-1-0">
    <specVersion>
        <major>1</major>
        <minor>0</minor>
    </specVersion>
    <actionList>
        <action>
            <name>Browse</name>
            <argumentList>
                <argument>
                    <name>ObjectID</name>
                    <direction>in</direction>
                    <relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
                </argument>
                <argument>
                    <name>BrowseFlag</name>
                    <direction>in</direction>
                    <relatedStateVariable>A_ARG_TYPE_BrowseFlag</relatedStateVariable>
                </argument>
                <!-- ... -->
            </argumentList>
        </action>
        <!-- ... -->
    </actionList>
    <!-- ... -->
</scpd>

Ez szintén egy erősen megrövidített lista, a lényeg azonban látható. A ContentDirectory szolgáltatás tartalmaz egy Browse actiont, melynek a bemeneti és kimeneti paraméterei is fel vannak sorolva. Ez az action való arra, hogy a médiatartalmat listázni tudjuk.

Action meghívása: médiatartalmak lekérdezése

Az Action meghívása SOAP over HTTP formátumban/protokollon történik. Igen-igen, ki hinné, hogy ilyen elavult technológiák vannak. Ez egy HTTP POST hívás, ahol a törzsben egy XML utazik.

Ezt már nem akartam leprogramozni, hanem helyette a Python upnpy könyvtárát használtam.

Itt megint okozott bonyodalmat a több hálózati interfész, de csak sikerült meghívnom a socket bind() metódusát.

import upnpy

upnp = upnpy.UPnP()
upnp.ssdp.socket.bind(("192.168.0.213", 1901))
devices = upnp.discover()
print(devices)

device = next(device for device in devices if device.friendly_name == "Gerbera")

services = device.get_services()
print(services)

service = device["ContentDirectory"]
print(service.get_actions())

result = service.Browse(ObjectID=0, BrowseFlag="BrowseDirectChildren", Filter="*", StartingIndex=0, RequestedCount=5000,
                        SortCriteria="")
xml = result["Result"]
print(xml)

Itt a paraméterek megadásával gyűlt meg a bajom, hogy milyen értékeket kell átadnom. Ehhez megint csak a Wiresharkot hívtam segítségül, hogy pontosan milyen kérést is ad ki. Alább látható, persze az eredeti kérés nem formázott. Ezután már egyszerű volt a paramétereket visszafejteni.

POST /upnp/control/cds HTTP/1.1
HOST: 192.168.0.145:49494
CONTENT-LENGTH: 440
CONTENT-TYPE: text/xml; charset="utf-8"
SOAPACTION: "urn:schemas-upnp-org:service:ContentDirectory:1#Browse"
USER-AGENT: 6.2.9200 2/, UPnP/1.0, Portable SDK for UPnP devices/1.6.19

<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
            s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
    <s:Body>
        <u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">
            <ObjectID>0</ObjectID>
            <BrowseFlag>BrowseDirectChildren</BrowseFlag>
            <Filter>*</Filter>
            <StartingIndex>0</StartingIndex>
            <RequestedCount>5000</RequestedCount>
            <SortCriteria></SortCriteria>
        </u:Browse>
    </s:Body>
</s:Envelope>

Itt ismét egy szép XML jött vissza:

<?xml version="1.0" encoding="UTF-8"?>
<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/"
           xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns:sec="http://www.sec.co.kr/dlna">
    <container id="4" parentID="0" restricted="1" childCount="4">
        <dc:title>Video</dc:title>
        <upnp:class>object.container</upnp:class>
    </container>
        <item id="67" parentID="5" restricted="1">
        <dc:title>bbb sunflower 1080p 30fps normal</dc:title>
        <upnp:class>object.item.videoItem</upnp:class>
        <dc:created>2013-12-16</dc:created>
        <dc:description>Creative Commons Attribution 3.0 - http://bbb3d.renderfarming.net</dc:description>
        <upnp:artist>Blender Foundation 2008, Janus Bager Kristensen 2013</upnp:artist>
        <upnp:composer>Sacha Goedegebure</upnp:composer>
        <upnp:genre>Animation</upnp:genre>
        <res bitrate="435178" bitsPerSample="16" duration="0:10:34.533" nrAudioChannels="2"
             protocolInfo="http-get:*:video/mp4:DLNA.ORG_PN=AVC_MP4_EU;DLNA.ORG_OP=01;DLNA.ORG_CI=0"
             resolution="1920x1080" sampleFrequency="48000" sec:acodec="mp3" sec:vcodec="h264" size="276134947">
            http://192.168.0.145:49494/content/media/object_id/67/res_id/0/ext/file.mp4
        </res>
    </item>
</DIDL-Lite>

És ezen már látszik, hogy a főkönyvtár tartalmát adja vissza, benne az alkönyvtárakat és videó állományokat. A videó állományokról a címén és az url-jén kívül sok hasznos információ is megtalálható, mint pl. a mérete, felbontása, hossza, formátuma, video és audio codec, stb. Sőt, ha a videófájlban megtalálható, pl. a készítő, műfaja, létrehozás dátuma, megjegyzés, stb.

Ebből az információkat én már Pythonban, XPath használatával olvastam ki, melyhez az lxml csomagot használtam. (Látható, hogy a névterek használata hogy megbonyolítja a kódot.) Az alábbi kódrészlet a címeket írja ki.

import lxml.etree as etree

root = etree.fromstring(xml.encode())
for element in root.xpath("//didl:item[./upnp:class[text() = 'object.item.videoItem']]/dc:title",
                          namespaces={"dc": "http://purl.org/dc/elements/1.1/",
                                      "didl": "urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/",
                                      "upnp": "urn:schemas-upnp-org:metadata-1-0/upnp/"}):
    print(element.text)

Saját UPnP szerver

Ha az UDP szervert már megírtam Pythonban, akkor nem láttam akadályát, hogy egy teljes UPnP szervert megírjak, hiszen innentől kezdve már csak a HTTP kéréseket kell kiszolgálni.

Ehhez viszont konkurens kéne elindítani egy UDP-n és egy TCP-n hallgató folyamatot. Ez egy remek alkalom volt, hogy kipróbáljam a Python Async IO megoldását.

Az Async IO úgy oldja meg a konkurens futást, hogy valójában egy szál dolgozik, azonban az egyik feladat IO-ra vár, addig a szál tud a másik feladat számításigényes dolgaival foglalkozni.

A Real Python oldalon egy remek példa található az aszinkron működésre, Miguel Grinberg 2017 PyCon konferencián tartott beszédéből, ahol az aszinkron működést Polgár Judit szimultán sakkjához hasonlítja.

Event loopnak hívják azt az egy szálat, ami dolgozik.

Az Async IO szíve a coroutine, mely egy speciális generator függvény. Ennek futását még a return előtt megszakíthatja az interpreter, és átadhatja a vezérlést egy másik coroutine függvénynek.

Coroutine deklarálásánál használhatók a async / await kulcsszavak. Következzék erre rövid példa.

async def main():
    print('hello')
    await asyncio.sleep(1)
    print('world')

asyncio.run(main())

És akkor következhet az így megírt UDP szerver.

import asyncio
from kiss_headers import parse_it
from socket import *
import platform

MULTICAST_PORT = 1900
MULTICAST_GROUP = "239.255.255.250"


class SsdpProtocol(asyncio.BaseProtocol):
    def __init__(self, interface_ip):
        self.transport = None
        self.interface_ip = interface_ip

    def connection_made(self, transport):
        print("UDP connection made")
        self.transport = transport

    def connection_lost(self, ex):
        print("UDP connection lost")

    def datagram_received(self, data, address):
        text = data.decode("utf-8")

        method = text.split()[0]
        headers = parse_it(text)
        if method == "M-SEARCH" and headers.ST == "upnp:rootdevice":
            print("Handle M-SEARCH")
            response = f"""HTTP/1.1 200 OK
EXT:
LOCATION: http://{self.interface_ip}:8080/rootDesc.xml
SERVER: {platform.system()}/{platform.release()}, UPnP/1.0, JTechLog UPnP Server 0.0.1
ST: upnp:rootdevice
USN: uuid:fea4bf14-6da5-11ec-90d6-0242ac120003::upnp:rootdevice

""".replace("\n", "\r\n")

            self.transport.sendto(response.encode("utf-8"), address)


async def run_udp_server(interface_ip):
    print("Starting UDP server")

    udp_server = socket(AF_INET, SOCK_DGRAM)
    udp_server.bind(("", MULTICAST_PORT))
    mreq = inet_aton(MULTICAST_GROUP) + inet_aton(interface_ip)
    udp_server.setsockopt(IPPROTO_IP, IP_ADD_MEMBERSHIP, mreq)

    loop = asyncio.get_running_loop()
    transport, protocol = await loop.create_datagram_endpoint(
        lambda: SsdpProtocol(interface_ip),
        sock=udp_server)

    try:
        await asyncio.sleep(3600)  # Serve for 1 hour.
    except asyncio.CancelledError:
        print("Cancelled UDP server")
    finally:
        transport.close()

asyncio.run(run_udp_server("192.168.0.213"))

A HTTP protokollt nem akartam leprogramozni, hanem helyette az Async IO-ra épülő aiohttp könyvtárat használtam.

class HttpHandler:

    def __init__(self, interface_ip, http_port):
        self.interface_ip = interface_ip
        self.http_port = http_port

    async def handle(self, request):
        print("Handle HTTP request to 'rootDesc.xml'")
        text = f"""<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0" xmlns:dlna="urn:schemas-dlna-org:device-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<device>
    <deviceType>upnp:rootdevice</deviceType>
    <friendlyName>JTechLog</friendlyName>
</device>
<URLBase>http://{self.interface_ip}:{self.http_port}/</URLBase>
</root>
"""
        return web.Response(text=text)


async def run_http_server(interface_ip):
    print("Starting HTTP server")
    app = web.Application()
    handler = HttpHandler(interface_ip, 8080)
    app.add_routes([web.get('/rootDesc.xml', handler.handle)])

    runner = web.AppRunner(app)
    await runner.setup()
    site = web.TCPSite(runner, host=interface_ip, port=8080)
    await site.start()

    try:
        await asyncio.sleep(3600)  # Serve for 1 hour.
    except asyncio.CancelledError:
        print("Cancelled HTTP server")

asyncio.run(run_http_server("192.168.0.213"))

De hogy indítjuk el egymás mellett a kettőt?

async def run_servers(interface_ip):

    udp_server_task = asyncio.ensure_future(run_udp_server(interface_ip))
    http_server_task = asyncio.ensure_future(run_http_server(interface_ip))

    await asyncio.gather(udp_server_task, http_server_task)

print("Starting servers")
asyncio.run(run_servers("192.168.0.213"))

Amit még szerettem volna megoldani, hogy hogy tud kezelni Linux signalokat, azaz pl. amikor nyomok egy Ctrl + C billentyűzetkombinációt a konzolban.

Láthattuk, hogy a szerverek indításakor asyncio.sleep() függvényt hívtam, és kezeltem a asyncio.CancelledError kivételt. Hát küldjünk akkor cancel-t az összes feladatnak.

class SignalHandler:

    def __init__(self, tasks):
        self.tasks = tasks

    def handle(self):
        print("Got SIGINT signal")
        for task in self.tasks:
            task.cancel()


loop = asyncio.get_event_loop()
signal_handler = SignalHandler([udp_server_task, http_server_task])
loop.add_signal_handler(signal.SIGINT, signal_handler.handle)