It is always a frustration when you turn up to a meeting room you’ve diligently booked weeks in advance only to find it very much in use. Should you walk away? Barge in? Or design a Raspberry Pi based meeting room display?
I went with the latter.
Now, such devices already exist from a number of companies. The idea isn’t new. However, they often run into the thousands of pounds, require licencing, whole servers etc. My plan was to create something for a fraction of that cost, which could do pretty much the same job.
A Raspberry Pi is a perfect platform for this kind of project, as everything we need can be sourced and integrated easily.
My shopping list would be:
- Raspberry Pi 3
- Raspberry Pi 7-Inch Touch Screen Display
- 5v PoE splitter
- Wooden picture frame to accommodate the above
The Raspberry Pi and screen go together easily, a single ribbon cable and two pins for power and we’re done. The Pi and screen can be screwed together with standoffs.
To keep the ribbon cable as neat as possible, the best route was out from the Pi, under the controller board and then into the board at the other side. One of the USB headers on the Pi made this last part very tight and I didn’t like how much of a bend it forced on the cable.
Therefore to gain a bit more clearance for the ribbon cable I de-soldered the middle USB header which made a huge difference.
I used a 5v PoE splitter to power the system. This means the only wire the system needs is a single network cable. To save space I removed the case and attached the splitter to the rear of the screen with hot glue. I used a bit of hot glue for strain relief and isolation too. These PoE splitters are very handy and have a smaller footprint than the Pi PoE hats on the market.
I got a wooden frame made at Picture Frame Express. The dimensions below worked well.
Everything fit nicely with at least 5mm clearance for the back panel.
Because the official Raspberry Pi screen has some bizarrely thick borders on the sides it made the screen look odd when mounted straight in the frame.
I ordered a card mount to hide the borders and just display the actual screen. The aperture of this was 152mm x 82mm.
I decided to write the interface for the screens as a web application. The only thing required of the Pi would therefore be to load a web browser and open the web app.
I wanted the interface to display the current and upcoming meetings for the room, and also our guest wifi key which changes every week.
I first needed to create a back-end service that would expose the required information. On a server inside our corporate network I spun up an IIS website. I used the php-ews library to get events from Microsoft Exchange and output them as JSON.
<?php
header("Access-Control-Allow-Origin: *");
require_once 'vendor/autoload.php';
use \jamesiarmes\PhpEws\Client;
use \jamesiarmes\PhpEws\Request\FindItemType;
use \jamesiarmes\PhpEws\ArrayType\NonEmptyArrayOfBaseFolderIdsType;
use \jamesiarmes\PhpEws\Enumeration\DefaultShapeNamesType;
use \jamesiarmes\PhpEws\Enumeration\DistinguishedFolderIdNameType;
use \jamesiarmes\PhpEws\Enumeration\ResponseClassType;
use \jamesiarmes\PhpEws\Enumeration\ItemQueryTraversalType;
use \jamesiarmes\PhpEws\Type\CalendarViewType;
use \jamesiarmes\PhpEws\Type\DistinguishedFolderIdType;
use \jamesiarmes\PhpEws\Type\ItemResponseShapeType;
use \jamesiarmes\PhpEws\Type\EmailAddressType;
@$index = 0+$_REQUEST['index'];
$rooms[0] = "Boardroom@corp.domain.com";
$rooms[1] = "MeetingRoom@corp.domain.com";
$host = 'mail.corp.domain.com';
$username = 'svc_meetingroomscreens';
$password = 'password';
$version = Client::VERSION_2016;
$timezone = 'GMT Standard Time';
$email_room = $rooms[$index];
$start_date = new DateTime('today');
$end_date = new DateTime('tomorrow');
$client = new Client($host, $username, $password, $version);
$client->setTimezone($timezone);
$request = new FindItemType();
$request->Traversal = ItemQueryTraversalType::SHALLOW;
$request->ItemShape = new ItemResponseShapeType();
$request->ItemShape->BaseShape = DefaultShapeNamesType::DEFAULT_PROPERTIES;
$request->CalendarView = new CalendarViewType();
$request->CalendarView->StartDate = $start_date->format('c');
$request->CalendarView->EndDate = $end_date->format('c');
$folder_id = new DistinguishedFolderIdType();
$folder_id->Id = DistinguishedFolderIdNameType::CALENDAR;
$folder_id->Mailbox = new EmailAddressType();
$folder_id->Mailbox->EmailAddress = $email_room;
$request->ParentFolderIds->DistinguishedFolderId[] = $folder_id;
$response = $client->FindItem($request);
$response_messages = $response->ResponseMessages->FindItemResponseMessage;
foreach ($response_messages as $response_message) {
if ($response_message->ResponseClass != ResponseClassType::SUCCESS) {
$code = $response_message->ResponseCode;
$message = $response_message->MessageText;
echo "{\n\"result\": \"ERROR\",\n";
echo "\"code\": \"".$message."\"";
echo "\n}";
continue;
}
$items = $response_message->RootFolder->Items->CalendarItem;
$events = [];
foreach ($items as $item) {
$event['id'] = $item->ItemId->Id;
$event['title'] = $item->Subject;
$event['start'] = $item->Start;
$event['end'] = $item->End;
$event['organ'] = $item->Organizer->Mailbox->Name;
$events[] = $event;
}
echo "{\n\"result\": \"OK\",\n";
echo "\"events\": ".json_encode($events, JSON_PRETTY_PRINT);
echo "\n}";
}
?>
I then needed to grab our current wifi key in much the same way. We use Cisco access points controlled by the integrated Wireless LAN Controller on 3850 series switches.
Cisco switches have a somewhat crusty web API for reading and changing config. To get our wifi details I simply needed to execute the command show run | b wlan Guest_WiFi 3 Guest_WiFi
and the key should be present straight after the first instance of security wpa akm psk set-key ascii 0
in the output.
My php code went as follows:
<?php
header("Access-Control-Allow-Origin: *");
$username = "svc-wifireader";
$password = "password";
$url = 'https://switch.corp.domain.com/level/15/exec/-/show/run/%7C/b/wlan/Guest/CR';
$key = "";
$fields_string = "";
$fields = array(
"command" => "show run | b wlan Guest_WiFi 3 Guest_WiFi",
"command_url" => "/level/15/exec/-",
);
foreach($fields as $key=>$value) { $fields_string .= $key.'='.$value.'&'; }
rtrim($fields_string, '&');
$ch = curl_init();
curl_setopt($ch,CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch,CURLOPT_POST, count($fields));
curl_setopt($ch,CURLOPT_POSTFIELDS, $fields_string);
curl_setopt($ch,CURLOPT_USERPWD, $username . ":" . $password);
curl_setopt($ch,CURLOPT_SSL_VERIFYHOST, 0);
curl_setopt($ch,CURLOPT_SSL_VERIFYPEER, 0);
curl_setopt($ch,CURLOPT_COOKIE, 'privilege=15');
$result = curl_exec($ch);
$n = "security wpa akm psk set-key ascii 0 ";
$key = substr($result, strpos($result, $n)+strlen($n), strpos(substr($result, strpos($result, $n)+strlen($n)), "\r"));
if ($key){
echo "{\n\"result\": \"OK\",\n";
echo "\"key\": \"".urlencode($key)."\"";
echo "\n}";
}else{
echo "{\n\"result\": \"ERROR\",\n";
echo "\"code\": \"empty string\"";
echo "\n}";
}
curl_close($ch);
?>
I then created the following frontend interface which calls the two backend services:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link href='css/style.css' rel='stylesheet' type='text/css'>
<script type="text/javascript" src="js/jquery.min.js"></script>
<script type="text/javascript" src="js/jquery.qrcode.min.js"></script>
</head>
<body>
<div id="screensaver"></div>
<div id="outer">
<div id="logo"></div>
<div id="time">00:00</div>
<div id="room"></div>
<div id="avail"><div id="head1">Now</div><div id="cal_now"></div><div id="head2">Upcoming</div><div id="cal_next"></div></div>
<div id="wifi"><div id="whead">This week's WiFi key is</div><div id="key">---</div></div>
<div id="qrcode"></div>
</div>
<script>
var roomIndex = 1;
var roomName = "Boardroom";
var endpointBase = "https://meetings.corp.domain.com";
$(function() {
$( "#room" ).text(roomName);
var intTime = window.setInterval(updateTime, 1000);
updateTime();
});
var flash = 0;
var lastmin = 0;
var firstrun = 1;
function updateTime(){
var dn = new Date();
thismin = dn.getFullMinutes();
if ((dn.getHours() < 7) || (dn.getHours() >= 19)){
screensaver = 1;
}else{
screensaver = 0;
}
if (screensaver == 0){
$( "#screensaver" ).hide();
if (flash == 0){
$( "#time" ).text( dn.getHours() + ":" + thismin );
flash = 1;
}else{
$( "#time" ).text( dn.getHours() + " " + thismin );
flash = 0;
}
if (thismin != lastmin){
updateEvents();
}
if (firstrun == 1){
firstrun = 0;
updateWifi();
}else{
if (thismin != lastmin && thismin == 00){
updateWifi();
}
}
}else{
$( "#screensaver" ).show();
}
lastmin = thismin;
}
function updateEvents(){
$.get( endpointBase + "/events/get.php?index=" + roomIndex, function( data ) {
if (data.result = "OK"){
var event_now = -1;
var event_next = -1;
var up_count = 0;
var dn = new Date();
$( "#cal_now" ).html("");
$( "#cal_next" ).html("");
for (var i = 0; i < data.events.length; i++) {
var ds = new Date(data.events[i].start);
var de = new Date(data.events[i].end);
if (ds <= dn && de > dn){ //now
$( "#cal_now" ).append( ds.getHours() + ":" + ds.getFullMinutes() + " - " + de.getHours() + ":" + de.getFullMinutes() + ": " + $.trim(data.events[i].organ)+"<br>" );
event_now = i;
}else if (ds >= dn ){ //next{
if (up_count <= 2){
up_count++;
$( "#cal_next" ).append( ds.getHours() + ":" + ds.getFullMinutes() + " - " + de.getHours() + ":" + de.getFullMinutes() + ": " + $.trim(data.events[i].organ)+"<br>" );
if (event_next == -1){
event_next = i;
}
}
}
}
if (event_now == -1){
if (event_next >= 0){
var ds = new Date(data.events[event_next].start);
$( "#cal_now" ).append( dn.getHours() + ":" + dn.getFullMinutes() + " - " + ds.getHours() + ":" + ds.getFullMinutes() + ": Free<br>" );
}else{
$( "#cal_now" ).append( dn.getHours() + ":" + dn.getFullMinutes() + " - " + "00:00: Free<br>" );
}
}
if (event_next == -1){
$( "#cal_next" ).append( "No more meetings today" );
}
}else{
alert("error");
}
}, "json");
}
function updateWifi(){
$.get( endpointBase + "/wifi/get.php", function( data ) {
if (data.result == "OK"){
$('#qrcode').empty();
$("#key").text(urldecode(data.key));
$('#qrcode').qrcode({width: 160,height: 160,text: "WIFI:S:Guest_WiFi;T:WPA;P:"+data.key+";;"});
}else{
$("#key").text("---");
}
}, "json");
}
function urldecode(str) {
return decodeURIComponent((str+'').replace(/\+/g, '%20'));
}
Date.prototype.getFullMinutes = function () {
if (this.getMinutes() < 10) {
return '0' + this.getMinutes();
}
return this.getMinutes();
};
</script>
</body>
</html>
The frontend looked like this:
In order to run the frontend automatically on the Pi I started by enabling auto login for the default “pi” account using raspi-config
.
For the least overhead I installed the Matchbox window-manager. Matchbox is a very basic window manager that is ideally suited to this kind of application.
Next I created an xinitrc file which would control the execution flow of xserver:
#!/bin/sh
#clear config for each session
rm -rf /home/pi/.config
# Disable DPMS / Screen blanking
xset -dpms
xset s off
# Reset the framebuffer's colour-depth
fbset -depth $( cat /sys/module/*fb*/parameters/fbdepth );
# Start the window manager and disable the mouse cursor
matchbox-window-manager -use_titlebar no -use_cursor no &
#filter out various system keys to prevent breaking out of kiosk
xmodmap /opt/restricted.map
#start node webkit
/opt/node-webkit/nw /opt/console.nw 2>>/home/pi/logs/node-webkit.log
The most important part of the .xinitrc is that it launches an instance of node-webkit. This is a webkit engine designed for running applications, without any of the other frills of a browser.
To configure the node-webkit instance a “.nw” file is passed as an argument. An “.nw” file is just a renamed .”zip” file and it should contain a package.json file. My package.json file was configured as follows:
{
"name" : "portal",
"window" : {
"fullscreen" : true,
"toolbar" : false
},
"chromium-args": "--ignore-gpu-blacklist --enable-webgl --disable-transparency --disable-device-orientation --disable-accelerated-video --disable-3d-apis --disable-plugins --disable-plugins-discovery --disable-setuid-sandbox",
"main" : "http://meetings.corp.domain.com/screen/1",
"webkit":
{
"plugin": false,
"java": false,
"page-cache" : false
},
"platformOverrides": {
"linux": {
"window": {
"toolbar": false
},
"chromium-args": "--ignore-gpu-blacklist --enable-webgl --disable-transparency --disable-device-orientation --disable-accelerated-video --disable-3d-apis --disable-plugins --disable-plugins-discovery --disable-setuid-sandbox"
}
},
"nodejs" : false
}
The node-webkit instance will run full screen and will load the page at http://meetings.corp.domain.com/screen/x. With x representing a number assigned to each meeting room.