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

4 years ago
  1. # -*- coding: utf-8 -*-
  2. """CLI UPnP client module."""
  3. # pylint: disable=invalid-name
  4. import argparse
  5. import asyncio
  6. import json
  7. import logging
  8. import operator
  9. import sys
  10. import time
  11. import urllib.parse
  12. from typing import Optional
  13. from async_upnp_client import UpnpDevice
  14. from async_upnp_client import UpnpFactory
  15. from async_upnp_client import UpnpService
  16. from async_upnp_client.aiohttp import AiohttpRequester
  17. from async_upnp_client.aiohttp import AiohttpNotifyServer
  18. from async_upnp_client.aiohttp import get_local_ip
  19. from async_upnp_client.dlna import dlna_handle_notify_last_change
  20. from async_upnp_client.discovery import async_discover as async_ssdp_discover
  21. from async_upnp_client.discovery import SSDP_ST_ALL
  22. logging.basicConfig()
  23. _LOGGER = logging.getLogger('upnp-client')
  24. _LOGGER.setLevel(logging.ERROR)
  25. _LOGGER_LIB = logging.getLogger('async_upnp_client')
  26. _LOGGER_LIB.setLevel(logging.ERROR)
  27. _LOGGER_TRAFFIC = logging.getLogger('async_upnp_client.traffic')
  28. _LOGGER_TRAFFIC.setLevel(logging.ERROR)
  29. DEFAULT_PORT = 11302
  30. parser = argparse.ArgumentParser(description='upnp_client')
  31. parser.add_argument('--debug', action='store_true', help='Show debug messages')
  32. parser.add_argument('--debug-traffic', action='store_true', help='Show network traffic')
  33. parser.add_argument('--pprint', action='store_true', help='Pretty-print (indent) JSON output')
  34. parser.add_argument('--timeout', type=int, help='Timeout for connection', default=5)
  35. parser.add_argument('--strict', action='store_true', help='Be strict about invalid data received')
  36. subparsers = parser.add_subparsers(title='Command', dest='command')
  37. subparsers.required = True
  38. subparser = subparsers.add_parser('call-action', help='Call an action')
  39. subparser.add_argument('device', help='URL to device description XML')
  40. subparser.add_argument('call-action', nargs='+', help='service/action param1=val1 param2=val2')
  41. subparser = subparsers.add_parser('subscribe', help='Subscribe to services')
  42. subparser.add_argument('device', help='URL to device description XML')
  43. subparser.add_argument('service', nargs='+', help='service type or part or abbreviation')
  44. subparser.add_argument('--bind', help='ip[:port], e.g., 192.168.0.10:8090')
  45. subparser = subparsers.add_parser('discover', help='Subscribe to services')
  46. subparser.add_argument('--bind', help='ip, e.g., 192.168.0.10')
  47. subparser.add_argument('--service_type', help='service type to discover', default=SSDP_ST_ALL)
  48. args = parser.parse_args()
  49. pprint_indent = 4 if args.pprint else None
  50. event_handler = None
  51. async def create_device(description_url):
  52. """Create UpnpDevice."""
  53. timeout = args.timeout
  54. requester = AiohttpRequester(timeout)
  55. disable_validation = not args.strict
  56. factory = UpnpFactory(requester, disable_state_variable_validation=disable_validation)
  57. return await factory.async_create_device(description_url)
  58. def bind_host_port():
  59. """Determine listening host/port."""
  60. bind = args.bind
  61. if not bind:
  62. # figure out listening host ourselves
  63. target_url = args.device
  64. parsed = urllib.parse.urlparse(target_url)
  65. target_host = parsed.hostname
  66. bind = get_local_ip(target_host)
  67. if ':' not in bind:
  68. bind = bind + ':' + str(DEFAULT_PORT)
  69. return bind.split(':')
  70. def service_from_device(device: UpnpDevice, service_name: str) -> Optional[UpnpService]:
  71. """Get UpnpService from UpnpDevice by name or part or abbreviation."""
  72. for service in device.services.values():
  73. part = service.service_id.split(':')[-1]
  74. abbr = ''.join([c for c in part if c.isupper()])
  75. if service_name in (service.service_type, part, abbr):
  76. return service
  77. return None
  78. def on_event(service, service_variables):
  79. """Handle a UPnP event."""
  80. _LOGGER.debug('State variable change for %s, variables: %s',
  81. service,
  82. ','.join([sv.name for sv in service_variables]))
  83. obj = {
  84. 'timestamp': time.time(),
  85. 'service_id': service.service_id,
  86. 'service_type': service.service_type,
  87. 'state_variables': {sv.name: sv.value for sv in service_variables},
  88. }
  89. print(json.dumps(obj, indent=pprint_indent))
  90. # do some additional handling for DLNA LastChange state variable
  91. if len(service_variables) == 1 and \
  92. service_variables[0].name == 'LastChange':
  93. last_change = service_variables[0]
  94. dlna_handle_notify_last_change(last_change)
  95. async def call_action(description_url, call_action_args):
  96. """Call an action and show results."""
  97. device = await create_device(description_url)
  98. if '/' in call_action_args[0]:
  99. service_name, action_name = call_action_args[0].split('/')
  100. else:
  101. service_name = call_action_args[0]
  102. action_name = ''
  103. for action_arg in call_action_args[1:]:
  104. if '=' not in action_arg:
  105. print('Invalid argument value: %s' % (action_arg, ))
  106. print('Use: Argument=value')
  107. sys.exit(1)
  108. action_args = {a.split('=', 1)[0]: a.split('=', 1)[1] for a in call_action_args[1:]}
  109. # get service
  110. service = service_from_device(device, service_name)
  111. if not service:
  112. print('Unknown service: %s' % (service_name, ))
  113. print('Available services:\n%s' % (
  114. '\n'.join([' ' + service.service_id.split(':')[-1]
  115. for service in device.services.values()])
  116. ))
  117. sys.exit(1)
  118. # get action
  119. action = service.action(action_name)
  120. if not action:
  121. print('Unknown action: %s' % (action_name, ))
  122. print('Available actions:\n%s' % (
  123. '\n'.join([' ' + name for name in sorted(service.actions)])
  124. ))
  125. sys.exit(1)
  126. # get in variables
  127. coerced_args = {}
  128. for key, value in action_args.items():
  129. in_arg = action.argument(key)
  130. if not in_arg:
  131. print('Unknown argument: %s' % (key, ))
  132. print('Available arguments: %s' % (
  133. ','.join([a.name for a in action.in_arguments()])))
  134. sys.exit(1)
  135. coerced_args[key] = in_arg.coerce_python(value)
  136. # ensure all in variables given
  137. for in_arg in action.in_arguments():
  138. if in_arg.name not in action_args:
  139. print('Missing in-arguments')
  140. print('Known in-arguments:\n%s' % (
  141. '\n'.join([' ' + in_arg.name
  142. for in_arg in sorted(action.in_arguments(),
  143. key=operator.attrgetter('name'))])
  144. ))
  145. sys.exit(1)
  146. _LOGGER.debug('Calling %s.%s, parameters:\n%s',
  147. service.service_id, action.name,
  148. '\n'.join(['%s:%s' % (key, value) for key, value in coerced_args.items()]))
  149. result = await action.async_call(**coerced_args)
  150. _LOGGER.debug('Results:\n%s',
  151. '\n'.join(['%s:%s' % (key, value) for key, value in coerced_args.items()]))
  152. obj = {
  153. 'timestamp': time.time(),
  154. 'service_id': service.service_id,
  155. 'service_type': service.service_type,
  156. 'action': action.name,
  157. 'in_parameters': coerced_args,
  158. 'out_parameters': result,
  159. }
  160. print(json.dumps(obj, indent=pprint_indent))
  161. async def subscribe(description_url, service_names):
  162. """Subscribe to service(s) and output updates."""
  163. global event_handler # pylint: disable=global-statement
  164. device = await create_device(description_url)
  165. # start notify server/event handler
  166. host, port = bind_host_port()
  167. server = AiohttpNotifyServer(device.requester, port, listen_host=host)
  168. await server.start_server()
  169. _LOGGER.debug('Listening on: %s', server.callback_url)
  170. # gather all wanted services
  171. if '*' in service_names:
  172. service_names = device.services.keys()
  173. services = []
  174. for service_name in service_names:
  175. service = service_from_device(device, service_name)
  176. if not service:
  177. print('Unknown service: %s' % (service_name, ))
  178. sys.exit(1)
  179. service.on_event = on_event
  180. services.append(service)
  181. # subscribe to services
  182. event_handler = server.event_handler
  183. for service in services:
  184. await event_handler.async_subscribe(service)
  185. # keep the webservice running
  186. while True:
  187. await asyncio.sleep(120)
  188. await event_handler.async_resubscribe_all()
  189. async def discover(discover_args):
  190. """Discover devices."""
  191. timeout = args.timeout
  192. service_type = discover_args.service_type
  193. source_ip = discover_args.bind
  194. async def response_received(response):
  195. response['_timestamp'] = str(response['_timestamp'])
  196. print(json.dumps(response, indent=pprint_indent))
  197. await async_ssdp_discover(service_type=service_type,
  198. source_ip=source_ip,
  199. timeout=timeout,
  200. async_callback=response_received)
  201. async def async_main():
  202. """Asunc main."""
  203. if args.debug:
  204. _LOGGER.setLevel(logging.DEBUG)
  205. _LOGGER_LIB.setLevel(logging.DEBUG)
  206. if args.debug_traffic:
  207. _LOGGER_TRAFFIC.setLevel(logging.DEBUG)
  208. if args.command == 'call-action':
  209. await call_action(args.device, getattr(args, 'call-action'))
  210. elif args.command == 'subscribe':
  211. await subscribe(args.device, args.service)
  212. elif args.command == 'discover':
  213. await discover(args)
  214. def main():
  215. """Main."""
  216. loop = asyncio.get_event_loop()
  217. try:
  218. loop.run_until_complete(async_main())
  219. except KeyboardInterrupt:
  220. if event_handler:
  221. loop.run_until_complete(event_handler.async_unsubscribe_all())
  222. finally:
  223. loop.close()
  224. if __name__ == '__main__':
  225. main()