# -*- coding: utf-8 -*-
# Licensed under the MIT license
# http://opensource.org/licenses/mit-license.php
# Copyright 2018, Pol Canelles <canellestudi@gmail.com>
'''
WebUI
=====
The :class:`WebUI` is used to enable an html interface where the user can
browse the devices content via web browser. By default, the WebUI interface
is disabled and could be enabled from config file or by config dictionary when
initializing :class:`~coherence.base.Coherence`
.. versionchanged:: 0.8.2
.. note:: Be aware that the browser should support Web Sockets and js enabled.
All modern browsers should have this features integrated (tested with
firefox and google chrome).
.. warning:: Don't create your web server into port 9000. This is reserved for
the js WebSocket.
WebUi Example
-------------
A simple server with *web-ui* enabled::
from coherence.base import Coherence
from coherence.upnp.core.uuid import UUID
from twisted.internet import reactor
new_uuid = UUID()
coherence = Coherence(
{'web-ui': 'yes',
'serverport': '9001',
'logmode': 'info',
'controlpoint': 'yes',
'plugin': {'backend': 'FSStore',
'name': 'WEB UI FSStore',
'content': 'path-to-a-directory-with-media-content',
'uuid': new_uuid
}
}
)
reactor.run()
'''
from os.path import dirname, join, exists
import json
from twisted.web.template import (
Element, renderer, flatten,
XMLFile, XMLString, tags, TagLoader)
from twisted.web import server, resource
from twisted.web import static
from twisted.python import util
from twisted.python.filepath import FilePath
from zope.interface import interface
from autobahn.twisted.websocket import (
WebSocketServerFactory, WebSocketServerProtocol)
from coherence import __version__
from coherence import log
TEMPLATES_DIR = join(dirname(__file__), 'templates')
TEMPLATE_INDEX = FilePath(join(TEMPLATES_DIR, 'template_index.xml'))
template_menu_item = '''\
<ul class="text-center">
<li class="nav-logo"></li>
<li xmlns:t="http://twistedmatrix.com/ns/twisted.web.template/0.1"
t:render="menu_elements">
<t:attr name="class"><t:slot name="menu_class" /></t:attr>
<a class="tablink" href="#" t:render="name">
<t:attr name="id"><t:slot name="menu_id" /></t:attr>
<t:attr name="onclick"><t:slot name="menu_click" /></t:attr>
</a>
</li>
</ul>
'''
[docs]class WSBroadcastServerProtocol(WebSocketServerProtocol):
'''
WSBroadcastServerProtocol deals with the async WebSocket client connection.
.. versionadded:: 0.8.2
.. versionchanged:: 0.9.0
Migrated from louie/dispatcher to EventDispatcher
.. note:: We can attach a callback into the variable message_callback, this
callback will be triggered whenever onMessage is called.
'''
factory = None
message_callback = None
[docs] def onMessage(self, payload, isBinary):
self.factory.broadcast(payload.decode('utf-8'))
if self.message_callback is not None:
self.message_callback(payload, isBinary)
[docs] def onOpen(self):
self.factory.register(self)
[docs] def connectionLost(self, reason):
WebSocketServerProtocol.connectionLost(self, reason)
self.factory.unregister(self)
[docs]class WSBroadcastServerFactory(WebSocketServerFactory):
'''
WSBroadcastServerFactory is the central WebSocket server side component
shared between connections.
.. versionadded:: 0.8.2
'''
def __init__(self, client_tracker):
WebSocketServerFactory.__init__(self)
self.client_tracker = client_tracker
[docs] def register(self, client):
self.client_tracker.register(client)
[docs] def unregister(self, client):
self.client_tracker.unregister(client)
[docs] def broadcast(self, msg):
# print(f'WSBroadcastServerFactory: {msg}')
for c in self.client_tracker.clients:
c.sendMessage(msg.encode('utf8'), isBinary=False)
[docs]class WSClientTracker:
'''
Helper to keep track of connections,
accessed by the sync and async methods.
.. versionadded:: 0.8.2
'''
def __init__(self):
self.clients = []
[docs] def register(self, client):
if client not in self.clients:
self.clients.append(client)
[docs] def unregister(self, client):
if client in self.clients:
self.clients.remove(client)
[docs]class DevicesWatcher(log.LogAble):
'''
To manage the connected devices. Broadcast messages informing about the
connected/disconnected devices via the web socket interface. This messages
can be received by the html/js side, which will be responsible to add or
to remove the devices.
Args:
page (object): An instance of :class:`~coherence.web.ui.WebUI`.
.. versionadded:: 0.8.2
'''
addSlash = False
isLeaf = True
detected = []
def __init__(self, page):
log.LogAble.__init__(self)
self.factory = page.factory
self.coherence = page.coherence
[docs] def add_device(self, device):
self.info(f'DevicesWatcher found device {device.get_usn()} '
f'{device.get_friendly_name()} of type '
f'{device.get_device_type()}')
c = self.coherence
if device.location:
link = join(
dirname(device.get_location().decode('utf-8')),
'0') # here we force to navigate into the Content folder
else:
link = \
f'http://{device.host}:{c.web_server_port}/' \
f'{device.udn.replace("uuid:", "")}',
dev = {'type': 'add-device',
'name': device.get_markup_name(),
'usn': device.get_usn(),
'link': link,
}
if (device.get_friendly_name(), device.get_usn()) not in self.detected:
self.detected.append(
(device.get_friendly_name(), device.get_usn()))
self.factory.broadcast(json.dumps(dev))
[docs] def remove_device(self, usn):
self.info(f'DevicesWatcher remove device {usn}')
dev = {'type': 'remove-device',
'usn': usn,
}
self.factory.broadcast(json.dumps(dev))
for d, u in self.detected[:]:
if u == usn:
self.detected.remove((d, u))
break
[docs] def going_live(self):
# TODO: Properly implement disconnection calls
# d = self.page.notifyOnDisconnect()
# d.addCallback(self.remove_me)
# d.addErrback(self.remove_me)
devices = []
for device in self.coherence.get_devices():
if device is not None:
# print(device.__dict__)
self.add_device(device)
self.coherence.bind(
coherence_device_detection_completed=self.add_device)
self.coherence.bind(
coherence_device_removed=self.remove_device)
[docs]class LogsWatcher(log.LogAble):
'''
Object that takes control of all known loggers (at init time) and redirects
them into the web-ui interface.
Args:
page (object): An instance of :class:`~coherence.web.ui.WebUI`.
active (bool): Choice to enable disable the web-ui logging system
.. versionadded:: 0.8.2
'''
logCategory = 'webui-logger'
addSlash = False
isLeaf = True
_messages = []
_ws_ready = False
def __init__(self, page, active):
super(LogsWatcher, self).__init__()
self.factory = page.factory
self.coherence = page.coherence
self.active = active
# TODO: Maybe this should be implemented differently:
# we could read from the logfile and extract the last lines
# from the logfile, this way we will make the logging process
# lighter and we will make sure to get all the created loggers
# at anytime, even before this function is initialized.
for k, v in log.loggers.items():
webui_logger = v
webui_logger.log = self.log
webui_logger.warning = self.warning
webui_logger.info = self.info
webui_logger.critical = self.critical
webui_logger.debug = self.debug
webui_logger.error = self.error
webui_logger.exception = self.exception
[docs] def going_live(self):
self.info(f'add a view to the LogsWatcher {self.coherence}')
while len(self._messages) > 0:
m = self._messages.pop(0)
self.factory.broadcast(m)
self._ws_ready = True
[docs] def send_log(self, type, message, *args, **kwargs):
msg = format_log(message, *args, **kwargs)
print(f'webui-{type}: {msg}')
m = json.dumps(
{'type': f'log-{type}',
'data': f'[{type}] {msg}'})
if self._ws_ready:
self.factory.broadcast(m)
else:
self._messages.append(m)
[docs] def log(self, message, *args, **kwargs):
self.send_log('log', message, *args, **kwargs)
[docs] def warning(self, message, *args, **kwargs):
self.send_log('warning', message, *args, **kwargs)
[docs] def info(self, message, *args, **kwargs):
self.send_log('info', message, *args, **kwargs)
[docs] def critical(self, message, *args, **kwargs):
self.send_log('critical', message, *args, **kwargs)
[docs] def debug(self, message, *args, **kwargs):
self.send_log('debug', message, *args, **kwargs)
[docs] def error(self, message, *args, **kwargs):
self.send_log('error', message, *args, **kwargs)
[docs] def exception(self, message, *args, **kwargs):
# self._logger.exception(message, *args, **kwargs)
self.send_log('exception', message, *args, **kwargs)
[docs]class IndexResource(Element, log.LogAble):
'''
A sub class of :class:`twisted.web.template.Element` which represents the
main page for the web-ui interface. This takes care of rendering the main
page as an element template, so we could add some dynamic elements when
initializing it, like the navigation bar or the current version of the
program.
.. versionadded:: 0.8.2
'''
loader = XMLFile(TEMPLATE_INDEX)
def __init__(self, web_resource):
super(IndexResource, self).__init__()
self.resource = web_resource
self.coherence = web_resource.coherence
[docs] @renderer
def version(self, request, data):
return __version__.encode('ascii')
class IWeb(interface.InterfaceClass):
'''
Interface class that allow us to register :class:`~coherence.web.ui.WebUI'
as a new adapter using the `twisted.python.components.registerAdapter`.
.. note:: See :class:`~coherence.base.WebServerUi' for usage.
'''
__module__ = 'zope.interface'
def goingLive(self):
pass
[docs]class Web(object):
'''
Convenient class describing an adapterFactory that allow us to register
:class:`~coherence.web.ui.WebUI' as a new adapter, using the
`twisted.python.components.registerAdapter`
Args:
coherence (object): An instance of `~coherence.base.Coherence`
.. note:: See :class:`~coherence.base.WebServerUi` for usage.
'''
def __init__(self, coherence):
super(Web, self).__init__()
self.coherence = coherence
[docs]class WebUI(resource.Resource, log.LogAble):
'''
A convenient html interface to browse the connected devices via preferred
web browser. This interface could be enabled when initializing
:class:`~coherence.base.Coherence` by setting "'web-ui': 'yes'" into your
config command or via config file using the same key and value.
Args:
coherence (object): An instance of `~coherence.base.Coherence`
.. versionchanged:: 0.8.2
.. warning:: Be aware that the browser should support Web Sockets and to
have js enabled. All modern browsers should have this features
integrated (tested with firefox and google chrome).
'''
logCategory = 'webui'
addSlash = True
isLeaf = False
ws_recived = []
def __init__(self, coherence, *a, **kw):
resource.Resource.__init__(self)
log.LogAble.__init__(self)
self.coherence = coherence
# WebSocket init
self.client_tracker = WSClientTracker()
self.factory = WSBroadcastServerFactory(self.client_tracker)
self.factory.protocol = WSBroadcastServerProtocol
self.factory.protocol.message_callback = self.on_ws_message
# Enable resources
self.putChild(b'styles',
static.File(util.sibpath(__file__, 'static/styles'),
defaultType='text/css'))
self.putChild(b'server-images',
static.File(util.sibpath(__file__, 'static/images'),
defaultType='text/css'))
self.putChild(b'js',
static.File(util.sibpath(__file__, 'static/js'),
defaultType='text/javascript'))
self.devices = DevicesWatcher(self)
self.logging = LogsWatcher(self, 'yes')
self.index = IndexResource(self)
[docs] def on_ws_message(self, payload, isBinary):
self.info(f'on_ws_message: {payload}')
self.ws_recived.append(payload)
if payload == b'WebSocket Ready':
self.devices.going_live()
self.logging.going_live()
[docs] def render(self, request):
request.setHeader(b'content-type', b'text/html; charset=utf-8')
return super(WebUI, self).render(request)
[docs] def render_GET(self, request):
d = flatten(request, self.index, request.write)
def done_index(ignored):
request.finish()
d.addBoth(done_index)
return server.NOT_DONE_YET
[docs] def getChild(self, name, request):
self.info(f'WebUI getChild: {name}')
if name in [b'', b'\'']:
return self
def exist_child(key, children):
if key in children:
# print('\t- found child: ', name)
return children[key]
return None
for na in (name, name.decode('utf-8')):
for ch in (self.children, self.coherence.children):
c = exist_child(na, ch)
if c is not None:
return c
ch = super(WebUI, self).getChild(name, request)
if isinstance(ch, resource.NoResource):
self.warning('not found child, checking static file: ', name)
p = util.sibpath(__file__, name.decode('utf-8'))
self.warning(f'looking for file {p}')
if exists(p):
ch = static.File(p)
return ch
if __name__ == '__main__':
from coherence.base import Coherence
from coherence.upnp.core.uuid import UUID
from twisted.internet import reactor
new_uuid = UUID()
icon_url = 'file://{}'.format(
join(dirname(__file__), 'static',
'images', 'coherence-icon.png'))
coherence = Coherence(
{'unittest': 'no',
'web-ui': 'yes',
'serverport': '9001',
'logmode': 'info',
'controlpoint': 'yes',
'plugin': {'backend': 'FSStore',
'name': 'WEB UI FSStore',
'content': '/media/MEDIA/TVSHOWS', # change path
'uuid': new_uuid,
'icon': {'mimetype': 'image/png',
'width': '256',
'height': '256',
'depth': '24',
'url': icon_url}
}
}
)
reactor.run()