Digital signage solution for a school

This time around, I’ll be documenting a simple and cheap project I did for my workplace – a digital signage solution for the school I work at. Our plan was to get a larger LED TV, mount it in the main hall and use it to display information and reminders about upcoming events, activities and so on. Parents often have to wait for their children in this same hall, so they would also have something to check out as they wait. 

Trying to find the best way to do this, I’ve immediately discounted all offline solutions which would involve running photos or video off a USB stick on that TV because I needed a way to allow a limited staff to edit and update the information. Since the TV at my disposal had an HDMI port, a Raspberry Pi seemed like the perfect choice. However, after reviewing a large number of commercial, freemium and free digital signage solutions, none of them turned out to be a clear winner – they were either too complicated to edit or focused on video, a complete overkill for displaying a few lines of text or a couple of photos.

I figured the best way would be to present a fullscreen website in a browser. Again, I tried out a few different ideas, including a digital signage WordPress theme, but it lacked some editing options. Instead, I decided to show an HTML version of an online presentation tool like Microsoft’s PowerPoint online or Google Docs Slides. Office Online gave me loads of trouble providing public access to the plain HTML version, so Google Docs Slides was the only choice left.

After creating a presentation and using the Publish to the Web option, I received an embed code to display fullscreen using the Chromium browser on the Raspberry Pi. However, the actual fullscreen view looked like this:

As you can see, the display is not actually fullscreen because Google adds a toolbar at the bottom and there is no publishing option that would allow you to remove it. However, there is an undocumented (or semi-documented) flag, “rm=minimal” which removes the toolbar, so my final embed URL looked like this:

https://docs.google.com/presentation/d/[PRESENTATION ID]/embed?start=true&loop=true&delayms=15000&rm=minimal

Since it was clear I would use HTML/CSS to display an iFrame with the presentation, I decided to overlay a clock and some minimal weather info (current temperature) using JavaScript.

HTML/JavaScript is freely accessible on the production site I load on the RPi, available at https://kiosk.oskatancic.hr/, but just to be on the safe side, the source is included in this post. (Sorry, inline comments are partly in Croatian).

<!DOCTYPE html>
<html>
  <head>
    <link href="https://fonts.googleapis.com/css?family=Droid+Sans+Mono|Days+One|Source+Sans+Pro" rel="stylesheet" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
    <meta charset="utf-8" />
    <title>Kiosk</title>
    <style>
html, body {
	background: white;
	padding: 0;
	margin: 0;
	width: 100%;
	height: 100%;
}

.container {
	width: 100%;
	height: 100%;
	overflow: hidden;
    z-index: 1;
}
.container iframe {
	height: 100%;
	width: 100%;
}

.boks {
    font-family: 'Droid Sans Mono', monospace;
	color: red;
	position: absolute;
	right: 2%;
	top: 5%;
    z-index: 2;
	width: 24%;
	height: 9%;
    font-weight: bold;
	display: flex;
    align-items: center;
    justify-content: center;
    font-size: 5vw;
	background: linear-gradient(#a6e3f8fa, #b9e9fafa);  
}

.kalendar {
    font-family: 'Helvetica', sans-serif;
    font-weight: bold;
	color: #1155cc;
	position: absolute;
	bottom: 1%;
	left: 6%;
	z-index: 2;
	width: 90%;
	height: 3.4vw;
	font-size: 2.9vw;
	overflow: hidden;
	background: #e5e5e5;
	overflow: hidden;
    margin: 0;
    padding: 0;
    list-style: none;
}
.kalendar li {
	height: 3.4vw;
	padding: 0px;
	margin: 0px 5px;
}

#boks2 {
	top: calc(5% + 9%);
	background: linear-gradient(#b9e9fa90, #c7edfa90);  
    font-family: 'Days One', sans-serif;
    font-weight: normal;
}

.clock {
}
ul {
	list-style: none;
	padding-left: 0px;
}

ul li {
	display: inline;
}

#point {
	position: relative;
    padding-left: 0px;
    padding-right: 0px;
}
#weather-temperature {
	z-index: 2;
}

/* Animations */
@-webkit-keyframes blink {
	0% { opacity: 1.0;}
	50% { opacity: 0;}
	100% { opacity: 1.0;};      
}

@-moz-keyframes blink {
	0% {opacity: 1.0;}        
	50% {opacity: 0;}
	100% {opacity: 1.0;};
}
</style>
  </head>
  <body>
  <div class="container">
    <iframe id="presentFrame"
    src="https://docs.google.com/presentation/d/[ID_PREZENTACIJE]/embed?start=true&amp;loop=true&amp;delayms=15000&amp;rm=minimal"
    frameborder="0" allowfullscreen="true" mozallowfullscreen="true" webkitallowfullscreen="true"></iframe>
  </div>
  <div class="boks">
    <div class="clock">
      <ul>
        <li id="hours"></li>
        <li id="point">:</li>
        <li id="min"></li>
        <li id="point">:</li>
        <li id="sec"></li>
      </ul>
    </div>
    <br />
  </div>
  <div class="boks" id="boks2">
    <div id="weather-temperature"></div>
  </div>
  <div id="kalendar-data" class="kalendar-boks"></div>
  <script src="js/jquery-1.12.4.min.js"></script> 
  <script type="text/javascript">
// Javascript za iscrtavanje sata
$(document).ready(function() {
        setInterval( function() {
                $(".clock").css("visibility", "visible");
                // Create a new Date() object and extract the seconds, minutes and hours...
                var time = new Date();
                var seconds = time.getSeconds();
                var minutes = time.getMinutes();
                var hours = time.getHours();
                $("#sec").html(( seconds < 10 ? "0" : "" ) + seconds);
                $("#min").html(( minutes < 10 ? "0" : "" ) + minutes);
                $("#hours").html(( hours < 10 ? "0" : "" ) + hours);
        },1000);
        
});
</script> 
  <script type="text/javascript">
// Osvježavanje temperature
$(function worker(){
        $.ajaxSetup ({
cache: false,
complete: function() {
	setTimeout(worker, 10*60*1000);
                }
        });
        // load() functions
        var loadUrl = "temp";
        $("#weather-temperature").html("...");
        $("#weather-temperature").load(loadUrl);
        // end  
});
</script> 
  <script type="text/javascript">
// osvježavanje prezentacije
var refreshMinutes = 30;
var timer=setInterval(function(){refreshFrame()}, refreshMinutes*60*1000);
function refreshFrame()
{
        // var iframe = document.getElementById('presentFrame');
        // var iframeURL = iframe.src;
        // iframe.src = iframeURL;
        window.location.reload(true);
}
</script> 
  <script type="text/javascript">
// Ispis kalendara
$(function worker(){
        $.ajaxSetup ({
cache: false,
complete: function() {
	setTimeout(worker, 10*60*1000);
	}
	});
	// load() functions
	var loadUrl = "kalendar.cache";
	$("#kalendar-data").html("...");
	$("#kalendar-data").load(loadUrl);
	// end  
});
</script> 
<script type="text/javascript">
  // ticker kalendara
function tick(){
    $('#kalendar li:first').fadeToggle( function () { $(this).appendTo($('#kalendar')).fadeToggle(); });
}
setInterval(function(){ tick () }, 6000);
</script></body>
</html>

Temperature data is polled from a PHP script which gets it from a local database; if you don’t have one, you can use OpenWeatherMap.

Google Calendar events are polled from a different PHP script which uses the Google Docs API to import events and formats it as a unnumbered list.

<?php
setlocale(LC_ALL, 'hr_HR.UTF-8');
$apikey = 'API_KEY'; // YOUR API key here!
$sada = date(DATE_ATOM);
$limit = new DateTime();
$limit = $limit->add(new DateInterval("P1M"))->format(DATE_ATOM);
$sada = urlencode($sada);
$limit = urlencode($limit);
$brojrezultata = 100;
$adresa = "https://www.googleapis.com/calendar/v3/calendars/oskatancic.hr_[ID_KALENDARA]%40group.calendar.google.com/events?maxResults=$brojrezultata&orderBy=startTime&singleEvents=true&timeMin=$sada&timeMax=$limit&key=$apikey";
$response = file_get_contents($adresa);
$eventi = json_decode($response, true)['items'];
	
echo "<ul id=\"kalendar\" class=\"kalendar\">";
if (!empty($eventi))
{
foreach ($eventi as $dogadjaj)
{
	if (array_key_exists('dateTime', $dogadjaj['start']))
	{
	$dan =  strftime("%A, %e.%-m", strtotime($dogadjaj['start']['dateTime']));
	$pocetak =  date("G:i", strtotime($dogadjaj['start']['dateTime']));
	$recenica_vrijeme = "$dan u $pocetak sati"; 
	}
	else if (array_key_exists('date', $dogadjaj['start']))
	{
	$dan =  strftime("%A, %e.%-m", strtotime($dogadjaj['start']['date']));
	$recenica_vrijeme = "$dan"; 
	}
	$opis = $dogadjaj['summary'];
	echo "<li style=\"display: list-item\">$recenica_vrijeme: $opis</li>";
}
}
else
{
	echo "Nema najavljenih događaja";
}
echo "</ul>"; 
?>

As for the Raspberry Pi itself, I used the latest version of Raspbian (desktop) and made a few changes.

  1. Using raspi-conf  I enabled autologin for “pi” user
  2. Configured the wireless network via Desktop
  3. Installed unclutter to hide the mouse 
    sudo apt install unclutter 
  4. Edited the autostart file for the “pi” user, located at  ~/.config/lxsession/LXDE-pi/autostart
    @lxpanel --profile LXDE-pi
    @pcmanfm --desktop --profile LXDE-pi
    @xscreensaver -no-splash
    @point-rpi
    @unclutter -idle 0.1
    @xset s off
    @xset -dpms
    @xset s noblank
    @chromium-browser --app=https://kiosk.oskatancic.hr/ --temp-profile --no-touch-pinch --kiosk
    

Another good idea is to enable a Chromium flag which would automatically reload the page once the browser goes online (in case of network connectivity losses). This can be done through chromium://flags.

Of course, this whole project is in a perpetual work-in-progress state, so don’t expect all this to work for you out of the box.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.