Умный Дом - автоматизация устройств с поддержкой UPnP/DLNA

Universal Plug and Play (UPnP) - это набор протоколов для обнаружения и последующего взаимодействия различных сетевых устройств. Девайсом может быть почти все что угодно: роутер, принтер, Smart TV, мобильный телефон - главное чтобы он был подключён к сети и поддерживал технологию UPnP/DLNA.

Тут пожалуй стоит сделать риторическое отступление. Зачем нам две технологии UPnP и DLNA ? Так сложилось исторически. С точки зрения бытового использования - это почти одно и то же. Технически DLNA базируется на UPnP, накладывая на него некоторые ограничения. UPnP - набор открытых бесплатных сетевых протоколов, в то время как для сертификации DLNA от производителя оборудования требуется заплатить денюшку. Также DLNA привносит интересы правообладателя, стремясь допустить использование только "правильного" медиаконтента. Хороший пример - формат mkv, столь распространённый в сети и столь ненавистный многим правообладателям. В результате этот формат, вполне совместимый с UPnP, оказался за бортом спецификаций DLNA и множество DLNA устройств не могут с ним работать.

На практике это означает, что и DLNA и UPnP устройства в подавляющем большинстве случаев могут работать вместе. Правда, не на любом контенте.

screenshot

Наша цель - автоматизировать действия на картинке одной командой т.к. очень удобно включать/выключать музыку в комнате комбинацией клавиш на ноуте.

Начнём с обнаружения доступных 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

links

social