From 00f03510cef5a5905dec51244d0107a68c3a8888 Mon Sep 17 00:00:00 2001 From: Cort Buffington Date: Fri, 22 Jun 2018 09:34:14 -0500 Subject: [PATCH] Initial Commit --- .gitignore | 3 + README.md | 40 ++++ config_SAMPLE.py | 18 ++ index_template.html | 83 ++++++++ logfile.log | 0 requirements.txt | 5 + templates/bridge_table.html | 39 ++++ templates/hblink_table.html | 72 +++++++ web_tables.py | 415 ++++++++++++++++++++++++++++++++++++ 9 files changed, 675 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config_SAMPLE.py create mode 100644 index_template.html create mode 100644 logfile.log create mode 100644 requirements.txt create mode 100644 templates/bridge_table.html create mode 100644 templates/hblink_table.html create mode 100755 web_tables.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83021cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.pyc +config.py +*.csv diff --git a/README.md b/README.md new file mode 100644 index 0000000..28f9ca1 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +--- +### FOR SUPPORT, DISCUSSION, GETTING INVOLVED ### + +Please join the DVSwitch group at groups.io for online forum support, discussion, and to become part of the development team. + +DVSwitch@groups.io + +--- + +**Socket-Based Reporting for HBlink** + +Over the years, the biggest request recevied for HBlink (other than call-routing/bridging tools) has been web-based diagnostics and/or statistics for the program. + +I strongly disagree with including the amount of overhead this would require inside HBlink -- which still runs nicely on very modest resources. That it does this, and is in Python is a point of pride for me... Just let me have this one, ok? What I have done is added some hooks to HBlink, which will be expanded over time, whereby it listens on a TCP socket and provides the raw data necessary for a "web dashboard", or really any external logging or statistics gathering program. + +HBmonitor is my take on a "web dashboard" for HBlink. + +***THIS SOFTWARE IS VERY, VERY NEW*** + +Right now, I'm just getting into how this should work, what does work well, what does not... and I am NOT a web applications programmer, so yeah, that javascript stuff is gonna look bad. Know what you're doing? Help me! + +It has now reached a point where folks who know what they're doing can probably make it work reasonably well, so I'm opening up the project to the public. + +***GOALS OF THE PROJECT*** + +Some things I'm going to stick to pretty closely. Here they are: + ++ HBmonitor be one process that includes a webserver ++ Websockets are used for pushing data to the browser - no long-polling, etc. ++ Does not provide data that's easily misunderstood + +***0x49 DE N0MJS*** + +Copyright (C) 2013-2018 Cortney T. Buffington, N0MJS + +This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA diff --git a/config_SAMPLE.py b/config_SAMPLE.py new file mode 100644 index 0000000..6b2af5a --- /dev/null +++ b/config_SAMPLE.py @@ -0,0 +1,18 @@ +REPORT_NAME = 'system.domain.name' # Name of the monitored HBlink system +CONFIG_INC = True # Include HBlink stats +BRIDGES_INC = True # Include Bridge stats (confbrige.py) +DMRLINK_IP = '127.0.0.1' # HBlink's IP Address +DMRLINK_PORT = 4321 # HBlink's TCP reporting socket +FREQUENCY = 10 # Frequency to push updates to web clients +WEB_SERVER_PORT = 8080 # Has to be above 1024 if you're not running as root + +# Files and stuff for loading alias files for mapping numbers to names +PATH = './' # MUST END IN '/' +PEER_FILE = 'peer_ids.csv' # Will auto-download from DMR-MARC +SUBSCRIBER_FILE = 'subscriber_ids.csv' # Will auto-download from DMR-MARC +TGID_FILE = 'talkgroup_ids.csv' # User provided, should be in "integer TGID, TGID name" format +LOCAL_SUB_FILE = 'local_subscriber_ids.csv' # User provided (optional, leave '' if you don't use it), follow the format of DMR-MARC +LOCAL_PEER_FILE = 'local_peer_ids.csv' # User provided (optional, leave '' if you don't use it), follow the format of DMR-MARC +FILE_RELOAD = 7 # Number of days before we reload DMR-MARC database files +PEER_URL = 'http://radioid.net/static/rptrs.csv' +SUBSCRIBER_URL = 'http://radioid.net/static/users.csv' diff --git a/index_template.html b/index_template.html new file mode 100644 index 0000000..b31deb1 --- /dev/null +++ b/index_template.html @@ -0,0 +1,83 @@ + + + + + + +

HBlink Monitoring Server

+

<<>>

+
+ + + +

+
+

Event Log Window:

+

+    
+
\ No newline at end of file
diff --git a/logfile.log b/logfile.log
new file mode 100644
index 0000000..e69de29
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..3a5cd7c
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,5 @@
+Twisted>=12.0.0
+dmr_utils>=0.1.7
+bitstring>=3.1.3
+autobahn>=0.17.2
+jinja2>=2.9.6
\ No newline at end of file
diff --git a/templates/bridge_table.html b/templates/bridge_table.html
new file mode 100644
index 0000000..efd48c3
--- /dev/null
+++ b/templates/bridge_table.html
@@ -0,0 +1,39 @@
+
+

Bridge Group Status Tables:

+{% for _bridge, _bridge_data in _table.iteritems() %} + + + + + + + + + + + +

Conference Bridge: {{ _bridge }}

+ + + + + + + + + + + {% for system, _system_data in _table[_bridge].iteritems() %} + + + + + + + + + + + {% endfor %} +
SystemSlotTGIDStatusTimeoutTimeout ActionConnect TGIDsDisconnect TGIDs
{{ system }}{{ _table[_bridge][system]['TS'] }}{{ _table[_bridge][system]['TGID'] }}{{ _table[_bridge][system]['ACTIVE'] }}{{ _table[_bridge][system]['EXP_TIME'] }}{{ _table[_bridge][system]['TO_ACTION'] }}{{ _table[_bridge][system]['TRIG_ON'] }}{{ _table[_bridge][system]['TRIG_OFF'] }}
+{% endfor %} \ No newline at end of file diff --git a/templates/hblink_table.html b/templates/hblink_table.html new file mode 100644 index 0000000..dc7e7f5 --- /dev/null +++ b/templates/hblink_table.html @@ -0,0 +1,72 @@ +

HBlink Status Tables:

+

Master Systems

+ + + + + + + + + + + + + + + + + + + + + {% for _master in _table['MASTERS'] %} + + + + {% for _client, _cdata in _table['MASTERS'][_master]['CLIENTS'].iteritems() %} + + + + + + + + {% endfor %} + {% endfor %} +
HBP SystemClient Radio IDCallsignConnectionPings
Received
IPPort
{{ _master}} {{ _client }}{{ _cdata['CALLSIGN'] }}{{ _cdata['CONNECTION'] }}{{ _cdata['PINGS_RECEIVED'] }}{{ _cdata['IP'] }}{{ _cdata['PORT'] }}
+ +

Client Systems

+ + + + + + + + + + + + + + + + + + + + + {% for _client in _table['CLIENTS'] %} + + + + + + + + + + + {% endfor %} +
HBP SystemClient Radio IDCallsignConnectionPing SentPing AckMaster
{{ _client}} {{ _table['CLIENTS'][_client]['RADIO_ID'] }}{{ _table['CLIENTS'][_client]['CALLSIGN'] }}{{ _table['CLIENTS'][_client]['STATS']['CONNECTION'] }}{{ _table['CLIENTS'][_client]['STATS']['PINGS_SENT'] }}{{ _table['CLIENTS'][_client]['STATS']['PINGS_ACKD'] }}{{ _table['CLIENTS'][_client]['MASTER_IP'] }}
diff --git a/web_tables.py b/web_tables.py new file mode 100755 index 0000000..3c1cbd3 --- /dev/null +++ b/web_tables.py @@ -0,0 +1,415 @@ +#!/usr/bin/env python +# +############################################################################### +# Copyright (C) 2016 Cortney T. Buffington, N0MJS +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +############################################################################### + +from __future__ import print_function + +# Standard modules +import logging +import sys + +# Twisted modules +from twisted.internet.protocol import ReconnectingClientFactory, Protocol +from twisted.protocols.basic import NetstringReceiver +from twisted.internet import reactor, task +from twisted.web.server import Site +from twisted.web.static import File +from twisted.web.resource import Resource + +# Autobahn provides websocket service under Twisted +from autobahn.twisted.websocket import WebSocketServerProtocol, WebSocketServerFactory + +# Specific functions to import from standard modules +from pprint import pprint +from time import time, strftime, localtime +from cPickle import loads +from binascii import b2a_hex as h +from os.path import getmtime +from collections import deque + +# Web templating environment +from jinja2 import Environment, PackageLoader, select_autoescape + +# Utilities from K0USY Group sister project +from dmr_utils.utils import int_id, get_alias, try_download, mk_full_id_dict + +# Configuration variables and IPSC constants +from config import * +#from ipsc_const import * + +# Opcodes for reporting protocol to HBlink +OPCODE = { + 'CONFIG_REQ': '\x00', + 'CONFIG_SND': '\x01', + 'BRIDGE_REQ': '\x02', + 'BRIDGE_SND': '\x03', + 'CONFIG_UPD': '\x04', + 'BRIDGE_UPD': '\x05', + 'LINK_EVENT': '\x06', + 'BRDG_EVENT': '\x07', + } + +# Global Variables: +CONFIG = {} +CTABLE = {'MASTERS': {}, 'CLIENTS': {}} +BRIDGES = {} +BTABLE = {} +BTABLE['BRIDGES'] = {} +BRIDGES_RX = '' +CONFIG_RX = '' +LOGBUF = deque(100*[''], 100) +RED = '#ff0000' +GREEN = '#00ff00' +BLUE = '#0000ff' +ORANGE = '#ff8000' +WHITE = '#ffffff' + + +# For importing HTML templates +def get_template(_file): + with open(_file, 'r') as html: + return html.read() + +# Alias string processor +def alias_string(_id, _dict): + alias = get_alias(_id, _dict, 'CALLSIGN', 'CITY', 'STATE') + if type(alias) == list: + for x,item in enumerate(alias): + if item == None: + alias.pop(x) + return ', '.join(alias) + else: + return alias + +# Build the HBlink connections table +def build_hblink_table(_config): + _stats_table = {'MASTERS': {}, 'CLIENTS': {}} + for _hbp, _hbp_data in _config.iteritems(): + if _hbp_data['ENABLED'] == True: + if _hbp_data['MODE'] == 'MASTER': + _stats_table['MASTERS'][_hbp] = {} + _stats_table['MASTERS'][_hbp]['REPEAT'] = _hbp_data['REPEAT'] + _stats_table['MASTERS'][_hbp]['CLIENTS'] = {} + for _client in _hbp_data['CLIENTS']: + _stats_table['MASTERS'][_hbp]['CLIENTS'][int_id(_client)] = {} + _stats_table['MASTERS'][_hbp]['CLIENTS'][int_id(_client)]['CALLSIGN'] = _hbp_data['CLIENTS'][_client]['CALLSIGN'] + _stats_table['MASTERS'][_hbp]['CLIENTS'][int_id(_client)]['CONNECTION'] = _hbp_data['CLIENTS'][_client]['CONNECTION'] + _stats_table['MASTERS'][_hbp]['CLIENTS'][int_id(_client)]['IP'] = _hbp_data['CLIENTS'][_client]['IP'] + _stats_table['MASTERS'][_hbp]['CLIENTS'][int_id(_client)]['PINGS_RECEIVED'] = _hbp_data['CLIENTS'][_client]['PINGS_RECEIVED'] + _stats_table['MASTERS'][_hbp]['CLIENTS'][int_id(_client)]['LAST_PING'] = _hbp_data['CLIENTS'][_client]['LAST_PING'] + _stats_table['MASTERS'][_hbp]['CLIENTS'][int_id(_client)]['PORT'] = _hbp_data['CLIENTS'][_client]['PORT'] + elif _hbp_data['MODE'] == 'CLIENT': + _stats_table['CLIENTS'][_hbp] = {} + _stats_table['CLIENTS'][_hbp]['CALLSIGN'] = _hbp_data['CALLSIGN'] + _stats_table['CLIENTS'][_hbp]['RADIO_ID'] = int_id(_hbp_data['RADIO_ID']) + _stats_table['CLIENTS'][_hbp]['MASTER_IP'] = _hbp_data['MASTER_IP'] + _stats_table['CLIENTS'][_hbp]['STATS'] = _hbp_data['STATS'] + + return(_stats_table) + + +# +# CONFBRIDGE TABLE FUNCTIONS +# +def build_bridge_table(_bridges): + _stats_table = {} + _now = time() + _cnow = strftime('%Y-%m-%d %H:%M:%S', localtime(_now)) + + for _bridge, _bridge_data in _bridges.iteritems(): + _stats_table[_bridge] = {} + + for system in _bridges[_bridge]: + _stats_table[_bridge][system['SYSTEM']] = {} + _stats_table[_bridge][system['SYSTEM']]['TS'] = system['TS'] + _stats_table[_bridge][system['SYSTEM']]['TGID'] = int_id(system['TGID']) + + if system['TO_TYPE'] == 'ON' or system['TO_TYPE'] == 'OFF': + if system['TIMER'] - _now > 0: + _stats_table[_bridge][system['SYSTEM']]['EXP_TIME'] = int(system['TIMER'] - _now) + else: + _stats_table[_bridge][system['SYSTEM']]['EXP_TIME'] = 'Expired' + if system['TO_TYPE'] == 'ON': + _stats_table[_bridge][system['SYSTEM']]['TO_ACTION'] = 'Disconnect' + else: + _stats_table[_bridge][system['SYSTEM']]['TO_ACTION'] = 'Connect' + else: + _stats_table[_bridge][system['SYSTEM']]['EXP_TIME'] = 'N/A' + _stats_table[_bridge][system['SYSTEM']]['TO_ACTION'] = 'None' + + if system['ACTIVE'] == True: + _stats_table[_bridge][system['SYSTEM']]['ACTIVE'] = 'Connected' + _stats_table[_bridge][system['SYSTEM']]['COLOR'] = GREEN + elif system['ACTIVE'] == False: + _stats_table[_bridge][system['SYSTEM']]['ACTIVE'] = 'Disconnected' + _stats_table[_bridge][system['SYSTEM']]['COLOR'] = RED + + for i in range(len(system['ON'])): + system['ON'][i] = str(int_id(system['ON'][i])) + + _stats_table[_bridge][system['SYSTEM']]['TRIG_ON'] = ', '.join(system['ON']) + + for i in range(len(system['OFF'])): + system['OFF'][i] = str(int_id(system['OFF'][i])) + + _stats_table[_bridge][system['SYSTEM']]['TRIG_OFF'] = ', '.join(system['OFF']) + + return _stats_table + +# +# BUILD HBlink AND CONFBRIDGE TABLES FROM CONFIG/BRIDGES DICTS +# THIS CURRENTLY IS A TIMED CALL +# +build_time = time() +def build_stats(): + global build_time + now = time() + if True: #now > build_time + 1: + if CONFIG: + table = 'd' + dtemplate.render(_table=CTABLE) + dashboard_server.broadcast(table) + if BRIDGES: + table = 'b' + btemplate.render(_table=BTABLE['BRIDGES']) + dashboard_server.broadcast(table) + build_time = now + +# +# PROCESS IN COMING MESSAGES AND TAKE THE CORRECT ACTION DEPENING ON THE OPCODE +# +def process_message(_message): + global CTABLE, CONFIG, BRIDGES, CONFIG_RX, BRIDGES_RX + opcode = _message[:1] + _now = strftime('%Y-%m-%d %H:%M:%S %Z', localtime(time())) + + if opcode == OPCODE['CONFIG_SND']: + logging.debug('got CONFIG_SND opcode') + CONFIG = load_dictionary(_message) + CONFIG_RX = strftime('%Y-%m-%d %H:%M:%S', localtime(time())) + CTABLE = build_hblink_table(CONFIG) + + elif opcode == OPCODE['BRIDGE_SND']: + logging.debug('got BRIDGE_SND opcode') + BRIDGES = load_dictionary(_message) + BRIDGES_RX = strftime('%Y-%m-%d %H:%M:%S', localtime(time())) + BTABLE['BRIDGES'] = build_bridge_table(BRIDGES) + + elif opcode == OPCODE['LINK_EVENT']: + logging.info('LINK_EVENT Received: {}'.format(repr(_message[1:]))) + + elif opcode == OPCODE['BRDG_EVENT']: + logging.info('BRIDGE EVENT: {}'.format(repr(_message[1:]))) + p = _message[1:].split(",") + if p[0] == 'GROUP VOICE': + if p[1] == 'END': + log_message = '{}: {} {}: System: {}; IPSC Peer: {} - {}; Subscriber: {} - {}; TS: {}; TGID: {}; Duration: {}s'.format(_now, p[0], p[1], p[2], p[4], alias_string(int(p[4]), peer_ids), p[5], alias_string(int(p[5]), subscriber_ids), p[6], p[7], p[8]) + elif p[1] == 'START': + log_message = '{}: {} {}: System: {}; IPSC Peer: {} - {}; Subscriber: {} - {}; TS: {}; TGID: {}'.format(_now, p[0], p[1], p[2], p[4], alias_string(int(p[4]), peer_ids), p[5], alias_string(int(p[5]), subscriber_ids), p[6], p[7]) + elif p[1] == 'END WITHOUT MATCHING START': + log_message = '{}: {} {} on IPSC System {}: IPSC Peer: {} - {}; Subscriber: {} - {}; TS: {}; TGID: {}'.format(_now, p[0], p[1], p[2], p[4], alias_string(int(p[4]), peer_ids), p[5], alias_string(int(p[5]), subscriber_ids), p[6], p[7]) + else: + log_message = '{}: UNKNOWN GROUP VOICE LOG MESSAGE'.format(_now) + else: + log_message = '{}: UNKNOWN LOG MESSAGE'.format(_now) + + dashboard_server.broadcast('l' + log_message) + LOGBUF.append(log_message) + else: + logging.debug('got unknown opcode: {}, message: {}'.format(repr(opcode), repr(_message[1:]))) + + +def load_dictionary(_message): + data = _message[1:] + return loads(data) + logging.debug('Successfully decoded dictionary') + +# +# COMMUNICATION WITH THE HBlink INSTANCE +# +class report(NetstringReceiver): + def __init__(self): + pass + + def connectionMade(self): + pass + + def connectionLost(self, reason): + pass + + def stringReceived(self, data): + process_message(data) + + +class reportClientFactory(ReconnectingClientFactory): + def __init__(self): + pass + + def startedConnecting(self, connector): + logging.info('Initiating Connection to Server.') + if 'dashboard_server' in locals() or 'dashboard_server' in globals(): + dashboard_server.broadcast('q' + 'Connection to HBlink Established') + + def buildProtocol(self, addr): + logging.info('Connected.') + logging.info('Resetting reconnection delay') + self.resetDelay() + return report() + + def clientConnectionLost(self, connector, reason): + logging.info('Lost connection. Reason: %s', reason) + ReconnectingClientFactory.clientConnectionLost(self, connector, reason) + dashboard_server.broadcast('q' + 'Connection to HBlink Lost') + + def clientConnectionFailed(self, connector, reason): + logging.info('Connection failed. Reason: %s', reason) + ReconnectingClientFactory.clientConnectionFailed(self, connector, reason) + + +# +# WEBSOCKET COMMUNICATION WITH THE DASHBOARD CLIENT +# +class dashboard(WebSocketServerProtocol): + + def onConnect(self, request): + logging.info('Client connecting: %s', request.peer) + + def onOpen(self): + logging.info('WebSocket connection open.') + self.factory.register(self) + self.sendMessage('d' + str(dtemplate.render(_table=CTABLE))) + self.sendMessage('b' + str(btemplate.render(_table=BTABLE['BRIDGES']))) + for _message in LOGBUF: + if _message: + self.sendMessage('l' + _message) + + def onMessage(self, payload, isBinary): + if isBinary: + logging.info('Binary message received: %s bytes', len(payload)) + else: + logging.info('Text message received: %s', payload.decode('utf8')) + + def connectionLost(self, reason): + WebSocketServerProtocol.connectionLost(self, reason) + self.factory.unregister(self) + + def onClose(self, wasClean, code, reason): + logging.info('WebSocket connection closed: %s', reason) + + +class dashboardFactory(WebSocketServerFactory): + + def __init__(self, url): + WebSocketServerFactory.__init__(self, url) + self.clients = [] + + def register(self, client): + if client not in self.clients: + logging.info('registered client %s', client.peer) + self.clients.append(client) + + def unregister(self, client): + if client in self.clients: + logging.info('unregistered client %s', client.peer) + self.clients.remove(client) + + def broadcast(self, msg): + logging.debug('broadcasting message to: %s', self.clients) + for c in self.clients: + c.sendMessage(msg.encode('utf8')) + logging.debug('message sent to %s', c.peer) + +# +# STATIC WEBSERVER +# +class web_server(Resource): + isLeaf = True + def render_GET(self, request): + logging.info('static website requested: %s', request) + if request.uri == '/': + return index_html + else: + return 'Bad request' + + + + +if __name__ == '__main__': + logging.basicConfig(level=logging.INFO,handlers=[logging.FileHandler(PATH + 'logfile.log'),logging.StreamHandler()]) + + logging.info('web_tables.py starting up') + + # Download alias files + result = try_download(PATH, 'peer_ids.csv', PEER_URL, (FILE_RELOAD * 86400)) + logging.info(result) + + result = try_download(PATH, 'subscriber_ids.csv', SUBSCRIBER_URL, (FILE_RELOAD * 86400)) + logging.info(result) + + # Make Alias Dictionaries + peer_ids = mk_full_id_dict(PATH, PEER_FILE, 'peer') + if peer_ids: + logging.info('ID ALIAS MAPPER: peer_ids dictionary is available') + + subscriber_ids = mk_full_id_dict(PATH, SUBSCRIBER_FILE, 'subscriber') + if subscriber_ids: + logging.info('ID ALIAS MAPPER: subscriber_ids dictionary is available') + + talkgroup_ids = mk_full_id_dict(PATH, TGID_FILE, 'tgid') + if talkgroup_ids: + logging.info('ID ALIAS MAPPER: talkgroup_ids dictionary is available') + + local_subscriber_ids = mk_full_id_dict(PATH, LOCAL_SUB_FILE, 'subscriber') + if local_subscriber_ids: + logging.info('ID ALIAS MAPPER: local_subscriber_ids added to subscriber_ids dictionary') + subscriber_ids.update(local_subscriber_ids) + + local_peer_ids = mk_full_id_dict(PATH, LOCAL_PEER_FILE, 'peer') + if local_peer_ids: + logging.info('ID ALIAS MAPPER: local_peer_ids added peer_ids dictionary') + peer_ids.update(local_peer_ids) + + # Jinja2 Stuff + env = Environment( + loader=PackageLoader('web_tables', 'templates'), + autoescape=select_autoescape(['html', 'xml']) + ) + + dtemplate = env.get_template('hblink_table.html') + btemplate = env.get_template('bridge_table.html') + + # Create Static Website index file + index_html = get_template(PATH + 'index_template.html') + index_html = index_html.replace('<<>>', REPORT_NAME) + + # Start update loop + update_stats = task.LoopingCall(build_stats) + update_stats.start(FREQUENCY) + + # Connect to HBlink + reactor.connectTCP(HBLINK_IP, HBLINK_PORT, reportClientFactory()) + + # Create websocket server to push content to clients + dashboard_server = dashboardFactory('ws://*:9000') + dashboard_server.protocol = dashboard + reactor.listenTCP(9000, dashboard_server) + + # Create static web server to push initial index.html + website = Site(web_server()) + reactor.listenTCP(WEB_SERVER_PORT, website) + + reactor.run()