HTML5 Huddle Room

For our first room type, we’re going to program a very simple huddle room. It will focus around a PC-based codec that we won’t control directly. These spaces are typically low-cost and plentiful, but only allow 3 or 4 people to use them.

Keep It Simple

Huddle Room design is easy: keep it simple, keep it cheap, rinse, and repeat.

System Design

We want the system to automatically turn on when someone enters the room and shutdown once the room has remained vacant long enough. Huddle Rooms might be scheduled, but they’re more likely used ad-hoc since they’re for quick, small meetings. Users might want to share their laptop or join into some form of video call.

Here’s a quick sketch of how the system will work:

We should be mindful that since we’re using VC-4, all of our networked devices should allow entering the ROOM ID into their IP tables.

We’ll perform display control via RS-232 through the DM-RMC-4KZ-100-C. The HDMI output of the DM receiver will actually feed a content input to the NUC PC rather than the display. The display will always show the conferencing interface on the NUC (whether Zoom, Teams, WebEx, etc.). A 3rd party touch control is used with the NUC so we don’t have to provide any control integration with it.

I think this is about as simple a design as I can imagine. Unfortunately, it doesn’t need an HTML5 interface, but we’ll create one anyway.

Implementation

We’re going to work backwards on this one to get our folder structure setup correctly, so bear with me. You can grab the code from my GitHub repo.

Code

Create a new Class Library (.NET Framework) project in VS 2019. We’ll create a new solution named RoomExamples to stash our future programs in. The project name will be HuddleRoom. Make sure it targets .NET Framework 4.7.

Add the usual Crestron NuGet packages to the project and delete Class1.cs. Pare down ControlSystem.cs to the bare minimum:

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

namespace HuddleRoom
{
    public class ControlSystem : CrestronControlSystem
    {
        private BasicTriListWithSmartObject _tp;
        private ushort _src;

        public ControlSystem()
            : base()
        {
            try
            {
                Thread.MaxNumberOfUserThreads = 20;
            }
            catch (Exception e)
            {
                ErrorLog.Error("Error in ControlSystem: {0}", e.Message);
            }
        }

        public override void InitializeSystem()
        {
            try
            {
                _tp = new Tsw1060(0x03, this);
                _tp.OnlineStatusChange += tp_OnlineStatusChange;
                _tp.SigChange += tp_SigChange;

                if (_tp.Register() != eDeviceRegistrationUnRegistrationResponse.Success)
                {
                    ErrorLog.Error("Unable to register TSW-1060: {0}", _tp.RegistrationFailureReason);
                }
            }
            catch (Exception e)
            {
                ErrorLog.Error("Error in InitializeSystem: {0}", e.Message);
            }
        }

        private void tp_OnlineStatusChange(GenericBase dev, OnlineOfflineEventArgs args)
        {
            if (args.DeviceOnLine)
            {
                var tp = (BasicTriList)dev;

                tp.StringInput[1].StringValue = "Huddle Room";
                tp.StringInput[2].StringValue = "x1234";

                tp.UShortInput[1].UShortValue = _src;
            }
        }

        private void tp_SigChange(BasicTriList dev, SigEventArgs args)
        {
            switch (args.Sig.Type)
            {
                case eSigType.UShort:
                    switch (args.Sig.Number)
                    {
                        case 1:
                            _src = args.Sig.UShortValue;
                            break;
                    }
                    break;
            }
        }
    }
}

Build this and make sure there aren’t any errors. Load this program to a 4-series processor (I’ll be using a VC-4 instance).

New Folder Layout

Make a new folder named html inside ExampleRooms/HuddleRoom. Copy everything from the src folder into the ExampleRooms/HuddleRoom/html folder. This will save us some time since we’re only making minor changes.

Create a new git branch to track our work:

$ git checkout -b HuddleRoom
$ git add HuddleRoom
$ git commit

Since we’re moving the touchpanel project into a subfolder, we need to make some changes to the way Webpack searches for files. The locations will change slightly from project to project. Update webpack.config.js to allow us to pass environment variables in to control where things go:

const path = require('path');

const CopyPlugin = require('copy-webpack-plugin');

module.exports = function (env) {
    if (env.target === undefined) {
        env.target = '';
    }

    if (env.source === undefined) {
        env.source = 'src';
    }

    return {
        mode: 'development',
        entry: './' + env.source + '/app.js',
        output: {
            filename: 'bundle.js',
            path: path.resolve(__dirname, env.target, 'dist')
        },
        plugins: [
            new CopyPlugin({
                patterns: [
                    {
                        from: env.source + '/assets',
                        to: 'assets/'
                    },
                    {
                        from: env.source + '/*.(html|css)',
                        to: '[name][ext]'
                    }
                ]
            })
        ]
    };
};

If we’re given source and target environment variables, we can point to different entry and output paths.

Now update package.json to pass in a new path for our Huddle Room project:

{
  "name": "html5-tutorial",
  "version": "1.0.0",
  "description": "Walks through building different HTML5 layouts for AV systems",
  "main": "app.js",
  "scripts": {
    "build": "webpack",
    "archive": "ch5-cli archive -p project -d dist -o archive",
    "onestep": "npm run build && npm run archive",
    "huddle:build": "webpack --env source=ExampleRooms/HuddleRoom/html --env target=ExampleRooms/HuddleRoom",
    "huddle:archive": "ch5-cli archive -p HuddleRoom -d ExampleRooms/HuddleRoom/dist -o archive"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/kielthecoder/HTML5-Tutorial.git"
  },
  "author": "Kiel Lofstrand",
  "license": "MIT",
  "bugs": {
    "url": "https://github.com/kielthecoder/HTML5-Tutorial/issues"
  },
  "homepage": "https://github.com/kielthecoder/HTML5-Tutorial#readme",
  "dependencies": {
    "@crestron/ch5-crcomlib": "^1.1.0",
    "@crestron/ch5-webxpanel": "^1.0.3",
    "moment": "^2.29.1"
  },
  "devDependencies": {
    "copy-webpack-plugin": "^9.0.1",
    "webpack": "^5.47.0",
    "webpack-cli": "^4.7.2"
  }
}

If you run the huddle:build and huddle:archive scripts, you’ll see HuddleRoom.ch5z gets built and placed into the archive/ folder.

Commit your changes to git. Next, we’ll work on touchpanel layout changes.

Touchpanel

Our touchpanel layout for this system is pretty simple: the system is either on or it’s off. I’m also going to get rid of the Settings pop-up for now and replace it with a Help screen.

html/index.html

Here are the changes to index.html:

<!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">
    <title>Document</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <section id="top">
        <div id="room-name">Room Name</div>
        <div id="time">12:00 PM</div>
        <div id="date">Tuesday, July 27, 2021</div>
    </section>

    <section id="view">
        <div class="card active" id="card-welcome">
            <div></div>
            <div class="text welcome-text">
                <h1>Welcome</h1>
                <h2>Choose a source from the menu below to get started.</h2>
            </div>
        </div>

        <div class="card" id="card-pc">
            <div class="icon">
                <img src="./assets/img/PC.png" />
            </div>
            <div class="text">
                <h1>Room PC</h1>
                <h2>Follow the on-screen instructions to connect your video call.</h2>
            </div>
        </div>

        <div class="popup">
            <div class="card" id="popup-help">
                <div class="icon">
                    <img src="./assets/img/Amenities.png" />
                </div>
                <div class="text">
                    <h1>Help</h1>
                    <h2>Call <span id="help-number">x1234</span> for assistance with this room.</h2>
                    <h3>
                        <button class="normal cancel">Cancel</button>
                    </h3>
                </div>
            </div>
            
            <div class="card " id="popup-shutdown">
                <div class="icon">
                    <img src="./assets/img/Power.png" />
                </div>
                <div class="text">
                    <h1>Shutdown</h1>
                    <h2>Are you sure you want to shutdown the system?</h2>
                    <h3>
                        <button class="danger" id="btn-shutdown-shutdown">Shutdown</button>
                        <button class="normal cancel">Cancel</button>
                    </h3>
                </div>
            </div>
        </div>
    </section>

    <section id="bottom">
        <div class="system">
            <button id="btn-shutdown">
                <img class="icon" src="./assets/img/Power_Dark.png" />
                <span class="text">Shutdown</span>
            </button>
        </div>
        <div class="sources">
            <button id="btn-pc">
                <img class="icon" src="./assets/img/Windows_Dark.png" />
                <span class="text">Room PC</span>
            </button>
        </div>
        <div class="settings">
            <button id="btn-help">
                <img class="icon" src="./assets/img/Help_Dark.png" />
                <span class="text">Help</span>
            </button>
        </div>
    </section>
    <script src="bundle.js"></script>
</body>
</html>

html/app.js

Here are the changes to app.js:

const webXpanel = require('@crestron/ch5-webxpanel/dist/cjs/index.js');

const configuration = {
    host: '192.168.1.13',
    ipId: '4'
};

if (webXpanel.isActive) {
    console.log(`WebXPanel version: ${webXpanel.getVersion()}`);
    console.log(`WebXPanel build date: ${webXpanel.getBuildDate()}`);

    webXpanel.default.initialize(configuration);
}
else {
    console.log('Skipping WebXPanel since running on touchpanel');
}

const CrComLib = require('@crestron/ch5-crcomlib/build_bundles/cjs/cr-com-lib.js');

window.CrComLib = CrComLib;
window.bridgeReceiveIntegerFromNative = CrComLib.bridgeReceiveIntegerFromNative;
window.bridgeReceiveBooleanFromNative = CrComLib.bridgeReceiveBooleanFromNative;
window.bridgeReceiveStringFromNative = CrComLib.bridgeReceiveStringFromNative;
window.bridgeReceiveObjectFromNative = CrComLib.bridgeReceiveObjectFromNative;

const moment = require('moment');

CrComLib.subscribeState('s', '1', (value) => {
    const elem = document.getElementById('room-name');
    elem.innerHTML = value;
});

CrComLib.subscribeState('s', '2', (value) => {
    const elem = document.getElementById('help-number');
    elem.innerHTML = value;
});

var activeCard = document.getElementById('card-welcome');
var prevCard;

function showCard (nextCard) {
    if (activeCard.id != nextCard) {
        const popup = document.getElementsByClassName('popup')[0];
        activeCard.classList.remove('active');

        if (nextCard.substring(0, 6) == 'popup-') {
            popup.classList.add('active');
        }
        else {
            popup.classList.remove('active');
        }

        prevCard = activeCard;
        activeCard = document.getElementById(nextCard);
        activeCard.classList.add('active');

        const name = activeCard.id.substring(activeCard.id.indexOf('-') + 1);
        const bottom = document.getElementById('bottom');
        Array.from(bottom.getElementsByTagName('BUTTON')).forEach((btn) => {
            if (btn.id == `btn-${name}`) {
                btn.classList.add('active');
            }
            else {
                btn.classList.remove('active');
            }
        });
    }
}

function routeSource (n) {
    CrComLib.publishEvent('n', '1', n);
}

CrComLib.subscribeState('n', '1', (value) => {
    switch (value) {
        case 0:
            showCard('card-welcome');
            break;
        case 1:
            showCard('card-laptop');
            break;
        case 2:
            showCard('card-appletv');
            break;
        case 3:
            showCard('card-pc');
            break;
    }
});

const btnPC = document.getElementById('btn-pc');

btnPC.addEventListener('click', (e) => {
    showCard('card-pc');
    routeSource(3);
})

const btnHelp = document.getElementById('btn-help');
const btnShutdown = document.getElementById('btn-shutdown');

btnHelp.addEventListener('click', (e) => showCard('popup-help'));
btnShutdown.addEventListener('click', (e) => showCard('popup-shutdown'));

function previousCard() {
    if (prevCard !== undefined) {
        showCard(prevCard.id);
        prevCard = undefined;
    }
}

const btnShutdownShutdown = document.getElementById('btn-shutdown-shutdown');
btnShutdownShutdown.addEventListener('click', (e) => {
    showCard('card-welcome');
    routeSource(0);
});

const btnsCancel = Array.from(document.getElementsByClassName('cancel'))
btnsCancel.forEach((btn) => {
    btn.addEventListener('click', (e) => previousCard());
});

const lblTime = document.getElementById('time');
const lblDate = document.getElementById('date');

setInterval(() => {
    lblTime.innerText = moment().format('h:mm A');
    lblDate.innerText = moment().format('dddd, MMMM Do, YYYY');
}, 5000);

On line 33, we listen for changes on serial join 2. We’ll use this to populate the help number for this room.

On line 85, we handle the new Room PC source (numbered 3 in our code).

On line 91, we setup an event handler for the new PC button. We’ll show the PC card and route source 3.

On line 98, we grab the new help button. On line 101, we handle showing the help pop-up when the button is clicked.

Build this layout using the new huddle:build and huddle:archive NPM scripts. Load to a touchpanel if you have it or load to a processor as a WebXPanel.

Don’t forget to git commit!

More Code

Head back over to VS 2019 and we’ll finish this example off.

ControlSystem.cs

Lets add code to check for room occupancy. First we need to include the Crestron.SimplSharpPro.GeneralIO namespace:

using System;
using Crestron.SimplSharp;
using Crestron.SimplSharpPro;
using Crestron.SimplSharpPro.CrestronThread;
using Crestron.SimplSharpPro.DeviceSupport;
using Crestron.SimplSharpPro.GeneralIO;
using Crestron.SimplSharpPro.UI;

namespace HuddleRoom
{

Declare an _occSensor:

public class ControlSystem : CrestronControlSystem
{
    private BasicTriListWithSmartObject _tp;
    private CenOdtCPoe _occSensor;
    private ushort _src;

    public ControlSystem()
        : base()
    {

Add some initialization code to InitializeSystem:

public override void InitializeSystem()
{
    try
    {
        _tp = new Tsw1060(0x03, this);
        _tp.OnlineStatusChange += tp_OnlineStatusChange;
        _tp.SigChange += tp_SigChange;

        if (_tp.Register() != eDeviceRegistrationUnRegistrationResponse.Success)
        {
            ErrorLog.Error("Unable to register TSW-1060: {0}", _tp.RegistrationFailureReason);
        }

        _occSensor = new CenOdtCPoe(0x04, this);
        _occSensor.CenOccupancySensorChange += occSensor_CenOccupancySensorChange;

        if (_occSensor.Register() != eDeviceRegistrationUnRegistrationResponse.Success)
        {
            ErrorLog.Error("Unable to register CEN-ODT-C-POE: {0}", _occSensor.RegistrationFailureReason);
        }
    }
    catch (Exception e)
    {
        ErrorLog.Error("Error in InitializeSystem: {0}", e.Message);
    }
}

Lastly, add a placeholder event handler for occupancy changes. We’ll finish this off shortly:

private void occSensor_CenOccupancySensorChange(object sender, GenericEventArgs args)
{
    var sensor = sender as CenOdtCPoe;

    switch (args.EventId)
    {
        case GlsOccupancySensorBase.RoomOccupiedFeedbackEventId:
            if (sensor.OccupancyDetectedFeedback.BoolValue)
            {
                // TODO: turn system on
            }
            break;
        case GlsOccupancySensorBase.RoomVacantFeedbackEventId:
            if (sensor.VacancyDetectedFeedback.BoolValue)
            {
                // TODO: turn system off
            }
            break;
    }
}

Next, we’ll handle display control. I’m going to breeze through this. First, make sure we have all the namespaces we need:

using System;
using Crestron.SimplSharp;
using Crestron.SimplSharpPro;
using Crestron.SimplSharpPro.CrestronThread;
using Crestron.SimplSharpPro.DeviceSupport;
using Crestron.SimplSharpPro.DM.Endpoints;
using Crestron.SimplSharpPro.DM.Endpoints.Receivers;
using Crestron.SimplSharpPro.DM.Endpoints.Transmitters;
using Crestron.SimplSharpPro.GeneralIO;
using Crestron.SimplSharpPro.UI;

namespace HuddleRoom
{

Add a room controller to our class:

public class ControlSystem : CrestronControlSystem
{
    private BasicTriListWithSmartObject _tp;
    private CenOdtCPoe _occSensor;
    private DmRmc4kz100C _rmc;
    private ushort _src;

    public ControlSystem()
        : base()
    {

And initialize it in InitializeSystem:

    _occSensor = new CenOdtCPoe(0x04, this);
    _occSensor.CenOccupancySensorChange += occSensor_CenOccupancySensorChange;

    if (_occSensor.Register() != eDeviceRegistrationUnRegistrationResponse.Success)
    {
        ErrorLog.Error("Unable to register CEN-ODT-C-POE: {0}", _occSensor.RegistrationFailureReason);
    }

    _rmc = new DmRmc4kz100C(0x14, this);
    _rmc.ComPorts[1].SetComPortSpec(ComPort.eComBaudRates.ComspecBaudRate9600,
        ComPort.eComDataBits.ComspecDataBits8, ComPort.eComParityType.ComspecParityNone,
        ComPort.eComStopBits.ComspecStopBits1, ComPort.eComProtocolType.ComspecProtocolRS232,
        ComPort.eComHardwareHandshakeType.ComspecHardwareHandshakeNone,
        ComPort.eComSoftwareHandshakeType.ComspecSoftwareHandshakeNone, false);
    _rmc.ComPorts[1].SerialDataReceived += display_DataReceived;

    if (_rmc.Register() != eDeviceRegistrationUnRegistrationResponse.Success)
    {
        ErrorLog.Error("Unable to register DM-RMC-4KZ-100-C: {0}", _rmc.RegistrationFailureReason);
    }
}

This looks like a lot is happening, but it’s just because we’re setting up the COM port on the DM-RMC-4KZ-100-C to match our display settings (9600 baud, 8 data bits, 1 stop bit, no parity). We also register an event handler named display_DataReceived to process responses from the display.

Next, we’ll create a couple helper functions that we can use within our program:

public void system_On()
{
    _rmc.ComPorts[1].Send("PWR ON\r");
}

public void system_Off()
{
    _rmc.ComPorts[1].Send("PWR OFF\r");
}

public void SetSource (ushort newSource)
{
    _src = newSource;

    if (_src == (ushort)SourceIds.None)
    {
        system_Off();
    }
    else
    {
        system_On();
    }

    _tp.UShortInput[1].UShortValue = _src;
}

We need to update the tp_SigChange and occSensor_Change handlers to use our new helpers (so it will handle turning the display on and off when selecting a source). We can also add a stub event handler for display_DataReceived:

private void tp_SigChange(BasicTriList dev, SigEventArgs args)
{
    switch (args.Sig.Type)
    {
        case eSigType.UShort:
            switch (args.Sig.Number)
            {
                case 1:
                    SetSource(args.Sig.UShortValue);
                    break;
            }
            break;
    }
}

private void occSensor_Change(object sender, GenericEventArgs args)
{
    var sensor = sender as CenOdtCPoe;

    switch (args.EventId)
    {
        case GlsOccupancySensorBase.RoomOccupiedFeedbackEventId:
            if (sensor.OccupancyDetectedFeedback.BoolValue)
            {
                SetSource((ushort)SourceIds.RoomPC);   // default to Room PC
            }
            break;
        case GlsOccupancySensorBase.RoomVacantFeedbackEventId:
            if (sensor.VacancyDetectedFeedback.BoolValue)
            {
                SetSource((ushort)SourceIds.None);
            }
            break;
    }
}

private void display_DataReceived(ComPort port, ComPortSerialDataEventArgs args)
{
    // TODO
}

Lastly, we’d like to make sure the system turns on if someone connects their laptop at the table. Add a DM transmitter to our class:

public class ControlSystem : CrestronControlSystem
{
    private BasicTriListWithSmartObject _tp;
    private CenOdtCPoe _occSensor;
    private DmRmc4kz100C _rmc;
    private DmTx4kz202C _tx;
    private ushort _src;

    public ControlSystem()
        : base()
    {

Initialize it in InitializeSystem:

    _tx = new DmTx4kz202C(0x15, this);
    _tx.HdmiInputs[1].InputStreamChange += laptop_StreamChange;
    _tx.HdmiInputs[2].InputStreamChange += laptop_StreamChange;
}

We’ll handle InputStreamChange events with laptop_StreamChange:

private void laptop_StreamChange(EndpointInputStream stream, EndpointInputStreamEventArgs args)
{
    if (args.EventId == EndpointInputStreamEventIds.SyncDetectedFeedbackEventId)
    {
        var inputStream = stream as EndpointHdmiInput;

        if (inputStream.SyncDetectedFeedback.BoolValue)
        {
            SetSource((ushort)SourceIds.RoomPC);   // make sure Room PC is on display
        }
    }
}

The transmitter will auto-switch to whichever HDMI input is active, but we want to make sure we turn the system on and update the touchpanel to match.

That’s it! Build the program, load to your processor, test away. And don’t forget to git commit your hard work.

Extending This System

Here are some obvious ways to extend this system:

  • Add a switcher in front of the PC content so you have the option to share other sources

Remember, the purpose of the Huddle Room is to be low-cost and low-friction, we don’t want to add too many bells and whistles. Which leads us to our next room type, the Conference Room.

One thought on “HTML5 Huddle Room

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 )

Facebook photo

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

Connecting to %s