comment 0

VC-4: Websockets

This post picks up right where the last one left off. I got VC-4 running on an RHEL instance, but now we’re back to some of the challenges that VC-4 brings… like printing to a console window.

Better Logging

Control Concepts has an excellent Websocket Server demo available on the Application Market. If you open the program, you’ll see a Websocket Server Lite module that gives you access to digitals, analogs, and serials (almost like a touchpanel, you might say):

There’s also a sample HTML5 page that acts as the Websocket Client from your web browser:

I’d like to use this module to setup a logging console we can connect to from a web page and catch messages as they spit out from our program. The module, however, is provided as a SIMPL User Macro, and we can’t run SIMPL Windows programs on VC-4. But that doesn’t stop us from being able to use the .NET assembly in a SIMPL# Pro program. Unzip the WebsocketServer.clz file and grab the WebsocketServer.dll file.

Create a new .NET Class Library (.NET Framework 4.7) project in Visual Studio 2019. Use NuGet to add the Crestron dependencies to your new project. Drop the WebsocketServer.dll file into the root directory of your project and add it as a reference. If any of this seems unfamiliar, check out my last post. You can delete the Class1.cs file from your project, we won’t need it. Update ControlSystem.cs to contain the following:

using System;
using Crestron.SimplSharp;
using Crestron.SimplSharpPro;
using Crestron.SimplSharpPro.CrestronThread;
using WebsocketServer;

namespace WebsocketLogger
{
    public class ControlSystem : CrestronControlSystem
    {
        private WebsocketSrvr _server;

        public ControlSystem()
            : base()
        {
            try
            {
                Thread.MaxNumberOfUserThreads = 20;

                _server = new WebsocketSrvr();
            }
            catch (Exception e)
            {
                ErrorLog.Error("Error in Constructor: {0}", e.Message);
            }
        }

        public override void InitializeSystem()
        {
            try
            {
                _server.Initialize(8081);
                _server.OnClientConnectedChange += OnClientConnected;
                _server.StartServer();
            }
            catch (Exception e)
            {
                ErrorLog.Error("Error in InitializeSystem: {0}", e.Message);
            }
        }

        private void OnClientConnected(ushort state)
        {
            if (state == 0)
            {
                // Disconnected
            }
            else
            {
                // Connected
                _server.SetIndirectTextSignal(1, "-- CONNECTED --");
            }
        }
    }
}

I’m going to load this program over the demo program on my CP4 and see if the provided HTML5 page connects OK:

Rock-n-roll! We’re connected!

Websockets on VC-4

Let’s try loading this to our VC-4 instance and see what happens:

Once this program has started, try connecting the example HTML5 page to it again. Hmm… doesn’t work this time:

Checking the error log on the VC-4 server, I don’t see anything that says the program failed to start. Let’s try changing the port number to something higher, like 54321. You’ll need to recompile the program, load it, update the port number in the HTML5 example and see if that fixes anything. Still no luck? Maybe something is blocking the websocket connection? Running netstat -ln on the VC-4 server, I can see our port listening for connections:

Proto Recv-Q Send-Q Local Address  Foreign Address  State
tcp        0      0 0.0.0.0:54321  0.0.0.0:*        LISTEN

But if I port scan from outside the server, I see a firewall is filtering it:

On the server, we can check the firewall status:

$ sudo firewall-cmd --state
running

OK but which ports are allowed?

$ sudo firewall-cmd --list-ports
161/udp 41794/tcp 41794/udp 41796/tcp 80/tcp 443/tcp

All we need to do is add our logger port:

$ sudo firewall-cmd --add-port=54321/tcp
success

Try connecting from the HTML5 web page again and… success! The last thing to do is make sure this firewall rule persists across reboots:

$ sudo firewall-cmd --runtime-to-permanent
success

Create a WebLogger class

Back in VS 2019, let’s add a new class to our project named WebLogger. We’ll take a lot of the logic from ControlSystem and move it into this class:

using System;
using System.Collections.Generic;
using Crestron.SimplSharp;
using WebsocketServer;

namespace WebsocketLogger
{
    class WebLogger
    {
        private WebsocketSrvr _server;
        private bool _clientConnected;

        private List<string> _backlog;

        public WebLogger()
        {
            try
            {
                _server = new WebsocketSrvr();
                _server.Initialize(54321);
                _server.OnClientConnectedChange += OnClientConnected;

                _backlog = new List<string>();

                _clientConnected = false;
            }
            catch (Exception e)
            {
                ErrorLog.Error("Exception in WebLogger constructor: {0}", e.Message);
            }
        }

        public void Start()
        {
            _server.StartServer();
        }

        public void Stop()
        {
            _server.StopServer();
        }

        public void WriteLine(string msg, params object[] args)
        {
            var text = String.Format(msg, args) + "\n";

            if (_clientConnected)
            {
                _server.SetIndirectTextSignal(1, text);
            }
            else
            {
                _backlog.Add(text);
            }
        }

        private void OnClientConnected(ushort state)
        {
            if (state == 0)
            {
                // Disconnected
                _clientConnected = false;
            }
            else
            {
                // Connected
                _clientConnected = true;
                _server.SetIndirectTextSignal(1, "\n-- CONNECTED --\n");

                if (_backlog.Count > 0)
                {
                    foreach (var msg in _backlog)
                    {
                        _server.SetIndirectTextSignal(1, msg);
                    }
                }

                _backlog.Clear();
            }
        }
    }
}

We’ll use a List to keep track of any log messages that might have been missed by our console. Once we connect, we’ll blast out any missed messages. Now update ControlSystem to use our new class:

using System;
using Crestron.SimplSharp;
using Crestron.SimplSharpPro;
using Crestron.SimplSharpPro.CrestronThread;

namespace WebsocketLogger
{
    public class ControlSystem : CrestronControlSystem
    {
        private WebLogger _log;
        private Thread _looper;

        public ControlSystem()
            : base()
        {
            try
            {
                Thread.MaxNumberOfUserThreads = 20;

                _log = new WebLogger();
            }
            catch (Exception e)
            {
                ErrorLog.Error("Error in Constructor: {0}", e.Message);
            }
        }

        public override void InitializeSystem()
        {
            try
            {
                _log.Start();
                _log.WriteLine("System is ready!");

                _looper = new Thread(Loop, null);
            }
            catch (Exception e)
            {
                ErrorLog.Error("Error in InitializeSystem: {0}", e.Message);
            }
        }

        private object Loop(object o)
        {
            while (true)
            {
                _log.WriteLine("Sleeping for 2 seconds...");
                Thread.Sleep(2000);
            }
        }
    }
}

This program just writes to the log every 2 seconds. If you wait a while before connecting, you should see a blast of messages. Bulid and load this to the VC-4 server. Replace the old program we’d loaded previously. Now lets create a console!

HTML Console

I’m going to hack apart the sample HTML5 page included with the Websocket Server demo. Create a new file named console.html with this:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link href="style.css" rel="stylesheet">
    <title>HTML5 Console</title>
    <script src="console.js"></script>
</head>
<body>
    <div id="top">
        <h1>Web Console</h1>
        <input type="text" id="host" placeholder="Host:Port" />
        <button id="connect">Connect</button>
        <button id="clear">Clear</button>
    </div>
    <pre id="console"></pre>
</body>
</html>

Now create style.css:

* {
    box-sizing: border-box;
}

body {
    font-family: sans-serif;
}

input[type=text] {
    width: 20rem;
    padding: 10px;
}

button {
    background-color: steelblue;
    color: white;
    border: none;
    padding: 10px 20px;
    cursor: pointer;
}

#top {
    background-color: lightgray;
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    padding: 1rem;
    transition: 2s linear;
    z-index: 1;
}

#top h1 {
    margin: 0 0 1rem 0;
}

#console {
    position: absolute;
    top: 8rem;
}

And lastly, create console.js:

var websocket = null;

function getBoundString(msg, startChar, stopChar)
{
    let response = "";
        
    if (msg != null && msg.length > 0)
    {
        let start = msg.indexOf(startChar);
            
        if (start >= 0)
        {
            start += startChar.length;
                
            let end = msg.indexOf(stopChar, start);
            
            if (start < end)
            {
                response = msg.substring(start, end);
            }
        }
    }
        
    return response;
}

function getBoundString_EndLastIndex(msg, startChar, stopChar)
{
    let response = "";
        
    if (msg != null && msg.length > 0)
    {
        let start = msg.indexOf(startChar);
            
        if (start >= 0)
        {
            start += startChar.length;
                
            let end = msg.lastIndexOf(stopChar);
            
            if (start < end)
            {
                response = msg.substring(start, end);
            }
        }
    }
        
    return response;
}	

function onMessage(event) 
{ 
    if (event != null)
    {
        const msg = event.data;
        
        //ON[CHANNEL]
        if (msg.indexOf("ON[") == 0)
        {
            const channel = parseInt(getBoundString(msg, "ON[", "]"), 10);
            
            /*
            if (isNaN(channel) == false)
            {
                var button = document.getElementById("fb" + channel);
                
                if (button != null)
                    button.style.background = "green";
            }
            */
        }
        //OFF[CHANNEL]
        else if (msg.indexOf("OFF[") == 0)
        {
            const channel = parseInt(getBoundString(msg, "OFF[", "]"), 10);

            /*
            if (isNaN(channel) == false)
            {
                var button = document.getElementById("fb" + channel);
                
                if (button != null)
                    button.style.background = "";
            }
            */
        }
        // LEVEL[LEVEL,VALUE]
        else if (msg.indexOf("LEVEL[") == 0)
        {
            const level = parseInt(getBoundString(msg, "LEVEL[", ","), 10);
            const value = parseInt(getBoundString(msg, ",", "]"), 10);					

            /*
            // set slider level
            var slider = document.getElementById("sliderInput" + level);
            
            if (slider != null)
                slider.value = value;
            
            // set feedback text
            var text = document.getElementById("analogValue" + level);
            
            if (text != null)
                text.innerHTML = "" + value;
            */
        }
        // STRING[SIG,DATA]
        else if (msg.indexOf("STRING[") == 0)
        {
            const text = parseInt(getBoundString(msg, "STRING[", ","), 10);
            const value = getBoundString_EndLastIndex(msg, ",", "]");					
                            
            // set receiving text
            const consoleElement = document.querySelector('#console');
            consoleElement.innerHTML += value;
        }
    }
}  

function doSend(message) 
{
    if (websocket !== null) {
        websocket.send(message); 
    }
}  

function socketclose()
{
    if (websocket !== null) {
        websocket.close();
    }
}

function doPush(channel)
{
    doSend("PUSH[" + channel + "]");
}

function doRelease(channel)
{
    doSend("RELEASE[" + channel + "]");
}

function sendLevel(sig)
{
    /*
    var inputRange = document.getElementById("sliderInput" + sig);

    if (inputRange != null)
        doSend("LEVEL[" + sig + "," + inputRange.value + "]");
    */
}

function sendString(sig)
{
    /*
    var inputText = document.getElementById("stringInput" + sig);
    
    if (inputText != null)
        doSend("STRING[" + sig + "," + inputText.value + "]");
    */
}

function startWebsocket() 
{ 
    const hostAddress = document.querySelector('#host');
    const ip = hostAddress.value;
    
    if (ip != '')
    {
        let wsUri = "ws://";

        if (ip.includes(':')) {
            wsUri += ip;
        }
        else {
            wsUri += ip + ":8081/";
        }
        
        websocket = new WebSocket(wsUri); 
        
        websocket.onopen = function (event) {
            const topArea = document.querySelector('#top');
            const connectBtn = document.querySelector('#connect');

            topArea.style.backgroundColor = 'lightgreen';
            connectBtn.innerHTML = 'Disconnect';
            connectBtn.style.backgroundColor = 'red';
            connectBtn.disabled = false;
        }; 
        
        websocket.onclose = function (event) { 
            const topArea = document.querySelector('#top');
            const hostAddress = document.querySelector('#host');
            const connectBtn = document.querySelector('#connect');

            topArea.style.backgroundColor = 'lightgray';
            connectBtn.innerHTML = 'Connect';
            connectBtn.style.backgroundColor = 'steelblue';
            hostAddress.disabled = false;
            connectBtn.disabled = false;
        }; 
        
        websocket.onmessage = function(event) { 
            onMessage(event) 
        }; 
        
        websocket.onerror = function(event) { 
            // todo
        };
    }
}

window.addEventListener("DOMContentLoaded", function () {
    const hostAddress = document.querySelector('#host');
    const connectBtn = document.querySelector('#connect');
    const clearBtn = document.querySelector('#clear');

    connectBtn.addEventListener('click', function () {
        if (hostAddress.value !== '') {
            if (connectBtn.innerHTML == 'Connect') {
                hostAddress.disabled = true;
                connectBtn.disabled = true;
                startWebsocket();
            }
            else {
                socketclose();
            }
        }
    });

    clearBtn.addEventListener('click', function () {
        const consoleElement = document.querySelector('#console');
        consoleElement.innerHTML = '';
    });
});  

Most of these functions came from the Control Concepts demo, I just adapted them to work with the HTML we created in console.html.

Now you can use a web browser to open console.html and watch it print messages from our program!

Accessing the Console

We could open this file locally from our laptop any time we want to fire up the console, but wouldn’t it be handy to keep it with the program? If you zip the 3 files together, you can load it as a “configuration page” to VC-4:

When your program restarts, you can browse to http://address/VirtualControl/Rooms/program-tag/Html/console.html to access your console.

I’m sure this console can be improved upon, so please grab it from GitHub and see if you can make it work for you. Thanks for reading!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s