#!/usr/bin/env python3
################################
# Interactive UPNP application #
# Craig Heffner                #
# 07/16/2008                   #
################################

import sys
import os
import re
import platform
import xml.dom.minidom as minidom
import urllib.request
import readline
import time
import pickle
import struct
import base64
import getopt
import select
from socket import *

# Optional legacy module; the original code used IN.SO_BINDTODEVICE.
# On modern Python, prefer socket.SO_BINDTODEVICE if available.
try:
    import IN  # type: ignore
except Exception:
    IN = None


# Most of the CmdCompleter class was originally written by John Kenyan
# It serves to tab-complete commands inside the program's shell
class CmdCompleter:
    def __init__(self, commands):
        self.commands = commands

    # Traverses the list of available commands
    def traverse(self, tokens, tree):
        retVal = []

        # If there are no commands, or no user input, return null
        if tree is None or len(tokens) == 0:
            retVal = []
        # If there is only one word, only auto-complete the primary commands
        elif len(tokens) == 1:
            retVal = [x + ' ' for x in tree if x.startswith(tokens[0])]
        # Else auto-complete for the sub-commands
        elif tokens[0] in list(tree.keys()):
            retVal = self.traverse(tokens[1:], tree[tokens[0]])

        return retVal

    # Returns a list of possible commands that match the partial command that the user has entered
    def complete(self, text, state):
        try:
            tokens = readline.get_line_buffer().split()
            if not tokens or readline.get_line_buffer()[-1] == ' ':
                tokens.append('')
            results = self.traverse(tokens, self.commands) + [None]
            return results[state]
        except Exception as e:
            print("Failed to complete command: %s" % str(e))

        return


# UPNP class for getting, sending and parsing SSDP/SOAP XML data (among other things...)
class upnp:
    ip = False
    port = False
    completer = False
    msearchHeaders = {
        'MAN': '"ssdp:discover"',
        'MX': '2'
    }
    DEFAULT_IP = "239.255.255.250"
    DEFAULT_PORT = 1900
    UPNP_VERSION = '1.0'
    MAX_RECV = 8192
    MAX_HOSTS = 0
    TIMEOUT = 0
    HTTP_HEADERS = []
    ENUM_HOSTS = {}
    VERBOSE = False
    UNIQ = False
    DEBUG = False
    LOG_FILE = False
    BATCH_FILE = None
    IFACE = None
    STARS = '****************************************************************'
    csock = False
    ssock = False

    def __init__(self, ip, port, iface, appCommands):
        if appCommands:
            self.completer = CmdCompleter(appCommands)
        if self.initSockets(ip, port, iface) is False:
            print('UPNP class initialization failed!')
            print('Bye!')
            sys.exit(1)
        else:
            self.soapEnd = re.compile(r'</.*:envelope>', re.IGNORECASE)

    # Initialize default sockets
    def initSockets(self, ip, port, iface):
        if self.csock:
            self.csock.close()
        if self.ssock:
            self.ssock.close()

        if iface is not None:
            self.IFACE = iface
        if not ip:
            ip = self.DEFAULT_IP
        if not port:
            port = self.DEFAULT_PORT
        self.port = port
        self.ip = ip

        try:
            # This is needed to join a multicast group (ip_mreq: 2x in_addr)
            try:
                self.mreq = struct.pack("4s4s", inet_aton(ip), inet_aton("0.0.0.0"))
            except Exception:
                # Fallback to original packing (less portable)
                self.mreq = struct.pack("4sl", inet_aton(ip), INADDR_ANY)

            # Set up client socket
            self.csock = socket(AF_INET, SOCK_DGRAM)
            self.csock.setsockopt(IPPROTO_IP, IP_MULTICAST_TTL, 2)

            # Set up server socket
            self.ssock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)
            self.ssock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)

            # BSD systems also need to set SO_REUSEPORT
            try:
                self.ssock.setsockopt(SOL_SOCKET, SO_REUSEPORT, 1)
            except Exception:
                pass

            # Only bind to this interface (Linux only)
            if self.IFACE is not None:
                print('\nBinding to interface', self.IFACE, '...\n')
                so_bind = getattr(__import__("socket"), "SO_BINDTODEVICE", None)
                if so_bind is None:
                    # Try legacy IN module constant if present
                    so_bind = getattr(IN, "SO_BINDTODEVICE", None) if IN else None

                if so_bind is None:
                    print("WARNING: SO_BINDTODEVICE not supported on this platform; ignoring iface bind.")
                else:
                    iface_bytes = self.IFACE.encode("utf-8") + b"\0"
                    self.ssock.setsockopt(SOL_SOCKET, so_bind, iface_bytes)
                    self.csock.setsockopt(SOL_SOCKET, so_bind, iface_bytes)

            try:
                self.ssock.bind(('', self.port))
            except Exception as e:
                print("WARNING: Failed to bind %s:%d: %s" % (self.ip, self.port, e))
            try:
                self.ssock.setsockopt(IPPROTO_IP, IP_ADD_MEMBERSHIP, self.mreq)
            except Exception as e:
                print('WARNING: Failed to join multicast group:', e)
        except Exception as e:
            print("Failed to initialize UPNP sockets:", e)
            return False
        return True

    # Clean up file/socket descriptors
    def cleanup(self):
        if self.LOG_FILE is not False:
            self.LOG_FILE.close()
        try:
            self.csock.close()
        except Exception:
            pass
        try:
            self.ssock.close()
        except Exception:
            pass

    # Send network data
    def send(self, data, socket_obj):
        # By default, use the client socket that's part of this class
        if socket_obj is False:
            socket_obj = self.csock
        try:
            # Python 3 sockets want bytes
            if isinstance(data, str):
                data = data.encode("utf-8", "surrogateescape")
            socket_obj.sendto(data, (self.ip, self.port))
            return True
        except Exception as e:
            print("SendTo method failed for %s:%d : %s" % (self.ip, self.port, e))
            return False

    # Receive network data
    def recv(self, size, socket_obj):
        if socket_obj is False:
            socket_obj = self.ssock

        if self.TIMEOUT:
            socket_obj.setblocking(0)
            ready = select.select([socket_obj], [], [], self.TIMEOUT)[0]
        else:
            socket_obj.setblocking(1)
            ready = True

        try:
            if ready:
                data = socket_obj.recv(size)  # bytes
                # SSDP looks like HTTP headers; latin-1 preserves bytes
                return data.decode("iso-8859-1", "replace")
            else:
                return False
        except Exception:
            return False

    # Create new UDP socket on ip, bound to port
    def createNewListener(self, ip, port):
        try:
            newsock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)
            newsock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
            # BSD systems also need to set SO_REUSEPORT
            try:
                newsock.setsockopt(SOL_SOCKET, SO_REUSEPORT, 1)
            except Exception:
                pass
            newsock.bind((ip, port))
            return newsock
        except Exception:
            return False

    # Return the class's primary server socket
    def listener(self):
        return self.ssock

    # Return the class's primary client socket
    def sender(self):
        return self.csock

    # Parse a URL, return the host and the page
    def parseURL(self, url):
        delim = '://'
        host = False
        page = False

        # Split the host and page
        try:
            (host, page) = url.split(delim)[1].split('/', 1)
            page = '/' + page
        except Exception:
            # If '://' is not in the url, then it's not a full URL, so assume that it's just a relative path
            page = url

        return (host, page)

    # Pull the name of the device type from a device type string
    def parseDeviceTypeName(self, string):
        delim1 = 'device:'
        delim2 = ':'

        if delim1 in string and not string.endswith(delim1):
            return string.split(delim1)[1].split(delim2, 1)[0]
        return False

    # Pull the name of the service type from a service type string
    def parseServiceTypeName(self, string):
        delim1 = 'service:'
        delim2 = ':'

        if delim1 in string and not string.endswith(delim1):
            return string.split(delim1)[1].split(delim2, 1)[0]
        return False

    # Pull the header info for the specified HTTP header - case insensitive
    def parseHeader(self, data, header):
        delimiter = "%s:" % header
        defaultRet = False

        lowerDelim = delimiter.lower()
        dataArray = data.split("\r\n")

        # Loop through each line of the headers
        for line in dataArray:
            lowerLine = line.lower()
            # Does this line start with the header we're looking for?
            if lowerLine.startswith(lowerDelim):
                try:
                    return line.split(':', 1)[1].strip()
                except Exception:
                    print("Failure parsing header data for %s" % header)
        return defaultRet

    # Extract the contents of a single XML tag from the data
    def extractSingleTag(self, data, tag):
        startTag = "<%s" % tag
        endTag = "</%s>" % tag

        try:
            tmp = data.split(startTag)[1]
            index = tmp.find('>')
            if index != -1:
                index += 1
                return tmp[index:].split(endTag)[0].strip()
        except Exception:
            pass
        return None

    # Parses SSDP notify and reply packets, and populates the ENUM_HOSTS dict
    def parseSSDPInfo(self, data, showUniq, verbose):
        hostFound = False
        messageType = False
        xmlFile = False
        host = False
        page = False
        upnpType = None
        knownHeaders = {
            'NOTIFY': 'notification',
            'HTTP/1.1 200 OK': 'reply'
        }

        # Use the class defaults if these aren't specified
        if showUniq is False:
            showUniq = self.UNIQ
        if verbose is False:
            verbose = self.VERBOSE

        if not data:
            return False

        # Is the SSDP packet a notification, a reply, or neither?
        for text, mt in knownHeaders.items():
            if data.upper().startswith(text):
                messageType = mt
                break

        # If this is a notification or a reply message...
        if messageType is not False:
            # Get the host name and location of its main UPNP XML file
            xmlFile = self.parseHeader(data, "LOCATION")
            upnpType = self.parseHeader(data, "SERVER")
            (host, page) = self.parseURL(xmlFile) if xmlFile else (False, False)

            # Sanity check to make sure we got all the info we need
            if xmlFile is False or host is False or page is False:
                print('ERROR parsing recieved header:')
                print(self.STARS)
                print(data)
                print(self.STARS)
                print('')
                return False

            # Get the protocol in use (i.e., http, https, etc)
            protocol = xmlFile.split('://')[0] + '://'

            # Check if we've seen this host before
            for hostID, hostInfo in self.ENUM_HOSTS.items():
                if hostInfo['name'] == host:
                    hostFound = True
                    if self.UNIQ:
                        return False

            if (hostFound and not self.UNIQ) or not hostFound:
                # Get the new host's index number and create an entry in ENUM_HOSTS
                index = len(self.ENUM_HOSTS)
                self.ENUM_HOSTS[index] = {
                    'name': host,
                    'dataComplete': False,
                    'proto': protocol,
                    'xmlFile': xmlFile,
                    'serverType': None,
                    'upnpServer': upnpType,
                    'deviceList': {}
                }
                # Update completer
                self.updateCmdCompleter(self.ENUM_HOSTS)

            # Print out some basic device info
            print(self.STARS)
            print("SSDP %s message from %s" % (messageType, host))

            if xmlFile:
                print("XML file is located at %s" % xmlFile)

            if upnpType:
                print("Device is running %s" % upnpType)

            print(self.STARS)
            print('')

            return True

        return False

    # Send GET request for a UPNP XML file
    def getXML(self, url):
        headers = {
            'USER-AGENT': 'uPNP/' + self.UPNP_VERSION,
            'CONTENT-TYPE': 'text/xml; charset="utf-8"'
        }

        try:
            req = urllib.request.Request(url, None, headers)
            with urllib.request.urlopen(req) as response:
                output = response.read()  # bytes
                resp_headers = response.info()
            return (resp_headers, output)
        except Exception as e:
            print("Request for '%s' failed: %s" % (url, e))
            return (False, False)

    # Send SOAP request
    def sendSOAP(self, hostName, serviceType, controlURL, actionName, actionArguments):
        argList = ''

        if '://' in controlURL:
            urlArray = controlURL.split('/', 3)
            if len(urlArray) < 4:
                controlURL = '/'
            else:
                controlURL = '/' + urlArray[3]

        soapRequest = 'POST %s HTTP/1.1\r\n' % controlURL

        # Check if a port number was specified in the host name; default is port 80
        if ':' in hostName:
            hostNameArray = hostName.split(':')
            host = hostNameArray[0]
            try:
                port = int(hostNameArray[1])
            except Exception:
                print('Invalid port specified for host connection:', hostNameArray[1])
                return False
        else:
            host = hostName
            port = 80

        # Create a string containing all of the SOAP action's arguments and values
        for arg, (val, dt) in actionArguments.items():
            argList += '<%s>%s</%s>' % (arg, val, arg)

        # Create the SOAP request
        soapBody = (
            '<?xml version="1.0"?>\n'
            '<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">\n'
            '<SOAP-ENV:Body>\n'
            '\t<m:%s xmlns:m="%s">\n'
            '%s\n'
            '\t</m:%s>\n'
            '</SOAP-ENV:Body>\n'
            '</SOAP-ENV:Envelope>' % (actionName, serviceType, argList, actionName)
        )

        body_bytes = soapBody.encode("utf-8")
        # Specify the headers to send with the request
        headers = {
            'Host': hostName,
            'Content-Length': str(len(body_bytes)),
            'Content-Type': 'text/xml; charset="utf-8"',
            'SOAPAction': '"%s#%s"' % (serviceType, actionName),
            'Connection': 'close',
        }

        # Generate the final payload
        for head, value in headers.items():
            soapRequest += '%s: %s\r\n' % (head, value)
        soapRequest += '\r\n%s' % soapBody

        # Send data and go into receive loop
        sock = None
        try:
            sock = socket(AF_INET, SOCK_STREAM)
            sock.connect((host, port))

            if self.DEBUG:
                print(self.STARS)
                print(soapRequest)
                print(self.STARS)
                print('')

            sock.sendall(soapRequest.encode("utf-8"))

            resp = bytearray()
            while True:
                chunk = sock.recv(self.MAX_RECV)
                if not chunk:
                    break
                resp.extend(chunk)
                if b":envelope>" in bytes(resp).lower():
                    break

            sock.close()

            raw = bytes(resp)
            if b"\r\n\r\n" in raw:
                header_bytes, body_bytes = raw.split(b"\r\n\r\n", 1)
            else:
                header_bytes, body_bytes = raw, b""

            header = header_bytes.decode("iso-8859-1", "replace")
            body = body_bytes.decode("utf-8", "replace")

            status_line = header.split("\r\n", 1)[0]
            if " 200 " not in status_line:
                print('SOAP request failed with status:', status_line)
                errorMsg = self.extractSingleTag(body, 'errorDescription')
                if errorMsg:
                    print('SOAP error message:', errorMsg)
                return False

            return body

        except Exception as e:
            print('Caught socket exception:', e)
            try:
                if sock:
                    sock.close()
            except Exception:
                pass
            return False
        except KeyboardInterrupt:
            print("")
            try:
                if sock:
                    sock.close()
            except Exception:
                pass
            return False

    # Wrapper function...
    def getHostInfo(self, xmlData, xmlHeaders, index):
        if self.ENUM_HOSTS[index]['dataComplete'] is True:
            return

        if index >= 0 and index < len(self.ENUM_HOSTS):
            try:
                xmlRoot = minidom.parseString(xmlData)
                self.parseDeviceInfo(xmlRoot, index)
                # Python 3: use .get()
                self.ENUM_HOSTS[index]['serverType'] = xmlHeaders.get('Server') if xmlHeaders else None
                self.ENUM_HOSTS[index]['dataComplete'] = True
                return True
            except Exception as e:
                print('Caught exception while getting host info:', e)
        return False

    # Parse device info from the retrieved XML file
    def parseDeviceInfo(self, xmlRoot, index):
        devTag = "device"
        deviceType = "deviceType"
        deviceListEntries = "deviceList"
        deviceTags = ["friendlyName", "modelDescription", "modelName", "modelNumber", "modelURL", "presentationURL", "UDN", "UPC", "manufacturer", "manufacturerURL"]

        # Find all device entries listed in the XML file
        for device in xmlRoot.getElementsByTagName(devTag):
            try:
                # Get the deviceType string
                deviceTypeName = str(device.getElementsByTagName(deviceType)[0].childNodes[0].data)
            except Exception:
                continue

            deviceDisplayName = self.parseDeviceTypeName(deviceTypeName)
            if not deviceDisplayName:
                continue

            deviceEntryPointer = self.ENUM_HOSTS[index][deviceListEntries][deviceDisplayName] = {}
            deviceEntryPointer['fullName'] = deviceTypeName

            for tag in deviceTags:
                try:
                    deviceEntryPointer[tag] = str(device.getElementsByTagName(tag)[0].childNodes[0].data)
                except Exception:
                    if self.VERBOSE:
                        print('Device', deviceEntryPointer['fullName'], 'does not have a', tag)
                    continue

            self.parseServiceList(device, deviceEntryPointer, index)

        return

    # Parse the list of services specified in the XML file
    def parseServiceList(self, xmlRoot, device, index):
        dictName = "services"
        serviceListTag = "serviceList"
        serviceTag = "service"
        serviceNameTag = "serviceType"
        serviceTags = ["serviceId", "controlURL", "eventSubURL", "SCPDURL"]

        try:
            device[dictName] = {}
            for service in xmlRoot.getElementsByTagName(serviceListTag)[0].getElementsByTagName(serviceTag):
                serviceName = str(service.getElementsByTagName(serviceNameTag)[0].childNodes[0].data)
                serviceDisplayName = self.parseServiceTypeName(serviceName)
                if not serviceDisplayName:
                    continue

                serviceEntryPointer = device[dictName][serviceDisplayName] = {}
                serviceEntryPointer['fullName'] = serviceName

                for tag in serviceTags:
                    serviceEntryPointer[tag] = str(service.getElementsByTagName(tag)[0].childNodes[0].data)

                self.parseServiceInfo(serviceEntryPointer, index)
        except Exception as e:
            print('Caught exception while parsing device service list:', e)

    # Parse details about each service (arguments, variables, etc)
    def parseServiceInfo(self, service, index):
        argTags = ['direction', 'relatedStateVariable']
        actionListTag = 'actionList'
        actionTag = 'action'
        nameTag = 'name'
        argumentList = 'argumentList'
        argumentTag = 'argument'

        xmlFile = self.ENUM_HOSTS[index]['proto'] + self.ENUM_HOSTS[index]['name']
        if not xmlFile.endswith('/') and not service['SCPDURL'].startswith('/'):
            try:
                xmlServiceFile = self.ENUM_HOSTS[index]['xmlFile']
                slashIndex = xmlServiceFile.rfind('/')
                xmlFile = xmlServiceFile[:slashIndex] + '/'
            except Exception:
                xmlFile += '/'

        if self.ENUM_HOSTS[index]['proto'] in service['SCPDURL']:
            xmlFile = service['SCPDURL']
        else:
            xmlFile += service['SCPDURL']
        service['actions'] = {}

        (xmlHeaders, xmlData) = self.getXML(xmlFile)
        if not xmlData:
            print('Failed to retrieve service descriptor located at:', xmlFile)
            return False

        try:
            xmlRoot = minidom.parseString(xmlData)

            try:
                actionList = xmlRoot.getElementsByTagName(actionListTag)[0]
            except Exception:
                print('Failed to retrieve action list for service %s!' % service['fullName'])
                return False
            actions = actionList.getElementsByTagName(actionTag)
            if actions == []:
                return False

            for action in actions:
                try:
                    actionName = str(action.getElementsByTagName(nameTag)[0].childNodes[0].data).strip()
                except Exception:
                    print('Failed to obtain service action name (%s)!' % service['fullName'])
                    continue

                service['actions'][actionName] = {}
                service['actions'][actionName]['arguments'] = {}

                try:
                    argList = action.getElementsByTagName(argumentList)[0]
                except Exception:
                    continue

                arguments = argList.getElementsByTagName(argumentTag)
                if arguments == []:
                    if self.VERBOSE:
                        print('Action', actionName, 'has no arguments!')
                    continue

                for argument in arguments:
                    try:
                        argName = str(argument.getElementsByTagName(nameTag)[0].childNodes[0].data)
                    except Exception:
                        print('Failed to get argument name for', actionName)
                        continue
                    service['actions'][actionName]['arguments'][argName] = {}

                    for tag in argTags:
                        try:
                            service['actions'][actionName]['arguments'][argName][tag] = str(argument.getElementsByTagName(tag)[0].childNodes[0].data)
                        except Exception:
                            print('Failed to find tag %s for argument %s!' % (tag, argName))
                            continue

            self.parseServiceStateVars(xmlRoot, service)

        except Exception as e:
            print('Caught exception while parsing Service info for service %s: %s' % (service['fullName'], str(e)))
            return False

        return True

    # Get info about a service's state variables
    def parseServiceStateVars(self, xmlRoot, servicePointer):
        na = 'N/A'
        serviceStateTable = 'serviceStateTable'
        stateVariable = 'stateVariable'
        nameTag = 'name'
        dataType = 'dataType'
        sendEvents = 'sendEvents'
        allowedValueList = 'allowedValueList'
        allowedValue = 'allowedValue'
        allowedValueRange = 'allowedValueRange'
        minimum = 'minimum'
        maximum = 'maximum'

        servicePointer['serviceStateVariables'] = {}

        try:
            stateVars = xmlRoot.getElementsByTagName(serviceStateTable)[0].getElementsByTagName(stateVariable)
        except Exception:
            return False

        for var in stateVars:
            try:
                varName = str(var.getElementsByTagName(nameTag)[0].childNodes[0].data)
            except Exception:
                print('Failed to get service state variable name for service %s!' % servicePointer['fullName'])
                continue

            servicePointer['serviceStateVariables'][varName] = {}
            try:
                servicePointer['serviceStateVariables'][varName]['dataType'] = str(var.getElementsByTagName(dataType)[0].childNodes[0].data)
            except Exception:
                servicePointer['serviceStateVariables'][varName]['dataType'] = na
            try:
                servicePointer['serviceStateVariables'][varName]['sendEvents'] = str(var.getElementsByTagName(sendEvents)[0].childNodes[0].data)
            except Exception:
                servicePointer['serviceStateVariables'][varName]['sendEvents'] = na

            servicePointer['serviceStateVariables'][varName][allowedValueList] = []

            try:
                vals = var.getElementsByTagName(allowedValueList)[0].getElementsByTagName(allowedValue)
            except Exception:
                vals = []
            for val in vals:
                try:
                    servicePointer['serviceStateVariables'][varName][allowedValueList].append(str(val.childNodes[0].data))
                except Exception:
                    pass

            try:
                valList = var.getElementsByTagName(allowedValueRange)[0]
            except Exception:
                valList = None
            if valList is not None:
                servicePointer['serviceStateVariables'][varName][allowedValueRange] = []
                try:
                    servicePointer['serviceStateVariables'][varName][allowedValueRange].append(str(valList.getElementsByTagName(minimum)[0].childNodes[0].data))
                    servicePointer['serviceStateVariables'][varName][allowedValueRange].append(str(valList.getElementsByTagName(maximum)[0].childNodes[0].data))
                except Exception:
                    pass
        return True

    # Update the command completer
    def updateCmdCompleter(self, struct_obj):
        indexOnlyList = {
            'host': ['get', 'details', 'summary'],
            'save': ['info']
        }
        hostCommand = 'host'
        subCommandList = ['info']
        sendCommand = 'send'

        if not self.completer:
            return

        try:
            structPtr = {}
            topLevelKeys = {}
            for key, val in struct_obj.items():
                structPtr[str(key)] = val
                topLevelKeys[str(key)] = None

            for subcmd in subCommandList:
                self.completer.commands[hostCommand][subcmd] = structPtr

            for cmd, data in indexOnlyList.items():
                for subcmd in data:
                    self.completer.commands[cmd][subcmd] = topLevelKeys

            structPtr = {}
            for hostIndex, hostData in struct_obj.items():
                host_key = str(hostIndex)
                structPtr[host_key] = {}
                if 'deviceList' in hostData:
                    for device, deviceData in hostData['deviceList'].items():
                        structPtr[host_key][device] = {}
                        if 'services' in deviceData:
                            for service, serviceData in deviceData['services'].items():
                                structPtr[host_key][device][service] = {}
                                if 'actions' in serviceData:
                                    for action, actionData in serviceData['actions'].items():
                                        structPtr[host_key][device][service][action] = None
            self.completer.commands[hostCommand][sendCommand] = structPtr
        except Exception:
            print("Error updating command completer structure; some command completion features might not work...")
        return


################## Action Functions ######################
# These functions handle user commands from the shell

def msearch(argc, argv, hp):
    defaultST = "upnp:rootdevice"
    st = "schemas-upnp-org"
    myip = ''
    lport = hp.port

    if argc >= 3:
        if argc == 4:
            st = argv[1]
            searchType = argv[2]
            searchName = argv[3]
        else:
            searchType = argv[1]
            searchName = argv[2]
        st = "urn:%s:%s:%s:%s" % (st, searchType, searchName, hp.UPNP_VERSION.split('.')[0])
    else:
        st = defaultST

    request = (
        "M-SEARCH * HTTP/1.1\r\n"
        "HOST:%s:%d\r\n"
        "ST:%s\r\n" % (hp.ip, hp.port, st)
    )
    for header, value in hp.msearchHeaders.items():
        request += header + ':' + value + "\r\n"
    request += "\r\n"

    print("Entering discovery mode for '%s', Ctl+C to stop..." % st)
    print('')

    server = hp.createNewListener(myip, lport)
    if server is False:
        print('Failed to bind port %d' % lport)
        return

    hp.send(request, server)
    count = 0
    start = time.time()

    while True:
        try:
            if hp.MAX_HOSTS > 0 and count >= hp.MAX_HOSTS:
                break

            if hp.TIMEOUT > 0 and (time.time() - start) > hp.TIMEOUT:
                raise Exception("Timeout exceeded")

            if hp.parseSSDPInfo(hp.recv(1024, server), False, False):
                count += 1

        except Exception:
            print('\nDiscover mode halted...')
            break


def pcap(argc, argv, hp):
    print('Entering passive mode, Ctl+C to stop...')
    print('')

    count = 0
    start = time.time()

    while True:
        try:
            if hp.MAX_HOSTS > 0 and count >= hp.MAX_HOSTS:
                break

            if hp.TIMEOUT > 0 and (time.time() - start) > hp.TIMEOUT:
                raise Exception("Timeout exceeded")

            if hp.parseSSDPInfo(hp.recv(1024, False), False, False):
                count += 1

        except Exception:
            print("\nPassive mode halted...")
            break


def head(argc, argv, hp):
    if argc >= 2:
        action = argv[1]
        if action == 'show':
            for header, value in hp.msearchHeaders.items():
                print(header, ':', value)
            return
        elif action == 'del':
            if argc == 3:
                header = argv[2]
                if header in hp.msearchHeaders:
                    del hp.msearchHeaders[header]
                    print('%s removed from header list' % header)
                    return
                else:
                    print('%s is not in the current header list' % header)
                    return
        elif action == 'set':
            if argc == 4:
                header = argv[2]
                value = argv[3]
                hp.msearchHeaders[header] = value
                print("Added header: '%s:%s" % (header, value))
                return

    showHelp(argv[0])


def set(argc, argv, hp):
    if argc >= 2:
        action = argv[1]
        if action == 'uniq':
            hp.UNIQ = toggleVal(hp.UNIQ)
            print("Show unique hosts set to: %s" % hp.UNIQ)
            return
        elif action == 'debug':
            hp.DEBUG = toggleVal(hp.DEBUG)
            print("Debug mode set to: %s" % hp.DEBUG)
            return
        elif action == 'verbose':
            hp.VERBOSE = toggleVal(hp.VERBOSE)
            print("Verbose mode set to: %s" % hp.VERBOSE)
            return
        elif action == 'version':
            if argc == 3:
                hp.UPNP_VERSION = argv[2]
                print('UPNP version set to: %s' % hp.UPNP_VERSION)
            else:
                showHelp(argv[0])
            return
        elif action == 'iface':
            if argc == 3:
                hp.IFACE = argv[2]
                print('Interface set to %s, re-binding sockets...' % hp.IFACE)
                if hp.initSockets(hp.ip, hp.port, hp.IFACE):
                    print('Interface change successful!')
                else:
                    print('Failed to bind new interface - are you sure you have root privilages??')
                    hp.IFACE = None
                return
        elif action == 'socket':
            if argc == 3:
                try:
                    (ip, port) = argv[2].split(':')
                    port = int(port)
                    hp.ip = ip
                    hp.port = port
                    hp.cleanup()
                    if hp.initSockets(ip, port, hp.IFACE) is False:
                        print("Setting new socket %s:%d failed!" % (ip, port))
                    else:
                        print("Using new socket: %s:%d" % (ip, port))
                except Exception as e:
                    print('Caught exception setting new socket:', e)
                return
        elif action == 'timeout':
            if argc == 3:
                try:
                    hp.TIMEOUT = int(argv[2])
                except Exception as e:
                    print('Caught exception setting new timeout value:', e)
                return
        elif action == 'max':
            if argc == 3:
                try:
                    hp.MAX_HOSTS = int(argv[2])
                except Exception as e:
                    print('Caught exception setting new max host value:', e)
                return
        elif action == 'show':
            print('Multicast IP:          ', hp.ip)
            print('Multicast port:        ', hp.port)
            print('Network interface:     ', hp.IFACE)
            print('Receive timeout:       ', hp.TIMEOUT)
            print('Host discovery limit:  ', hp.MAX_HOSTS)
            print('Number of known hosts: ', len(hp.ENUM_HOSTS))
            print('UPNP version:          ', hp.UPNP_VERSION)
            print('Debug mode:            ', hp.DEBUG)
            print('Verbose mode:          ', hp.VERBOSE)
            print('Show only unique hosts:', hp.UNIQ)
            print('Using log file:        ', hp.LOG_FILE)
            return

    showHelp(argv[0])
    return


def host(argc, argv, hp):
    indexError = "Host index out of range. Try the 'host list' command to get a list of known hosts"

    if argc >= 2:
        action = argv[1]
        if action == 'list':
            if len(hp.ENUM_HOSTS) == 0:
                print("No known hosts - try running the 'msearch' or 'pcap' commands")
                return
            for index, hostInfo in hp.ENUM_HOSTS.items():
                print("\t[%d] %s" % (index, hostInfo['name']))
            return

        elif action == 'get':
            if argc == 3:
                try:
                    index = int(argv[2])
                    hostInfo = hp.ENUM_HOSTS[index]
                except Exception:
                    print(indexError)
                    return

                if hostInfo['dataComplete'] is True:
                    print('Data for this host has already been enumerated!')
                    return

                try:
                    print("Requesting device and service info for %s (this could take a few seconds)..." % hostInfo['name'])
                    print('')
                    (xmlHeaders, xmlData) = hp.getXML(hostInfo['xmlFile'])
                    if xmlData is False:
                        print('Failed to request host XML file:', hostInfo['xmlFile'])
                        return
                    if hp.getHostInfo(xmlData, xmlHeaders, index) is False:
                        print("Failed to get device/service info for %s..." % hostInfo['name'])
                        return
                    print('Host data enumeration complete!')
                    hp.updateCmdCompleter(hp.ENUM_HOSTS)
                    return
                except KeyboardInterrupt:
                    print("")
                    return

    showHelp(argv[0])
    return


def save(argc, argv, hp):
    suffix = '%s_%s.mir'
    uniqName = ''
    saveType = ''
    fnameIndex = 3

    if argc >= 2:
        if argv[1] == 'help':
            showHelp(argv[0])
            return
        elif argv[1] == 'data':
            saveType = 'struct'
            index = argv[2] if argc == 3 else 'data'
        elif argv[1] == 'info':
            saveType = 'info'
            fnameIndex = 4
            if argc >= 3:
                try:
                    index = int(argv[2])
                except Exception:
                    print('Host index is not a number!')
                    showHelp(argv[0])
                    return
            else:
                showHelp(argv[0])
                return

        if argc == fnameIndex:
            uniqName = argv[fnameIndex - 1]
        else:
            uniqName = str(index)
    else:
        showHelp(argv[0])
        return

    fileName = suffix % (saveType, uniqName)
    if os.path.exists(fileName):
        print("File '%s' already exists! Please try again..." % fileName)
        return
    if saveType == 'struct':
        try:
            with open(fileName, 'wb') as fp:
                pickle.dump(hp.ENUM_HOSTS, fp)
            print("Host data saved to '%s'" % fileName)
        except Exception as e:
            print('Caught exception saving host data:', e)
    elif saveType == 'info':
        try:
            with open(fileName, 'w', encoding='utf-8', errors='replace') as fp:
                hp.showCompleteHostInfo(index, fp)
            print("Host info for '%s' saved to '%s'" % (hp.ENUM_HOSTS[index]['name'], fileName))
        except Exception as e:
            print('Failed to save host info:', e)
            return
    else:
        showHelp(argv[0])

    return


def load(argc, argv, hp):
    if argc == 2 and argv[1] != 'help':
        loadFile = argv[1]
        try:
            with open(loadFile, 'rb') as fp:
                hp.ENUM_HOSTS = pickle.load(fp)
            hp.updateCmdCompleter(hp.ENUM_HOSTS)
            print('Host data restored:')
            print('')
            host(2, ['host', 'list'], hp)
            return
        except Exception as e:
            print('Caught exception while restoring host data:', e)

    showHelp(argv[0])


def log(argc, argv, hp):
    if argc == 2:
        logFile = argv[1]
        try:
            fp = open(logFile, 'a', encoding='utf-8', errors='replace')
        except Exception as e:
            print('Failed to open %s for logging: %s' % (logFile, e))
            return
        try:
            hp.LOG_FILE = fp
            ts = list(time.localtime())
            theTime = "%d-%d-%d, %d:%d:%d" % (ts[0], ts[1], ts[2], ts[3], ts[4], ts[5])
            hp.LOG_FILE.write("\n### Logging started at: %s ###\n" % theTime)
        except Exception as e:
            print("Cannot write to file '%s': %s" % (logFile, e))
            hp.LOG_FILE = False
            return
        print("Commands will be logged to: '%s'" % logFile)
        return
    showHelp(argv[0])


def help(argc, argv, hp):
    showHelp(False)


def debug(argc, argv, hp):
    command = ''
    if hp.DEBUG is False:
        print('Debug is disabled! To enable, try the set command...')
        return
    if argc == 1:
        showHelp(argv[0])
    else:
        for cmd in argv[1:]:
            command += cmd + ' '
        command = command.strip()
        print(eval(command))
    return


def exit(argc, argv, hp):
    quit(argc, argv, hp)


def quit(argc, argv, hp):
    if argc == 2 and argv[1] == 'help':
        showHelp(argv[0])
        return
    print('Bye!')
    print('')
    hp.cleanup()
    sys.exit(0)


def showHelp(command):
    helpInfo = {
        'help': {'longListing': 'Usage:\n\t%s\n\t<command> help', 'quickView': 'Show program help'},
        'quit': {'longListing': 'Usage:\n\t%s', 'quickView': 'Exit this shell'},
        'exit': {'longListing': 'Usage:\n\t%s', 'quickView': 'Exit this shell'},
        'save': {'longListing': 'Usage:\n\t%s <data | info <host#>> [file prefix]', 'quickView': 'Save current host data to file'},
        'set': {'longListing': 'Usage:\n\t%s <show | uniq | debug | verbose | version <version #> | iface <interface> | socket <ip:port> | timeout <seconds> | max <count> >',
                'quickView': 'Show/define application settings'},
        'head': {'longListing': 'Usage:\n\t%s <show | del <header> | set <header>  <value>>', 'quickView': 'Show/define SSDP headers'},
        'host': {'longListing': 'Usage:\n\t%s <list | get>', 'quickView': 'View and send host list and host information'},
        'pcap': {'longListing': 'Usage:\n\t%s', 'quickView': 'Passively listen for UPNP hosts'},
        'msearch': {'longListing': 'Usage:\n\t%s [device | service] [<device name> | <service name>]', 'quickView': 'Actively locate UPNP hosts'},
        'load': {'longListing': 'Usage:\n\t%s <file name>', 'quickView': 'Restore previous host data from file'},
        'log': {'longListing': 'Usage:\n\t%s <log file name>', 'quickView': 'Logs user-supplied commands to a log file'}
    }

    try:
        print(helpInfo[command]['longListing'] % command)
    except Exception:
        for cmd, cmdHelp in helpInfo.items():
            print("%s\t\t%s" % (cmd, cmdHelp['quickView']))


def usage():
    print("""
Command line usage: %s [OPTIONS]

    -s <struct file>    Load previous host data from struct file
    -l <log file>       Log user-supplied commands to log file
    -i <interface>      Specify the name of the interface to use (Linux only, requires root)
    -b <batch file>     Process commands from a file
    -u                 Disable show-uniq-hosts-only option
    -d                 Enable debug mode
    -v                 Enable verbose mode
    -h                 Show help
""" % sys.argv[0])
    sys.exit(1)


def parseCliOpts(argc, argv, hp):
    try:
        opts, args = getopt.getopt(argv[1:], 's:l:i:b:udvh')
    except getopt.GetoptError as e:
        print('Usage Error:', e)
        usage()
    else:
        for (opt, arg) in opts:
            if opt == '-s':
                print('')
                load(2, ['load', arg], hp)
                print('')
            elif opt == '-l':
                print('')
                log(2, ['log', arg], hp)
                print('')
            elif opt == '-u':
                hp.UNIQ = toggleVal(hp.UNIQ)
            elif opt == '-d':
                hp.DEBUG = toggleVal(hp.DEBUG)
                print('Debug mode enabled!')
            elif opt == '-v':
                hp.VERBOSE = toggleVal(hp.VERBOSE)
                print('Verbose mode enabled!')
            elif opt == '-b':
                hp.BATCH_FILE = open(arg, 'r', encoding='utf-8', errors='replace')
                print("Processing commands from '%s'..." % arg)
            elif opt == '-h':
                usage()
            elif opt == '-i':
                networkInterfaces = []
                requestedInterface = arg
                interfaceName = None
                found = False

                try:
                    if platform.system() != 'Windows':
                        fp = open('/proc/net/dev', 'r')
                        for line in fp.readlines():
                            if ':' in line:
                                interfaceName = line.split(':')[0].strip()
                                if interfaceName == requestedInterface:
                                    found = True
                                    break
                                else:
                                    networkInterfaces.append(line.split(':')[0].strip())
                        fp.close()
                    else:
                        networkInterfaces.append('Run ipconfig to get a list of available network interfaces!')
                except Exception as e:
                    print('Error opening file:', e)
                    print("If you aren't running Linux, this file may not exist!")

                if not found and len(networkInterfaces) > 0:
                    print("Failed to find interface '%s'; try one of these:\n" % requestedInterface)
                    for iface in networkInterfaces:
                        print(iface)
                    print('')
                    sys.exit(1)
                else:
                    if not hp.initSockets(False, False, interfaceName):
                        print('Binding to interface %s failed; are you sure you have root privilages??' % interfaceName)


def toggleVal(val):
    return not bool(val)


def getUserInput(hp, shellPrompt):
    defaultShellPrompt = 'upnp> '

    if hp.BATCH_FILE is not None:
        return getFileInput(hp)

    if shellPrompt is False:
        shellPrompt = defaultShellPrompt

    try:
        uInput = input(shellPrompt).strip()
        argv = uInput.split()
        argc = len(argv)
    except KeyboardInterrupt:
        print('\n')
        if shellPrompt == defaultShellPrompt:
            quit(0, [], hp)
        return (0, None)

    if hp.LOG_FILE is not False:
        try:
            hp.LOG_FILE.write("%s\n" % uInput)
        except Exception:
            print('Failed to log data to log file!')

    return (argc, argv)


def getFileInput(hp):
    line = hp.BATCH_FILE.readline()
    if line:
        line = line.strip()
        argv = line.split()
        argc = len(argv)
    else:
        hp.BATCH_FILE.close()
        hp.BATCH_FILE = None
        argv = []
        argc = 0
    return (argc, argv)


def main(argc, argv):
    appCommands = {
        'help': {'help': None},
        'quit': {'help': None},
        'exit': {'help': None},
        'save': {'data': None, 'info': None, 'help': None},
        'load': {'help': None},
        'set': {'uniq': None, 'socket': None, 'show': None, 'iface': None, 'debug': None, 'version': None, 'verbose': None, 'timeout': None, 'max': None, 'help': None},
        'head': {'set': None, 'show': None, 'del': None, 'help': None},
        'host': {'list': None, 'get': None, 'help': None},
        'pcap': {'help': None},
        'msearch': {'device': None, 'service': None, 'help': None},
        'log': {'help': None},
        'debug': {'command': None, 'help': None}
    }

    for file in os.listdir(os.getcwd()):
        appCommands['load'][file] = None

    hp = upnp(False, False, None, appCommands)

    readline.parse_and_bind("tab: complete")
    readline.set_completer(hp.completer.complete)

    hp.UNIQ = True
    hp.VERBOSE = False

    parseCliOpts(argc, argv, hp)

    while True:
        if hp.BATCH_FILE is not None:
            (argc2, argv2) = getFileInput(hp)
        else:
            (argc2, argv2) = getUserInput(hp, False)
        if argc2 == 0:
            continue
        action = argv2[0]
        funcPtr = False

        print('')
        try:
            if action in appCommands:
                funcPtr = eval(action)
        except Exception:
            funcPtr = False

        if callable(funcPtr):
            if argc2 == 2 and argv2[1] == 'help':
                showHelp(argv2[0])
            else:
                try:
                    funcPtr(argc2, argv2, hp)
                except KeyboardInterrupt:
                    print('\nAction interrupted by user...')
            print('')
            continue
        print('Invalid command. Valid commands are:')
        print('')
        showHelp(False)
        print('')


if __name__ == "__main__":
    try:
        print('')
        print('Miranda v1.4')
        print('The interactive UPnP client')
        print('Craig Heffner, https://www.devttys0.com')
        print('')
        main(len(sys.argv), sys.argv)
    except Exception as e:
        print('Caught main exception:', e)
        sys.exit(1)
