You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

278 lines
9.5 KiB

# -*- coding: utf-8 -*-
"""CLI UPnP client module."""
# pylint: disable=invalid-name
import argparse
import asyncio
import json
import logging
import operator
import sys
import time
import urllib.parse
from typing import Optional
from async_upnp_client import UpnpDevice
from async_upnp_client import UpnpFactory
from async_upnp_client import UpnpService
from async_upnp_client.aiohttp import AiohttpRequester
from async_upnp_client.aiohttp import AiohttpNotifyServer
from async_upnp_client.aiohttp import get_local_ip
from async_upnp_client.dlna import dlna_handle_notify_last_change
from async_upnp_client.discovery import async_discover as async_ssdp_discover
from async_upnp_client.discovery import SSDP_ST_ALL
logging.basicConfig()
_LOGGER = logging.getLogger('upnp-client')
_LOGGER.setLevel(logging.ERROR)
_LOGGER_LIB = logging.getLogger('async_upnp_client')
_LOGGER_LIB.setLevel(logging.ERROR)
_LOGGER_TRAFFIC = logging.getLogger('async_upnp_client.traffic')
_LOGGER_TRAFFIC.setLevel(logging.ERROR)
DEFAULT_PORT = 11302
parser = argparse.ArgumentParser(description='upnp_client')
parser.add_argument('--debug', action='store_true', help='Show debug messages')
parser.add_argument('--debug-traffic', action='store_true', help='Show network traffic')
parser.add_argument('--pprint', action='store_true', help='Pretty-print (indent) JSON output')
parser.add_argument('--timeout', type=int, help='Timeout for connection', default=5)
parser.add_argument('--strict', action='store_true', help='Be strict about invalid data received')
subparsers = parser.add_subparsers(title='Command', dest='command')
subparsers.required = True
subparser = subparsers.add_parser('call-action', help='Call an action')
subparser.add_argument('device', help='URL to device description XML')
subparser.add_argument('call-action', nargs='+', help='service/action param1=val1 param2=val2')
subparser = subparsers.add_parser('subscribe', help='Subscribe to services')
subparser.add_argument('device', help='URL to device description XML')
subparser.add_argument('service', nargs='+', help='service type or part or abbreviation')
subparser.add_argument('--bind', help='ip[:port], e.g., 192.168.0.10:8090')
subparser = subparsers.add_parser('discover', help='Subscribe to services')
subparser.add_argument('--bind', help='ip, e.g., 192.168.0.10')
subparser.add_argument('--service_type', help='service type to discover', default=SSDP_ST_ALL)
args = parser.parse_args()
pprint_indent = 4 if args.pprint else None
event_handler = None
async def create_device(description_url):
"""Create UpnpDevice."""
timeout = args.timeout
requester = AiohttpRequester(timeout)
disable_validation = not args.strict
factory = UpnpFactory(requester, disable_state_variable_validation=disable_validation)
return await factory.async_create_device(description_url)
def bind_host_port():
"""Determine listening host/port."""
bind = args.bind
if not bind:
# figure out listening host ourselves
target_url = args.device
parsed = urllib.parse.urlparse(target_url)
target_host = parsed.hostname
bind = get_local_ip(target_host)
if ':' not in bind:
bind = bind + ':' + str(DEFAULT_PORT)
return bind.split(':')
def service_from_device(device: UpnpDevice, service_name: str) -> Optional[UpnpService]:
"""Get UpnpService from UpnpDevice by name or part or abbreviation."""
for service in device.services.values():
part = service.service_id.split(':')[-1]
abbr = ''.join([c for c in part if c.isupper()])
if service_name in (service.service_type, part, abbr):
return service
return None
def on_event(service, service_variables):
"""Handle a UPnP event."""
_LOGGER.debug('State variable change for %s, variables: %s',
service,
','.join([sv.name for sv in service_variables]))
obj = {
'timestamp': time.time(),
'service_id': service.service_id,
'service_type': service.service_type,
'state_variables': {sv.name: sv.value for sv in service_variables},
}
print(json.dumps(obj, indent=pprint_indent))
# do some additional handling for DLNA LastChange state variable
if len(service_variables) == 1 and \
service_variables[0].name == 'LastChange':
last_change = service_variables[0]
dlna_handle_notify_last_change(last_change)
async def call_action(description_url, call_action_args):
"""Call an action and show results."""
device = await create_device(description_url)
if '/' in call_action_args[0]:
service_name, action_name = call_action_args[0].split('/')
else:
service_name = call_action_args[0]
action_name = ''
for action_arg in call_action_args[1:]:
if '=' not in action_arg:
print('Invalid argument value: %s' % (action_arg, ))
print('Use: Argument=value')
sys.exit(1)
action_args = {a.split('=', 1)[0]: a.split('=', 1)[1] for a in call_action_args[1:]}
# get service
service = service_from_device(device, service_name)
if not service:
print('Unknown service: %s' % (service_name, ))
print('Available services:\n%s' % (
'\n'.join([' ' + service.service_id.split(':')[-1]
for service in device.services.values()])
))
sys.exit(1)
# get action
action = service.action(action_name)
if not action:
print('Unknown action: %s' % (action_name, ))
print('Available actions:\n%s' % (
'\n'.join([' ' + name for name in sorted(service.actions)])
))
sys.exit(1)
# get in variables
coerced_args = {}
for key, value in action_args.items():
in_arg = action.argument(key)
if not in_arg:
print('Unknown argument: %s' % (key, ))
print('Available arguments: %s' % (
','.join([a.name for a in action.in_arguments()])))
sys.exit(1)
coerced_args[key] = in_arg.coerce_python(value)
# ensure all in variables given
for in_arg in action.in_arguments():
if in_arg.name not in action_args:
print('Missing in-arguments')
print('Known in-arguments:\n%s' % (
'\n'.join([' ' + in_arg.name
for in_arg in sorted(action.in_arguments(),
key=operator.attrgetter('name'))])
))
sys.exit(1)
_LOGGER.debug('Calling %s.%s, parameters:\n%s',
service.service_id, action.name,
'\n'.join(['%s:%s' % (key, value) for key, value in coerced_args.items()]))
result = await action.async_call(**coerced_args)
_LOGGER.debug('Results:\n%s',
'\n'.join(['%s:%s' % (key, value) for key, value in coerced_args.items()]))
obj = {
'timestamp': time.time(),
'service_id': service.service_id,
'service_type': service.service_type,
'action': action.name,
'in_parameters': coerced_args,
'out_parameters': result,
}
print(json.dumps(obj, indent=pprint_indent))
async def subscribe(description_url, service_names):
"""Subscribe to service(s) and output updates."""
global event_handler # pylint: disable=global-statement
device = await create_device(description_url)
# start notify server/event handler
host, port = bind_host_port()
server = AiohttpNotifyServer(device.requester, port, listen_host=host)
await server.start_server()
_LOGGER.debug('Listening on: %s', server.callback_url)
# gather all wanted services
if '*' in service_names:
service_names = device.services.keys()
services = []
for service_name in service_names:
service = service_from_device(device, service_name)
if not service:
print('Unknown service: %s' % (service_name, ))
sys.exit(1)
service.on_event = on_event
services.append(service)
# subscribe to services
event_handler = server.event_handler
for service in services:
await event_handler.async_subscribe(service)
# keep the webservice running
while True:
await asyncio.sleep(120)
await event_handler.async_resubscribe_all()
async def discover(discover_args):
"""Discover devices."""
timeout = args.timeout
service_type = discover_args.service_type
source_ip = discover_args.bind
async def response_received(response):
response['_timestamp'] = str(response['_timestamp'])
print(json.dumps(response, indent=pprint_indent))
await async_ssdp_discover(service_type=service_type,
source_ip=source_ip,
timeout=timeout,
async_callback=response_received)
async def async_main():
"""Asunc main."""
if args.debug:
_LOGGER.setLevel(logging.DEBUG)
_LOGGER_LIB.setLevel(logging.DEBUG)
if args.debug_traffic:
_LOGGER_TRAFFIC.setLevel(logging.DEBUG)
if args.command == 'call-action':
await call_action(args.device, getattr(args, 'call-action'))
elif args.command == 'subscribe':
await subscribe(args.device, args.service)
elif args.command == 'discover':
await discover(args)
def main():
"""Main."""
loop = asyncio.get_event_loop()
try:
loop.run_until_complete(async_main())
except KeyboardInterrupt:
if event_handler:
loop.run_until_complete(event_handler.async_unsubscribe_all())
finally:
loop.close()
if __name__ == '__main__':
main()