import re
|
|
|
|
import requests
|
|
from lxml import etree
|
|
|
|
from .util import _getLogger
|
|
|
|
|
|
SOAP_TIMEOUT = 30
|
|
NS_SOAP_ENV = 'http://schemas.xmlsoap.org/soap/envelope/'
|
|
NS_UPNP_ERR = 'urn:schemas-upnp-org:control-1-0'
|
|
ENCODING_STYLE = 'http://schemas.xmlsoap.org/soap/encoding/'
|
|
ENCODING = 'utf-8'
|
|
|
|
|
|
class SOAPError(Exception):
|
|
pass
|
|
|
|
|
|
class SOAPProtocolError(Exception):
|
|
pass
|
|
|
|
|
|
class SOAP(object):
|
|
"""SOAP (Simple Object Access Protocol) implementation
|
|
This class defines a simple SOAP client.
|
|
"""
|
|
def __init__(self, url, service_type):
|
|
self.url = url
|
|
self.service_type = service_type
|
|
# FIXME: Use urlparse for this:
|
|
self._host = self.url.split('//', 1)[1].split('/', 1)[0] # Get hostname portion of url
|
|
self._log = _getLogger('SOAP')
|
|
|
|
def _extract_upnperror(self, err_xml):
|
|
"""
|
|
Extract the error code and error description from an error returned by the device.
|
|
"""
|
|
nsmap = {'s': list(err_xml.nsmap.values())[0]}
|
|
fault_str = err_xml.findtext(
|
|
's:Body/s:Fault/faultstring', namespaces=nsmap)
|
|
try:
|
|
err = err_xml.xpath(
|
|
's:Body/s:Fault/detail/*[name()="%s"]' % fault_str, namespaces=nsmap)[0]
|
|
except IndexError:
|
|
msg = 'Tag with name of %r was not found in the error response.' % fault_str
|
|
self._log.debug(
|
|
msg + '\n' + etree.tostring(err_xml, pretty_print=True).decode('utf8'))
|
|
raise SOAPProtocolError(msg)
|
|
|
|
err_code = err.findtext('errorCode', namespaces=err.nsmap)
|
|
err_desc = err.findtext('errorDescription', namespaces=err.nsmap)
|
|
|
|
if err_code is None or err_desc is None:
|
|
msg = 'Tags errorCode or errorDescription were not found in the error response.'
|
|
self._log.debug(
|
|
msg + '\n' + etree.tostring(err_xml, pretty_print=True).decode('utf8'))
|
|
raise SOAPProtocolError(msg)
|
|
return int(err_code), err_desc
|
|
|
|
@staticmethod
|
|
def _remove_extraneous_xml_declarations(xml_str):
|
|
"""
|
|
Sometimes devices return XML with more than one XML declaration in, such as when returning
|
|
their own XML config files. This removes the extra ones and preserves the first one.
|
|
"""
|
|
xml_declaration = ''
|
|
if xml_str.startswith('<?xml'):
|
|
xml_declaration, xml_str = xml_str.split('?>', maxsplit=1)
|
|
xml_declaration += '?>'
|
|
xml_str = re.sub(r'<\?xml.*?\?>', '', xml_str, flags=re.I)
|
|
return xml_declaration + xml_str
|
|
|
|
def call(self, action_name, arg_in=None, http_auth=None, http_headers=None):
|
|
"""
|
|
Construct the XML and make the call to the device. Parse the response values into a dict.
|
|
"""
|
|
if arg_in is None:
|
|
arg_in = {}
|
|
|
|
soap_env = '{%s}' % NS_SOAP_ENV
|
|
m = '{%s}' % self.service_type
|
|
|
|
root = etree.Element(soap_env+'Envelope', nsmap={'SOAP-ENV': NS_SOAP_ENV})
|
|
root.attrib[soap_env+'encodingStyle'] = ENCODING_STYLE
|
|
body = etree.SubElement(root, soap_env+'Body')
|
|
action = etree.SubElement(body, m+action_name, nsmap={'m': self.service_type})
|
|
for key, value in arg_in.items():
|
|
etree.SubElement(action, key).text = str(value)
|
|
body = etree.tostring(root, encoding=ENCODING, xml_declaration=True)
|
|
headers = {
|
|
'SOAPAction': '"%s#%s"' % (self.service_type, action_name),
|
|
'Host': self._host,
|
|
'Content-Type': 'text/xml',
|
|
'Content-Length': str(len(body)),
|
|
}
|
|
headers.update(http_headers or {})
|
|
|
|
try:
|
|
resp = requests.post(
|
|
self.url,
|
|
body,
|
|
headers=headers,
|
|
timeout=SOAP_TIMEOUT,
|
|
auth=http_auth
|
|
)
|
|
resp.raise_for_status()
|
|
except requests.exceptions.HTTPError as exc:
|
|
# If the body of the error response contains XML then it should be a UPnP error,
|
|
# otherwise reraise the HTTPError.
|
|
try:
|
|
err_xml = etree.fromstring(exc.response.content)
|
|
except etree.XMLSyntaxError:
|
|
raise exc
|
|
raise SOAPError(*self._extract_upnperror(err_xml))
|
|
|
|
xml_str = resp.content.strip()
|
|
try:
|
|
xml = etree.fromstring(xml_str)
|
|
except etree.XMLSyntaxError:
|
|
# Try removing any extra XML declarations in case there are more than one.
|
|
# This sometimes happens when a device sends its own XML config files.
|
|
xml = etree.fromstring(self._remove_extraneous_xml_declarations(xml_str))
|
|
except ValueError:
|
|
# This can occur when requests returns a `str` (unicode) but there's also an XML
|
|
# declaration, which lxml doesn't like.
|
|
xml = etree.fromstring(xml_str.encode('utf8'))
|
|
|
|
response = xml.find(".//{%s}%sResponse" % (self.service_type, action_name))
|
|
if response is None:
|
|
msg = ('Returned XML did not include an element which matches namespace %r and tag name'
|
|
' \'%sResponse\'.' % (self.service_type, action_name))
|
|
self._log.debug(msg + '\n' + etree.tostring(xml, pretty_print=True).decode('utf8'))
|
|
raise SOAPProtocolError(msg)
|
|
|
|
# Sometimes devices return XML strings as their argument values without escaping them with
|
|
# CDATA. This checks to see if the argument has been parsed as XML and un-parses it if so.
|
|
ret = {}
|
|
for arg in response.getchildren():
|
|
children = arg.getchildren()
|
|
if children:
|
|
ret[arg.tag] = b"\n".join(etree.tostring(x) for x in children)
|
|
else:
|
|
ret[arg.tag] = arg.text
|
|
|
|
return ret
|