Allow web apps in package directories.
authorBenjamin Braatz <bb@bbraatz.eu>
Mon, 22 Mar 2021 21:10:53 +0000 (22:10 +0100)
committerBenjamin Braatz <bb@bbraatz.eu>
Mon, 22 Mar 2021 21:29:46 +0000 (22:29 +0100)
13 files changed:
.gitignore
MANIFEST.in [new file with mode: 0644]
conf.json
controlpi_plugins/web/Debug/controlpi-debug.css [new file with mode: 0644]
controlpi_plugins/web/Debug/controlpi-debug.js [new file with mode: 0644]
controlpi_plugins/web/Debug/index.css [new file with mode: 0644]
controlpi_plugins/web/Debug/index.html [new file with mode: 0644]
controlpi_plugins/wsserver.py
setup.py
web/controlpi-debug.css [deleted file]
web/controlpi-debug.js [deleted file]
web/index.css
web/index.html

index aa9a585ab46dbeba099c106e0d74deb8994cdd60..af06cedfed5815488d1b0bc266742b248414b495 100644 (file)
@@ -1,3 +1,4 @@
 __pycache__/
+dist/
 controlpi_wsserver.egg-info/
 venv/
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644 (file)
index 0000000..6f2f309
--- /dev/null
@@ -0,0 +1,2 @@
+recursive-include doc/ *
+recursive-include controlpi_plugins/web/ *
index bb7087aee68096893dbad34c99df0611ada2f031..f24a053ada7ca9b6445213342e0d7b1f0a6a738c 100644 (file)
--- a/conf.json
+++ b/conf.json
@@ -2,7 +2,11 @@
     "Example Server": {
         "plugin": "WSServer",
         "port": 8080,
-        "web root": "web"
+        "web": {
+            "/": {"location": "web"},
+            "/Debug": {"module": "controlpi_plugins.wsserver",
+                       "location": "Debug"}
+        }
     },
     "Example State": {
         "plugin": "State"
diff --git a/controlpi_plugins/web/Debug/controlpi-debug.css b/controlpi_plugins/web/Debug/controlpi-debug.css
new file mode 100644 (file)
index 0000000..82ca93b
--- /dev/null
@@ -0,0 +1,186 @@
+.clientcontainer {
+    padding-right: 5px;
+    padding-bottom: 5px;
+}
+
+.client {
+    display: inline-block;
+    vertical-align: top;
+    width: 600px;
+    margin-top: 5px;
+    margin-left: 5px;
+    border: 2px solid black;
+    padding: 5px;
+    background-color: #bcdaf8;
+}
+
+.client > header {
+    display: flex;
+    justify-content: space-between;
+}
+
+.client > header > h2 {
+    vertical-align: top;
+    font-size: 1.2rem;
+    font-weight: bold;
+}
+
+.client > header > h3 {
+    vertical-align: top;
+    font-size: 0.8rem;
+    font-weight: bold;
+}
+
+.interfacecontainer {
+    white-space: nowrap;
+}
+
+.interfacecontainer > h3 {
+    display: inline-block;
+    vertical-align: top;
+    width: 20px;
+    margin-top: 5px;
+    font-weight: bold;
+}
+
+.templatecontainer {
+    display: inline-block;
+    vertical-align: top;
+    width: 580px;
+    white-space: normal;
+}
+
+.lastcontainer {
+    text-align: center;
+}
+
+.lastcontainer * {
+    text-align: left;
+}
+
+.message {
+    display: inline-block;
+    vertical-align: top;
+    background-color: #79b5e7;
+    border: 1px solid black;
+}
+
+.message.green {
+    background-color: #85be71;
+}
+
+.message.red {
+    background-color: #ee96a8;
+}
+
+.templatecontainer > .message {
+    font-size: 0.8rem;
+    margin-top: 5px;
+    margin-left: 5px;
+}
+
+.lastcontainer > .message {
+    margin-top: 5px;
+}
+
+.message > span {
+    margin: 5px;
+    font-size: 1rem;
+}
+
+.message > h4 {
+    margin: 2px;
+    font-size: 0.8rem;
+}
+
+.message table {
+    border-spacing: 2px;
+}
+
+.message .array {
+    display: flex;
+    flex-direction: row;
+    flex-wrap: wrap;
+}
+
+.message .array > * {
+    vertical-align: baseline;
+}
+
+.message .object {
+    display: flex;
+    flex-direction: column;
+    border: 1px solid black;
+    padding-top: 2px;
+    padding-left: 2px;
+    margin-bottom: 2px;
+}
+
+.message .object > .property {
+    display: flex;
+    flex-direction: row;
+    flex-wrap: wrap;
+}
+
+.message .object > .property > .key {
+    margin-right: 2px;
+    margin-bottom: 2px;
+}
+
+.message .object > .property > .value {
+    display: flex;
+    flex-direction: row;
+    flex-wrap: wrap;
+    margin-right: 2px;
+    margin-bottom: 2px;
+}
+
+.message input {
+    background-color: #35bfd3;
+}
+
+.message input[readonly] {
+    background-color: #79b5e7;
+}
+
+.message input:hover,
+.message input:focus {
+    background-color: #afa7ec;
+}
+
+.message input::selection {
+    background-color: #d69ae2;
+}
+
+.message input[type="submit"] {
+    border: 1px solid #0f4a53;
+    border-radius: 12px;
+    padding: 2px;
+}
+
+.message input[type="submit"]:hover,
+.message input[type="submit"]:focus {
+    border: 1px solid #44405e;
+}
+
+.message input[type="text"],
+.message input[type="number"] {
+    min-width: 5px;
+}
+
+input[type="number"]::-webkit-outer-spin-button,
+input[type="number"]::-webkit-inner-spin-button {
+    -webkit-appearance: none;
+}
+input[type="number"] {
+    -moz-appearance: textfield;
+}
+
+.message select {
+    background-color: #35bfd3;
+}
+
+.message select:hover,
+.message select:focus {
+    background-color: #afa7ec;
+}
diff --git a/controlpi_plugins/web/Debug/controlpi-debug.js b/controlpi_plugins/web/Debug/controlpi-debug.js
new file mode 100644 (file)
index 0000000..3cfe956
--- /dev/null
@@ -0,0 +1,385 @@
+// Create section for client:
+function createForClient(client, plugin) {
+    const section = document.createElement('section')
+    section.setAttribute('id', client)
+    section.setAttribute('class', 'client')
+    const heading = document.createElement('header')
+    const headingH2 = document.createElement('h2')
+    headingH2.appendChild(document.createTextNode(client))
+    heading.appendChild(headingH2)
+    const headingH3 = document.createElement('h3')
+    headingH3.appendChild(document.createTextNode(plugin))
+    heading.appendChild(headingH3)
+    section.appendChild(heading)
+    const receiveOuter = document.createElement('div')
+    receiveOuter.setAttribute('class', 'interfacecontainer')
+    const receiveHeading = document.createElement('h3')
+    receiveHeading.appendChild(document.createTextNode('=>'))
+    receiveOuter.appendChild(receiveHeading)
+    const receiveInner = document.createElement('div')
+    receiveInner.setAttribute('id', client + ' Receives')
+    receiveInner.setAttribute('class', 'templatecontainer')
+    receiveOuter.appendChild(receiveInner)
+    section.appendChild(receiveOuter)
+    const sendOuter = document.createElement('div')
+    sendOuter.setAttribute('class', 'interfacecontainer')
+    const sendHeading = document.createElement('h3')
+    sendHeading.appendChild(document.createTextNode('<='))
+    sendOuter.appendChild(sendHeading)
+    const sendInner = document.createElement('div')
+    sendInner.setAttribute('id', client + ' Sends')
+    sendInner.setAttribute('class', 'templatecontainer')
+    sendOuter.appendChild(sendInner)
+    section.appendChild(sendOuter)
+    const last = document.createElement('div')
+    last.setAttribute('id', client + ' Last')
+    last.setAttribute('class', 'lastcontainer')
+    section.appendChild(last)
+    return section
+}
+
+// Create div and table for template:
+function createForTemplate(template) {
+    const div = document.createElement('div')
+    div.setAttribute('class', 'message')
+    if (Object.keys(template).length === 0) {
+        // Create span with '*' for empty templates:
+        const span = document.createElement('span')
+        const spanContent = document.createTextNode('*')
+        span.appendChild(spanContent)
+        div.appendChild(span)
+    } else {
+        const table = document.createElement('table')
+        for (const key in template) {
+            // Append table row for key-value pair:
+            const tr = document.createElement('tr')
+            const keyTd = document.createElement('td')
+            const keyTdContent = document.createTextNode(key + ':')
+            keyTd.appendChild(keyTdContent)
+            tr.appendChild(keyTd)
+            const valueTd = document.createElement('td')
+            schema = template[key]
+            value = ''
+            if ('const' in schema) {
+                value = JSON.stringify(schema['const'])
+            } else {
+                value = JSON.stringify(schema)
+            }
+            const valueTdContent = document.createTextNode(value)
+            valueTd.appendChild(valueTdContent)
+            tr.appendChild(valueTd)
+            table.appendChild(tr)
+        }
+        div.appendChild(table)
+    }
+    return div
+}
+
+// Resize input fields to current input:
+function resizeInput() {
+    const tmp = document.createElement('span')
+    // HACK: Should get this from CSS rather than hardcode it:
+    tmp.setAttribute('style', 'font-size: 0.8rem;')
+    var value = this.value
+    // HACK: 'number' fields for floats do not return trailing dot in
+    // value, so we add one unconditionally:
+    if (this.type == 'number' && this.step == 'any' && !value.includes('.')) {
+        value += '.'
+    }
+    const tmpContent = document.createTextNode(value)
+    tmp.appendChild(tmpContent)
+    document.body.appendChild(tmp);
+    const width = tmp.getBoundingClientRect().width
+    document.body.removeChild(tmp);
+    this.style.width = width + 'px'
+}
+
+// Create form input (or select) for key-value pairs in receive templates:
+function inputsForKeyValue(key, schema) {
+    result = []
+    literal = false
+    if ('const' in schema) {
+        literal = true
+    }
+    if (schema['type'] == 'boolean' ||
+        (literal && typeof(schema['const']) == 'boolean')) {
+        // Create select with true and false options for Boolean:
+        const select = document.createElement('select')
+        select.setAttribute('name', key)
+        const optionTrue = document.createElement('option')
+        optionTrue.setAttribute('value', 'true')
+        const optionTrueContent = document.createTextNode('true')
+        optionTrue.appendChild(optionTrueContent)
+        select.appendChild(optionTrue)
+        const optionFalse = document.createElement('option')
+        optionFalse.setAttribute('value', false)
+        const optionFalseContent = document.createTextNode('false')
+        optionFalse.appendChild(optionFalseContent)
+        if (literal) {
+            // Select set value and disable other value
+            // for literal Boolean:
+            if (schema['const']) {
+                optionTrue.setAttribute('selected', '')
+                optioniFalse.setAttribute('disabled', '')
+            } else {
+                optionFalse.setAttribute('selected', '')
+                optionTrue.setAttribute('disabled', '')
+            }
+        }
+        select.appendChild(optionFalse)
+        result.push(select)
+    } else {
+        // Create input for everything except Booleans:
+        if (schema['type'] == 'string' ||
+            (literal && typeof(schema['const']) == 'string' &&
+             key != 'command')) {
+            // Quote strings:
+            const openquote = document.createTextNode('"')
+            result.push(openquote)
+        }
+        const input = document.createElement('input')
+        // Set type of input:
+        if (key == 'command') {
+            input.setAttribute('type', 'hidden')
+        } else if (schema['type'] == 'integer' ||
+                   schema['type'] == 'number' ||
+                   typeof(schema['const']) == 'number') {
+            input.setAttribute('type', 'number')
+            if (schema['type'] == 'integer') {
+                input.setAttribute('step', '1')
+            } else if (schema['type'] == 'number') {
+                input.setAttribute('step', 'any')
+            }
+        } else if (schema['type'] == 'string' ||
+                   typeof(schema['const']) == 'string') {
+            input.setAttribute('type', 'text')
+        }
+        // Set key as name of input:
+        input.setAttribute('name', key)
+        // Set value of input, readonly or required:
+        if (key == 'command') {
+            input.setAttribute('value', schema['const'])
+        } else if (literal) {
+            input.setAttribute('value', schema['const'])
+            input.setAttribute('readonly', '')
+        } else {
+            input.setAttribute('value', '')
+            input.setAttribute('required', '')
+        }
+        if (key != 'command') {
+            // Resize input field at every change and at beginning:
+            input.addEventListener('input', resizeInput)
+            resizeInput.call(input)
+        }
+        result.push(input)
+        if (schema['type'] == 'string' ||
+            (literal && typeof(schema['const']) == 'string' &&
+             key != 'command')) {
+            // Quote strings:
+            const closequote = document.createTextNode('"')
+            result.push(closequote)
+        }
+        if (key == 'command') {
+            // Add submit button for 'command' key:
+            const submit = document.createElement('input')
+            submit.setAttribute('type', 'submit')
+            submit.setAttribute('value', schema['const'])
+            result.push(submit)
+        }
+    }
+    return result
+}
+
+// Create div, form and table for command template:
+function createForCommand(template) {
+    const div = document.createElement('div')
+    div.setAttribute('class', 'message')
+    const form = document.createElement('form')
+    form.addEventListener("submit", function(e) {
+        // Convert names and values of all form elements to key-value pairs
+        // and send as JSON over websocket:
+        e.preventDefault();
+        var data = {};
+        for (const element of form.elements) {
+            if (element.type == 'text' || element.type == 'hidden') {
+                data[element.name] = element.value
+            } else if (element.type == 'number') {
+                data[element.name] = element.valueAsNumber
+            } else if (element.type == 'select-one') {
+                if (element.value == 'true') {
+                    data[element.name] = true
+                } else if (element.value == 'false') {
+                    data[element.name] = false
+                } else {
+                    data[element.name] = element.value
+                }
+            }
+        }
+        websocket.send(JSON.stringify(data))
+    })
+    const table = document.createElement('table')
+    for (const key in template) {
+        // Append table row for key-value pair:
+        const tr = document.createElement('tr')
+        const keyTd = document.createElement('td')
+        const keyTdContent = document.createTextNode(key + ':')
+        keyTd.appendChild(keyTdContent)
+        tr.appendChild(keyTd)
+        const valueTd = document.createElement('td')
+        for (const element of inputsForKeyValue(key, template[key])) {
+            valueTd.appendChild(element)
+        }
+        tr.appendChild(valueTd)
+        table.appendChild(tr)
+    }
+    form.appendChild(table)
+    div.appendChild(form)
+    return div
+}
+
+// Remove (if unregistered) or create (if not existent) elements for
+// clients and update interface information:
+function processBusMessage(message) {
+    if (message['event'] == 'unregistered') {
+        // On deregistration delete client element if it exists:
+        const clientElement = document.getElementById(message['client'])
+        if (clientElement != null) {
+            clientElement.remove()
+        }
+    } else {
+        // On registration or 'get clients' answer:
+        const clientElement = document.getElementById(message['client'])
+        if (clientElement == null) {
+            // Create element for client if not existent:
+            const main = document.getElementById('ControlPi Debug')
+            main.appendChild(createForClient(message['client'],
+                                             message['plugin']))
+        }
+        // Crate message elements for receives interface:
+        const receiveContainer = document.getElementById(message['client'] + ' Receives')
+        receiveContainer.innerHTML = ''
+        for (const template of message['receives']) {
+            if (template['command'] != null) {
+                receiveContainer.appendChild(createForCommand(template))
+            } else {
+                receiveContainer.appendChild(createForTemplate(template))
+            }
+        }
+        // Create message elements for sends interface:
+        const sendContainer = document.getElementById(message['client'] + ' Sends')
+        sendContainer.innerHTML = ''
+        for (const template of message['sends']) {
+            sendContainer.appendChild(createForTemplate(template))
+        }
+    }
+}
+
+// Recursively create div structure for arrays, objects and primitive
+// values:
+function createForMessageValue(value) {
+    var result = ''
+    if (typeof(value) == 'object') {
+        if (Array.isArray(value)) {
+            result = document.createElement('div')
+            result.setAttribute('class', 'array')
+            var counter = value.length
+            for (const child of value) {
+                result.appendChild(createForMessageValue(child))
+                if (--counter) {
+                    result.appendChild(document.createTextNode(', '))
+                }
+            }
+        } else {
+            result = document.createElement('div')
+            result.setAttribute('class', 'object')
+            for (const key in value) {
+                const property = document.createElement('div')
+                property.setAttribute('class', 'property')
+                const keyDiv = document.createElement('div')
+                keyDiv.setAttribute('class', 'key')
+                keyDiv.appendChild(document.createTextNode(key + ':'))
+                property.appendChild(keyDiv)
+                const valueDiv = document.createElement('div')
+                valueDiv.setAttribute('class', 'value')
+                valueDiv.appendChild(createForMessageValue(value[key]))
+                property.appendChild(valueDiv)
+                result.appendChild(property)
+            }
+        }
+    } else {
+        result = document.createTextNode(JSON.stringify(value))
+    }
+    return result
+}
+
+// Create div and table for message:
+function createForMessage(message) {
+    const div = document.createElement('div')
+    div.setAttribute('class', 'message')
+    // Current (receive) time as heading:
+    const time = new Date().toLocaleTimeString()
+    const h4 = document.createElement('h4')
+    const h4Content = document.createTextNode(time)
+    h4.appendChild(h4Content)
+    div.appendChild(h4)
+    const table = document.createElement('table')
+    for (const key in message) {
+        if (key == 'sender') {
+            // Ignore 'sender' for last received messages
+            // (information redundantly present in client heading):
+            continue
+        } else if (key == 'state') {
+            // Set background according to state:
+            if (message[key] === true) {
+                div.classList.add('green')
+            } else if (message[key] === false) {
+                div.classList.add('red')
+            }
+        }
+        // Append table row for key-value pair:
+        const tr = document.createElement('tr')
+        const keyTd = document.createElement('td')
+        const keyTdContent = document.createTextNode(key + ':')
+        keyTd.appendChild(keyTdContent)
+        tr.appendChild(keyTd)
+        const valueTd = document.createElement('td')
+        valueTd.appendChild(createForMessageValue(message[key]))
+        tr.appendChild(valueTd)
+        table.appendChild(tr)
+    }
+    div.appendChild(table)
+    return div
+}
+
+// Create element for client (if not existent)
+// and update last received message:
+function processClientMessage(message) {
+    const clientElement = document.getElementById(message['sender'])
+    if (clientElement == null) {
+        // Create section for client if not existent:
+        const main = document.getElementById('ControlPi Debug')
+        main.appendChild(createForClient(message.sender, ''))
+    }
+    // Update last received message:
+    const lastContainer = document.getElementById(message['sender'] + ' Last')
+    lastContainer.innerHTML = ''
+    lastContainer.appendChild(createForMessage(message))
+}
+
+// Open Websocket back to ControlPi we were loaded from:
+const websocket = new WebSocket("ws://" + window.location.host)
+
+// When Websocket is ready request all clients from bus:
+websocket.addEventListener('open', function (event) {
+    websocket.send(JSON.stringify({target: '', command: 'get clients'}))
+})
+
+// When receiving message from ControlPi through Websocket:
+websocket.addEventListener('message', function (event) {
+    const message = JSON.parse(event.data)
+    if (message['sender'] == '') {
+        processBusMessage(message)
+    } else {
+        processClientMessage(message)
+    }
+})
diff --git a/controlpi_plugins/web/Debug/index.css b/controlpi_plugins/web/Debug/index.css
new file mode 100644 (file)
index 0000000..e5d2c50
--- /dev/null
@@ -0,0 +1,71 @@
+@import url('https://fonts.googleapis.com/css?family=Roboto');
+@import url('https://fonts.googleapis.com/css?family=Michroma');
+
+* {
+    vertical-align: baseline;
+       margin: 0;
+       outline: 0;
+       border: 0 none;
+       padding: 0;
+    font-family: 'Roboto', sans-serif;
+       font-weight: inherit;
+       font-style: inherit;
+       font-size: inherit;
+}
+
+html {
+    font-size: 16px;
+}
+
+body {
+    background-color: white;
+    color: black;
+}
+
+body > header {
+    padding: 10px;
+    background-color: #2c343b;
+}
+
+body > header * {
+    vertical-align: middle;
+}
+
+body > header svg {
+    height: 40px;
+}
+
+body > header span {
+    font-family: "Michroma", sans-serif;
+    font-weight: bold;
+    font-size: 20px;
+    text-transform: uppercase;
+}
+
+body > header span.Graph {
+    margin-left: 10px;
+    color: white;
+}
+
+body > header span.IT {
+    margin-right: 10px;
+    color: #83a1b4;
+}
+
+h1, h2, h3 {
+    margin-top: 5px;
+    margin-left: 5px;
+    font-weight: bold;
+}
+
+h1 {
+    font-size: 1.4rem;
+}
+
+h2 {
+    font-size: 1.2rem;
+}
+
+h3 {
+    font-size: 1.2rem;
+}
diff --git a/controlpi_plugins/web/Debug/index.html b/controlpi_plugins/web/Debug/index.html
new file mode 100644 (file)
index 0000000..f6d6ff5
--- /dev/null
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<html lang="en">
+    <head>
+        <meta charset="utf-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1.0">
+        <title>ControlPi Debug</title>
+        <link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgo=">
+        <link rel="stylesheet" type="text/css" href="index.css">
+        <link rel="stylesheet" type="text/css" href="controlpi-debug.css">
+        <script src="controlpi-debug.js" defer></script>
+    </head>
+    <body>
+        <header>
+            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 334 299">
+                <g stroke="#83a1b4" stroke-width="8" fill="none">
+                    <circle r="14.5" transform="translate(18.53 235.35)"/>
+                    <circle r="14.5" transform="translate(40.39 75.08)"/>
+                    <circle r="14.5" transform="translate(198.04 20.48)"/>
+                    <circle r="14.5" transform="translate(313.77 145.08)"/>
+                    <circle r="14.5" transform="translate(145.7 131.46)"/>
+                    <circle r="14.5" transform="translate(209.99 279.89)"/>
+                    <path stroke-width="3.7" d="M38.8 241l149 34.8m34.7-12.3l77-100m-261 67l253.4-78M34 222.5L128 146M20.4 214.7l16-117m23.3-29l116.8-41.8M154 112.6L188 39m-21.5 95L291 144M58.6 86.5l67 35.7m86.2-86.6L298 130"/>
+                </g>
+            </svg>
+            <span class="Graph">Graph-</span><span class="IT">IT</span>
+        </header>
+        <h1>ControlPi Debug</h1>
+        <main id="ControlPi Debug" class="clientcontainer">
+        </main>
+    </body>
+</html>
index 2a8d62569db1893e9dba348c659d0b78928b2c3a..fc7a3fe1f08b704eaf2428fcaa3f63d7fb3d6ac1 100644 (file)
@@ -3,20 +3,22 @@
 â€¦
 
 TODO: documentation, doctests
-TODO: Mount multiple web apps from packages and file paths
 TODO: Let Debug web app collapse/expand nested structures
 TODO: Make Debug web app work with nested structures in commands
+TODO: Let clients filter messages received over websocket
 """
 import os
+import sys
 import json
 from websockets import (WebSocketServerProtocol, ConnectionClosedOK,
                         ConnectionClosedError, serve)
 from websockets.http import Headers
 from http import HTTPStatus
-from typing import Optional, Tuple
 
 from controlpi import BasePlugin, MessageBus, Message, MessageTemplate
 
+from typing import Optional, Tuple
+
 
 class Connection:
     """Connection to websocket.
@@ -78,9 +80,17 @@ class WSServer(BasePlugin):
     the contents in given web root.
     """
 
-    CONF_SCHEMA = {'properties': {'host': {'type': 'string'},
-                                  'port': {'type': 'integer'},
-                                  'web root': {'type': 'string'}}}
+    CONF_SCHEMA = {'properties':
+                   {'host': {'type': 'string'},
+                    'port': {'type': 'integer'},
+                    'web': {'type': 'object',
+                            'patternProperties':
+                            {'^/([A-Z][A-Za-z]*)?$':
+                             {'type': 'object',
+                              'properties': {'module': {'type': 'string'},
+                                             'location': {'type': 'string'}},
+                              'required': ['location']}},
+                            'additionalProperties': False}}}
     """Schema for WServer plugin configuration.
 
     Optional configuration keys:
@@ -88,7 +98,9 @@ class WSServer(BasePlugin):
     - 'host': network interfaces to listen on (default: None, meaning all
       interfaces)
     - 'port': port to connect to (default: 80)
-    - 'web root': root of files to serve (default: 'web')
+    - 'web': mapping of web paths to locations on disk (either as absolute
+      path, relative path from the working directory or path from the
+      path containing a given module)
     """
 
     async def _handler(self, websocket: WebSocketServerProtocol,
@@ -100,49 +112,71 @@ class WSServer(BasePlugin):
                                request_headers: Headers) -> Response:
         if 'Upgrade' in request_headers:
             return None
-        if path == '/':
-            path = '/index.html'
         response_headers = Headers()
         response_headers['Server'] = 'controlpi-wsserver websocket server'
         response_headers['Connection'] = 'close'
-        file_path = os.path.realpath(os.path.join(self._web_root, path[1:]))
-        if os.path.commonpath((self._web_root, file_path)) != self._web_root \
-                or not os.path.exists(file_path) \
-                or not os.path.isfile(file_path):
+        if path not in self._web_files:
             return (HTTPStatus.NOT_FOUND, response_headers,
                     f"File '{path}' not found!".encode())
-        mime_type = 'application/octet-stream'
-        extension = file_path.split('.')[-1]
-        if extension == 'html':
-            mime_type = 'text/html'
-        elif extension == 'js':
-            mime_type = 'text/javascript'
-        elif extension == 'css':
-            mime_type = 'text/css'
-        response_headers['Content-Type'] = mime_type
-        body = open(file_path, 'rb').read()
+        file_info = self._web_files[path]
+        response_headers['Content-Type'] = file_info['type']
+        body = open(file_info['location'], 'rb').read()
         response_headers['Content-Length'] = str(len(body))
         return HTTPStatus.OK, response_headers, body
 
     def process_conf(self) -> None:
         """Get host, port and path settings from configuration."""
+        self._host = None
+        if 'host' in self.conf:
+            self._host = self.conf['host']
+        else:
+            print(f"'host' not configured for WSServer '{self.name}'."
+                  " Serving on all interfaces.")
         self._port = 80
         if 'port' in self.conf:
             self._port = self.conf['port']
         else:
             print(f"'port' not configured for WSServer '{self.name}'."
-                  " Using 80.")
-        web_root = 'web'
-        if 'web root' in self.conf:
-            web_root = self.conf['web root']
-        else:
-            print(f"'web root' not configured for WSServer '{self.name}'."
-                  " Using 'web'.")
-        self._web_root = os.path.realpath(os.path.join(os.getcwd(),
-                                                       web_root))
+                  " Using port 80.")
+        self._web_files = {}
+        if 'web' in self.conf:
+            for path in self.conf['web']:
+                path_conf = self.conf['web'][path]
+                location = path_conf['location']
+                if 'module' in path_conf:
+                    # Determine location relative to module directory:
+                    module_file = sys.modules[path_conf['module']].__file__
+                    module_dir = os.path.dirname(module_file)
+                    location = os.path.join(module_dir, 'web', location)
+                else:
+                    # Determine location relative to current working directory:
+                    location = os.path.join(os.getcwd(), location)
+                location = os.path.realpath(location)
+                # Walk all files in location recursively:
+                for (dir_location, _, filenames) in os.walk(location):
+                    dir_path = os.path.join(path, dir_location[len(location):])
+                    for filename in filenames:
+                        file_path = os.path.join(dir_path, filename)
+                        file_location = os.path.join(dir_location, filename)
+                        file_info = {'location': file_location}
+                        # Determine MIME type:
+                        file_info['type'] = 'application/octet-stream'
+                        extension = filename.split('.')[-1]
+                        if extension == 'html':
+                            file_info['type'] = 'text/html'
+                        elif extension == 'js':
+                            file_info['type'] = 'text/javascript'
+                        elif extension == 'css':
+                            file_info['type'] = 'text/css'
+                        # Register in instance variable:
+                        self._web_files[file_path] = file_info
+                        # Serve index.html also as directory:
+                        if filename == 'index.html':
+                            self._web_files[dir_path] = file_info
+                            self._web_files[dir_path.rstrip('/')] = file_info
 
     async def run(self) -> None:
         """Set up websocket server."""
-        await serve(self._handler, port=self._port,
+        await serve(self._handler, host=self._host, port=self._port,
                     process_request=self._process_request)
         print(f"WSServer '{self.name}' serving on port {self._port}.")
index 76aca3aa1c1978cc7e2084fe0adbf5e10b013ada..d7067f7bcc5335346cb0bcf41e15d2ac66c60540 100644 (file)
--- a/setup.py
+++ b/setup.py
@@ -13,6 +13,7 @@ setuptools.setup(
     long_description_content_type="text/markdown",
     url="http://docs.graph-it.com/graphit/controlpi-wsserver",
     packages=["controlpi_plugins"],
+    include_package_data=True,
     install_requires=[
         "websockets",
         "controlpi @ git+git://git.graph-it.com/graphit/controlpi.git",
diff --git a/web/controlpi-debug.css b/web/controlpi-debug.css
deleted file mode 100644 (file)
index 82ca93b..0000000
+++ /dev/null
@@ -1,186 +0,0 @@
-.clientcontainer {
-    padding-right: 5px;
-    padding-bottom: 5px;
-}
-
-.client {
-    display: inline-block;
-    vertical-align: top;
-    width: 600px;
-    margin-top: 5px;
-    margin-left: 5px;
-    border: 2px solid black;
-    padding: 5px;
-    background-color: #bcdaf8;
-}
-
-.client > header {
-    display: flex;
-    justify-content: space-between;
-}
-
-.client > header > h2 {
-    vertical-align: top;
-    font-size: 1.2rem;
-    font-weight: bold;
-}
-
-.client > header > h3 {
-    vertical-align: top;
-    font-size: 0.8rem;
-    font-weight: bold;
-}
-
-.interfacecontainer {
-    white-space: nowrap;
-}
-
-.interfacecontainer > h3 {
-    display: inline-block;
-    vertical-align: top;
-    width: 20px;
-    margin-top: 5px;
-    font-weight: bold;
-}
-
-.templatecontainer {
-    display: inline-block;
-    vertical-align: top;
-    width: 580px;
-    white-space: normal;
-}
-
-.lastcontainer {
-    text-align: center;
-}
-
-.lastcontainer * {
-    text-align: left;
-}
-
-.message {
-    display: inline-block;
-    vertical-align: top;
-    background-color: #79b5e7;
-    border: 1px solid black;
-}
-
-.message.green {
-    background-color: #85be71;
-}
-
-.message.red {
-    background-color: #ee96a8;
-}
-
-.templatecontainer > .message {
-    font-size: 0.8rem;
-    margin-top: 5px;
-    margin-left: 5px;
-}
-
-.lastcontainer > .message {
-    margin-top: 5px;
-}
-
-.message > span {
-    margin: 5px;
-    font-size: 1rem;
-}
-
-.message > h4 {
-    margin: 2px;
-    font-size: 0.8rem;
-}
-
-.message table {
-    border-spacing: 2px;
-}
-
-.message .array {
-    display: flex;
-    flex-direction: row;
-    flex-wrap: wrap;
-}
-
-.message .array > * {
-    vertical-align: baseline;
-}
-
-.message .object {
-    display: flex;
-    flex-direction: column;
-    border: 1px solid black;
-    padding-top: 2px;
-    padding-left: 2px;
-    margin-bottom: 2px;
-}
-
-.message .object > .property {
-    display: flex;
-    flex-direction: row;
-    flex-wrap: wrap;
-}
-
-.message .object > .property > .key {
-    margin-right: 2px;
-    margin-bottom: 2px;
-}
-
-.message .object > .property > .value {
-    display: flex;
-    flex-direction: row;
-    flex-wrap: wrap;
-    margin-right: 2px;
-    margin-bottom: 2px;
-}
-
-.message input {
-    background-color: #35bfd3;
-}
-
-.message input[readonly] {
-    background-color: #79b5e7;
-}
-
-.message input:hover,
-.message input:focus {
-    background-color: #afa7ec;
-}
-
-.message input::selection {
-    background-color: #d69ae2;
-}
-
-.message input[type="submit"] {
-    border: 1px solid #0f4a53;
-    border-radius: 12px;
-    padding: 2px;
-}
-
-.message input[type="submit"]:hover,
-.message input[type="submit"]:focus {
-    border: 1px solid #44405e;
-}
-
-.message input[type="text"],
-.message input[type="number"] {
-    min-width: 5px;
-}
-
-input[type="number"]::-webkit-outer-spin-button,
-input[type="number"]::-webkit-inner-spin-button {
-    -webkit-appearance: none;
-}
-input[type="number"] {
-    -moz-appearance: textfield;
-}
-
-.message select {
-    background-color: #35bfd3;
-}
-
-.message select:hover,
-.message select:focus {
-    background-color: #afa7ec;
-}
diff --git a/web/controlpi-debug.js b/web/controlpi-debug.js
deleted file mode 100644 (file)
index 3cfe956..0000000
+++ /dev/null
@@ -1,385 +0,0 @@
-// Create section for client:
-function createForClient(client, plugin) {
-    const section = document.createElement('section')
-    section.setAttribute('id', client)
-    section.setAttribute('class', 'client')
-    const heading = document.createElement('header')
-    const headingH2 = document.createElement('h2')
-    headingH2.appendChild(document.createTextNode(client))
-    heading.appendChild(headingH2)
-    const headingH3 = document.createElement('h3')
-    headingH3.appendChild(document.createTextNode(plugin))
-    heading.appendChild(headingH3)
-    section.appendChild(heading)
-    const receiveOuter = document.createElement('div')
-    receiveOuter.setAttribute('class', 'interfacecontainer')
-    const receiveHeading = document.createElement('h3')
-    receiveHeading.appendChild(document.createTextNode('=>'))
-    receiveOuter.appendChild(receiveHeading)
-    const receiveInner = document.createElement('div')
-    receiveInner.setAttribute('id', client + ' Receives')
-    receiveInner.setAttribute('class', 'templatecontainer')
-    receiveOuter.appendChild(receiveInner)
-    section.appendChild(receiveOuter)
-    const sendOuter = document.createElement('div')
-    sendOuter.setAttribute('class', 'interfacecontainer')
-    const sendHeading = document.createElement('h3')
-    sendHeading.appendChild(document.createTextNode('<='))
-    sendOuter.appendChild(sendHeading)
-    const sendInner = document.createElement('div')
-    sendInner.setAttribute('id', client + ' Sends')
-    sendInner.setAttribute('class', 'templatecontainer')
-    sendOuter.appendChild(sendInner)
-    section.appendChild(sendOuter)
-    const last = document.createElement('div')
-    last.setAttribute('id', client + ' Last')
-    last.setAttribute('class', 'lastcontainer')
-    section.appendChild(last)
-    return section
-}
-
-// Create div and table for template:
-function createForTemplate(template) {
-    const div = document.createElement('div')
-    div.setAttribute('class', 'message')
-    if (Object.keys(template).length === 0) {
-        // Create span with '*' for empty templates:
-        const span = document.createElement('span')
-        const spanContent = document.createTextNode('*')
-        span.appendChild(spanContent)
-        div.appendChild(span)
-    } else {
-        const table = document.createElement('table')
-        for (const key in template) {
-            // Append table row for key-value pair:
-            const tr = document.createElement('tr')
-            const keyTd = document.createElement('td')
-            const keyTdContent = document.createTextNode(key + ':')
-            keyTd.appendChild(keyTdContent)
-            tr.appendChild(keyTd)
-            const valueTd = document.createElement('td')
-            schema = template[key]
-            value = ''
-            if ('const' in schema) {
-                value = JSON.stringify(schema['const'])
-            } else {
-                value = JSON.stringify(schema)
-            }
-            const valueTdContent = document.createTextNode(value)
-            valueTd.appendChild(valueTdContent)
-            tr.appendChild(valueTd)
-            table.appendChild(tr)
-        }
-        div.appendChild(table)
-    }
-    return div
-}
-
-// Resize input fields to current input:
-function resizeInput() {
-    const tmp = document.createElement('span')
-    // HACK: Should get this from CSS rather than hardcode it:
-    tmp.setAttribute('style', 'font-size: 0.8rem;')
-    var value = this.value
-    // HACK: 'number' fields for floats do not return trailing dot in
-    // value, so we add one unconditionally:
-    if (this.type == 'number' && this.step == 'any' && !value.includes('.')) {
-        value += '.'
-    }
-    const tmpContent = document.createTextNode(value)
-    tmp.appendChild(tmpContent)
-    document.body.appendChild(tmp);
-    const width = tmp.getBoundingClientRect().width
-    document.body.removeChild(tmp);
-    this.style.width = width + 'px'
-}
-
-// Create form input (or select) for key-value pairs in receive templates:
-function inputsForKeyValue(key, schema) {
-    result = []
-    literal = false
-    if ('const' in schema) {
-        literal = true
-    }
-    if (schema['type'] == 'boolean' ||
-        (literal && typeof(schema['const']) == 'boolean')) {
-        // Create select with true and false options for Boolean:
-        const select = document.createElement('select')
-        select.setAttribute('name', key)
-        const optionTrue = document.createElement('option')
-        optionTrue.setAttribute('value', 'true')
-        const optionTrueContent = document.createTextNode('true')
-        optionTrue.appendChild(optionTrueContent)
-        select.appendChild(optionTrue)
-        const optionFalse = document.createElement('option')
-        optionFalse.setAttribute('value', false)
-        const optionFalseContent = document.createTextNode('false')
-        optionFalse.appendChild(optionFalseContent)
-        if (literal) {
-            // Select set value and disable other value
-            // for literal Boolean:
-            if (schema['const']) {
-                optionTrue.setAttribute('selected', '')
-                optioniFalse.setAttribute('disabled', '')
-            } else {
-                optionFalse.setAttribute('selected', '')
-                optionTrue.setAttribute('disabled', '')
-            }
-        }
-        select.appendChild(optionFalse)
-        result.push(select)
-    } else {
-        // Create input for everything except Booleans:
-        if (schema['type'] == 'string' ||
-            (literal && typeof(schema['const']) == 'string' &&
-             key != 'command')) {
-            // Quote strings:
-            const openquote = document.createTextNode('"')
-            result.push(openquote)
-        }
-        const input = document.createElement('input')
-        // Set type of input:
-        if (key == 'command') {
-            input.setAttribute('type', 'hidden')
-        } else if (schema['type'] == 'integer' ||
-                   schema['type'] == 'number' ||
-                   typeof(schema['const']) == 'number') {
-            input.setAttribute('type', 'number')
-            if (schema['type'] == 'integer') {
-                input.setAttribute('step', '1')
-            } else if (schema['type'] == 'number') {
-                input.setAttribute('step', 'any')
-            }
-        } else if (schema['type'] == 'string' ||
-                   typeof(schema['const']) == 'string') {
-            input.setAttribute('type', 'text')
-        }
-        // Set key as name of input:
-        input.setAttribute('name', key)
-        // Set value of input, readonly or required:
-        if (key == 'command') {
-            input.setAttribute('value', schema['const'])
-        } else if (literal) {
-            input.setAttribute('value', schema['const'])
-            input.setAttribute('readonly', '')
-        } else {
-            input.setAttribute('value', '')
-            input.setAttribute('required', '')
-        }
-        if (key != 'command') {
-            // Resize input field at every change and at beginning:
-            input.addEventListener('input', resizeInput)
-            resizeInput.call(input)
-        }
-        result.push(input)
-        if (schema['type'] == 'string' ||
-            (literal && typeof(schema['const']) == 'string' &&
-             key != 'command')) {
-            // Quote strings:
-            const closequote = document.createTextNode('"')
-            result.push(closequote)
-        }
-        if (key == 'command') {
-            // Add submit button for 'command' key:
-            const submit = document.createElement('input')
-            submit.setAttribute('type', 'submit')
-            submit.setAttribute('value', schema['const'])
-            result.push(submit)
-        }
-    }
-    return result
-}
-
-// Create div, form and table for command template:
-function createForCommand(template) {
-    const div = document.createElement('div')
-    div.setAttribute('class', 'message')
-    const form = document.createElement('form')
-    form.addEventListener("submit", function(e) {
-        // Convert names and values of all form elements to key-value pairs
-        // and send as JSON over websocket:
-        e.preventDefault();
-        var data = {};
-        for (const element of form.elements) {
-            if (element.type == 'text' || element.type == 'hidden') {
-                data[element.name] = element.value
-            } else if (element.type == 'number') {
-                data[element.name] = element.valueAsNumber
-            } else if (element.type == 'select-one') {
-                if (element.value == 'true') {
-                    data[element.name] = true
-                } else if (element.value == 'false') {
-                    data[element.name] = false
-                } else {
-                    data[element.name] = element.value
-                }
-            }
-        }
-        websocket.send(JSON.stringify(data))
-    })
-    const table = document.createElement('table')
-    for (const key in template) {
-        // Append table row for key-value pair:
-        const tr = document.createElement('tr')
-        const keyTd = document.createElement('td')
-        const keyTdContent = document.createTextNode(key + ':')
-        keyTd.appendChild(keyTdContent)
-        tr.appendChild(keyTd)
-        const valueTd = document.createElement('td')
-        for (const element of inputsForKeyValue(key, template[key])) {
-            valueTd.appendChild(element)
-        }
-        tr.appendChild(valueTd)
-        table.appendChild(tr)
-    }
-    form.appendChild(table)
-    div.appendChild(form)
-    return div
-}
-
-// Remove (if unregistered) or create (if not existent) elements for
-// clients and update interface information:
-function processBusMessage(message) {
-    if (message['event'] == 'unregistered') {
-        // On deregistration delete client element if it exists:
-        const clientElement = document.getElementById(message['client'])
-        if (clientElement != null) {
-            clientElement.remove()
-        }
-    } else {
-        // On registration or 'get clients' answer:
-        const clientElement = document.getElementById(message['client'])
-        if (clientElement == null) {
-            // Create element for client if not existent:
-            const main = document.getElementById('ControlPi Debug')
-            main.appendChild(createForClient(message['client'],
-                                             message['plugin']))
-        }
-        // Crate message elements for receives interface:
-        const receiveContainer = document.getElementById(message['client'] + ' Receives')
-        receiveContainer.innerHTML = ''
-        for (const template of message['receives']) {
-            if (template['command'] != null) {
-                receiveContainer.appendChild(createForCommand(template))
-            } else {
-                receiveContainer.appendChild(createForTemplate(template))
-            }
-        }
-        // Create message elements for sends interface:
-        const sendContainer = document.getElementById(message['client'] + ' Sends')
-        sendContainer.innerHTML = ''
-        for (const template of message['sends']) {
-            sendContainer.appendChild(createForTemplate(template))
-        }
-    }
-}
-
-// Recursively create div structure for arrays, objects and primitive
-// values:
-function createForMessageValue(value) {
-    var result = ''
-    if (typeof(value) == 'object') {
-        if (Array.isArray(value)) {
-            result = document.createElement('div')
-            result.setAttribute('class', 'array')
-            var counter = value.length
-            for (const child of value) {
-                result.appendChild(createForMessageValue(child))
-                if (--counter) {
-                    result.appendChild(document.createTextNode(', '))
-                }
-            }
-        } else {
-            result = document.createElement('div')
-            result.setAttribute('class', 'object')
-            for (const key in value) {
-                const property = document.createElement('div')
-                property.setAttribute('class', 'property')
-                const keyDiv = document.createElement('div')
-                keyDiv.setAttribute('class', 'key')
-                keyDiv.appendChild(document.createTextNode(key + ':'))
-                property.appendChild(keyDiv)
-                const valueDiv = document.createElement('div')
-                valueDiv.setAttribute('class', 'value')
-                valueDiv.appendChild(createForMessageValue(value[key]))
-                property.appendChild(valueDiv)
-                result.appendChild(property)
-            }
-        }
-    } else {
-        result = document.createTextNode(JSON.stringify(value))
-    }
-    return result
-}
-
-// Create div and table for message:
-function createForMessage(message) {
-    const div = document.createElement('div')
-    div.setAttribute('class', 'message')
-    // Current (receive) time as heading:
-    const time = new Date().toLocaleTimeString()
-    const h4 = document.createElement('h4')
-    const h4Content = document.createTextNode(time)
-    h4.appendChild(h4Content)
-    div.appendChild(h4)
-    const table = document.createElement('table')
-    for (const key in message) {
-        if (key == 'sender') {
-            // Ignore 'sender' for last received messages
-            // (information redundantly present in client heading):
-            continue
-        } else if (key == 'state') {
-            // Set background according to state:
-            if (message[key] === true) {
-                div.classList.add('green')
-            } else if (message[key] === false) {
-                div.classList.add('red')
-            }
-        }
-        // Append table row for key-value pair:
-        const tr = document.createElement('tr')
-        const keyTd = document.createElement('td')
-        const keyTdContent = document.createTextNode(key + ':')
-        keyTd.appendChild(keyTdContent)
-        tr.appendChild(keyTd)
-        const valueTd = document.createElement('td')
-        valueTd.appendChild(createForMessageValue(message[key]))
-        tr.appendChild(valueTd)
-        table.appendChild(tr)
-    }
-    div.appendChild(table)
-    return div
-}
-
-// Create element for client (if not existent)
-// and update last received message:
-function processClientMessage(message) {
-    const clientElement = document.getElementById(message['sender'])
-    if (clientElement == null) {
-        // Create section for client if not existent:
-        const main = document.getElementById('ControlPi Debug')
-        main.appendChild(createForClient(message.sender, ''))
-    }
-    // Update last received message:
-    const lastContainer = document.getElementById(message['sender'] + ' Last')
-    lastContainer.innerHTML = ''
-    lastContainer.appendChild(createForMessage(message))
-}
-
-// Open Websocket back to ControlPi we were loaded from:
-const websocket = new WebSocket("ws://" + window.location.host)
-
-// When Websocket is ready request all clients from bus:
-websocket.addEventListener('open', function (event) {
-    websocket.send(JSON.stringify({target: '', command: 'get clients'}))
-})
-
-// When receiving message from ControlPi through Websocket:
-websocket.addEventListener('message', function (event) {
-    const message = JSON.parse(event.data)
-    if (message['sender'] == '') {
-        processBusMessage(message)
-    } else {
-        processClientMessage(message)
-    }
-})
index e0f29d10ddda68e06f79fef15583552834dc053e..e5d2c50fa0c15ae4ae8e7febafd8c374563a0b01 100644 (file)
@@ -52,10 +52,20 @@ body > header span.IT {
     color: #83a1b4;
 }
 
-h1 {
+h1, h2, h3 {
     margin-top: 5px;
     margin-left: 5px;
-    margin-right: 5px;
-    font-size: 1.4rem;
     font-weight: bold;
 }
+
+h1 {
+    font-size: 1.4rem;
+}
+
+h2 {
+    font-size: 1.2rem;
+}
+
+h3 {
+    font-size: 1.2rem;
+}
index f6d6ff578bfecfeaf5e52e42324adc80626ec506..59c384e26c8396d898797d84229455d624220dc5 100644 (file)
@@ -6,8 +6,6 @@
         <title>ControlPi Debug</title>
         <link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgo=">
         <link rel="stylesheet" type="text/css" href="index.css">
-        <link rel="stylesheet" type="text/css" href="controlpi-debug.css">
-        <script src="controlpi-debug.js" defer></script>
     </head>
     <body>
         <header>
@@ -24,8 +22,7 @@
             </svg>
             <span class="Graph">Graph-</span><span class="IT">IT</span>
         </header>
-        <h1>ControlPi Debug</h1>
-        <main id="ControlPi Debug" class="clientcontainer">
-        </main>
+        <h1>ControlPi</h1>
+        <h2><a href="Debug/">Debug</a></h2>
     </body>
 </html>