Universal Plug and Play (UPnP) - это набор протоколов для обнаружения и последующего взаимодействия различных сетевых устройств. Девайсом может быть почти все что угодно: роутер, принтер, Smart TV, мобильный телефон - главное чтобы он был подключён к сети и поддерживал технологию UPnP/DLNA.
Тут пожалуй стоит сделать риторическое отступление. Зачем нам две технологии UPnP и DLNA ? Так сложилось исторически. С точки зрения бытового использования - это почти одно и то же. Технически DLNA базируется на UPnP, накладывая на него некоторые ограничения. UPnP - набор открытых бесплатных сетевых протоколов, в то время как для сертификации DLNA от производителя оборудования требуется заплатить денюшку. Также DLNA привносит интересы правообладателя, стремясь допустить использование только "правильного" медиаконтента. Хороший пример - формат mkv, столь распространённый в сети и столь ненавистный многим правообладателям. В результате этот формат, вполне совместимый с UPnP, оказался за бортом спецификаций DLNA и множество DLNA устройств не могут с ним работать.
На практике это означает, что и DLNA и UPnP устройства в подавляющем большинстве случаев могут работать вместе. Правда, не на любом контенте.
Наша цель - автоматизировать действия на картинке одной командой т.к. очень удобно включать/выключать музыку в комнате комбинацией клавиш на ноуте.
Начнём с обнаружения доступных UPnP устройств. Для этого нужно отправить M-SEARCH запрос на multicast-адрес 239.255.255.250 порт 1900 по UDP протоколу. Все устройства и программы, поддерживающие спецификацию UPnP обязаны ответить по UDP unicast:
~$ upnp-scan() {
python3 -c '
import socket, sys
SSDP_ADDR = "239.255.255.250"
SSDP_PORT = 1900
# seconds to delay response
SSDP_MX = 3
# search target
SSDP_ST = sys.argv[1] # upnp-scan "ssdp:all"
ssdpRequest = "M-SEARCH * HTTP/1.1\r\n" + \
"HOST: %s:%d\r\n" % (SSDP_ADDR, SSDP_PORT) + \
"MAN: \"ssdp:discover\"\r\n" + \
"MX: %d\r\n" % (SSDP_MX, ) + \
"ST: %s\r\n" % (SSDP_ST, ) + "\r\n"
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.sendto(ssdpRequest.encode(), (SSDP_ADDR, SSDP_PORT))
sock.settimeout(SSDP_MX + 1)
while True:
try:
data = sock.recv(1024)
print(data.decode())
except socket.timeout:
break
' "$1" | \
tr -d '\r' # normalize new lines \r\n => \n
}
~$ upnp-scan "ssdp:all" | \
tee upnp.scan.dump | \
awk -v RS='\n\n\n' '/BubbleUPnP/ && /[Ss][Tt]: uuid:/ {print $0 "\n"}'
# response
HTTP/1.1 200 OK
Cache-control: max-age=1800
Usn: uuid:3ef89534-fb8e-c38f-0000-00007940d6d3
Location: http://192.168.0.100:57916/dev/3ef89534-fb8e-c38f-0000-00007940d6d3/desc.xml
Server: Linux/2.6.29 UPnP/1.0 BubbleUPnP/1.6.11.1
Ext:
St: uuid:3ef89534-fb8e-c38f-0000-00007940d6d3
Мы просканировали сеть и отфильтровали устройства BubbleUPnP, которыми хотим управлять. Таковых может быть несколько, поэтому для автоматизации удобно работать с конкретным девайсом через uuid:
~$ upnp-scan 'uuid:3ef89534-fb8e-c38f-0000-00007940d6d3' | awk '/Location:/ {print $2}'
# response
http://192.168.0.100:57916/dev/3ef89534-fb8e-c38f-0000-00007940d6d3/desc.xml
В спецификации UPnP много букв. Можно упростить себе жизнь и перехватить SOAP запросы на 192.168.0.100, которые отправляет программа на картинке sudo tcpdump -A dst 192.168.0.100
и сделать то же посредством curl:
~$ BASE=http://192.168.0.100:57916/ \
AV=dev/3ef89534-fb8e-c38f-0000-00007940d6d3/svc/upnp-org/AVTransport/action
~$ curl "$BASE$AV" \
-XPOST \
-H 'SOAPAction: "urn:schemas-upnp-org:service:AVTransport:1#Stop"' \
-H 'Content-Type: text/xml; charset="utf-8"' \
-d '<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:Stop xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
</u:Stop>
</s:Body>
</s:Envelope>'
~$ curl "$BASE$AV" \
-XPOST \
-H 'SOAPAction: "urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI"' \
-H 'Content-Type: text/xml; charset="utf-8"' \
-d '<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:SetAVTransportURI xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<CurrentURIMetaData>NOT_IMPLEMENTED</CurrentURIMetaData>
<CurrentURI>http://online.radioroks.com.ua:8000/RadioROKS</CurrentURI>
<InstanceID>0</InstanceID>
</u:SetAVTransportURI>
</s:Body>
</s:Envelope>'
~$ curl "$BASE$AV" \
-XPOST \
-H 'SOAPAction: "urn:schemas-upnp-org:service:AVTransport:1#Play"' \
-H 'Content-Type: text/xml; charset="utf-8"' \
-d '<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:Play xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<Speed>1</Speed>
<InstanceID>0</InstanceID>
</u:Play>
</s:Body>
</s:Envelope>'
Конечный результат:
upnp-cp.sh
#!/bin/bash
# chmod +x upnp-cp.sh && sudo ln -s "`pwd`/upnp-cp.sh" /usr/local/bin/play-to-upnp
# usage stop/play:
# play-to-upnp
# play-to-upnp http://online.radioroks.com.ua:8000/RadioROKS
set -e
upnp-scan() {
python3 -c '
import socket, sys
SSDP_ADDR = "239.255.255.250"
SSDP_PORT = 1900
# seconds to delay response
SSDP_MX = 3
# search target
SSDP_ST = sys.argv[1] # upnp-scan "ssdp:all"
ssdpRequest = "M-SEARCH * HTTP/1.1\r\n" + \
"HOST: %s:%d\r\n" % (SSDP_ADDR, SSDP_PORT) + \
"MAN: \"ssdp:discover\"\r\n" + \
"MX: %d\r\n" % (SSDP_MX, ) + \
"ST: %s\r\n" % (SSDP_ST, ) + "\r\n"
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.sendto(ssdpRequest.encode(), (SSDP_ADDR, SSDP_PORT))
sock.settimeout(SSDP_MX + 1)
while True:
try:
data = sock.recv(1024)
print(data.decode())
except socket.timeout:
break
' "$1" | \
tr -d '\r' # normalize new lines \r\n => \n
}
URL=`upnp-scan "uuid:3ef89534-fb8e-c38f-0000-00007940d6d3" | \
awk '/Location:/ {print $2}'`
echo Divice found: $URL
URL_AV=`curl -s "$URL" | \
python3 -c '
import sys, xml.dom.minidom
dom = xml.dom.minidom.parse(sys.stdin)
for srv in dom.getElementsByTagName("service"):
ids = [e.firstChild.nodeValue for e in srv.getElementsByTagName("serviceId")]
if ("urn:upnp-org:serviceId:AVTransport" in ids):
for url in srv.getElementsByTagName("controlURL"):
print(url.firstChild.nodeValue)
'`
URL_AV="`echo $URL | cut -d '/' -f1`//`echo $URL | cut -d '/' -f3`$URL_AV"
echo AVTransport found: $URL_AV
curl -vsf $URL_AV \
-XPOST \
-H 'SOAPAction: "urn:schemas-upnp-org:service:AVTransport:1#Stop"' \
-H 'Content-Type: text/xml; charset="utf-8"' \
-d '<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:Stop xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
</u:Stop>
</s:Body>
</s:Envelope>'
# $# variable will tell you the number of input arguments the script was passed
if [ $# -ne 0 ]; then
# play url $@
curl -vsf $URL_AV \
-XPOST \
-H 'SOAPAction: "urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI"' \
-H 'Content-Type: text/xml; charset="utf-8"' \
-d "<?xml version='1.0'?>
<s:Envelope xmlns:s='http://schemas.xmlsoap.org/soap/envelope/'
s:encodingStyle='http://schemas.xmlsoap.org/soap/encoding/'>
<s:Body>
<u:SetAVTransportURI xmlns:u='urn:schemas-upnp-org:service:AVTransport:1'>
<CurrentURIMetaData>NOT_IMPLEMENTED</CurrentURIMetaData>
<CurrentURI>$@</CurrentURI>
<InstanceID>0</InstanceID>
</u:SetAVTransportURI>
</s:Body>
</s:Envelope>"
curl -vsf $URL_AV \
-XPOST \
-H 'SOAPAction: "urn:schemas-upnp-org:service:AVTransport:1#Play"' \
-H 'Content-Type: text/xml; charset="utf-8"' \
-d '<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:Play xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<Speed>1</Speed>
<InstanceID>0</InstanceID>
</u:Play>
</s:Body>
</s:Envelope>'
fi