Using Laravel and an e-ink tablet to make a DIY digital sign for dynamic office window message display

I wanted a sign I could mount in the window at the front entrance of our newspaper’s office to display customizable information about our office’s current status and other useful info.

If you’ve ever researched this category of products, you know there are many vendors out there who will happily sell you very expensive devices and supporting accessories to make this happen. This is “call us for pricing” level product sales meant for hotels, convention centers, malls and large office buildings, not for a small community newspaper on a budget.

Even the few standalone products I was able to find for sale on Amazon and elsewhere seemed pretty janky. I’d have to download an app or install a hub with a proprietary communication technology or be constrained to a certain number of characters and layout, or some other limitation I wasn’t quite happy with.

I decided to ask the Fediverse:

A Mastodon post that reads: "A hardware product that seems like it should exist but that I'm having a hard time finding:
* E-ink sign to go in a store window and display basic text (open/closed, etc)
* Wifi connected, no hub or cloud subscription needed
* Programmable via some kind of API or app, refresh delay is fine
* Battery or plug-in is fine

Suggestions? (Edited to add: Unfortunately I don't have time to assemble one from parts.)"

(I learned while writing this that Mastodon posts do not seem to embed in WordPress seamlessly – tragic!)

At first, the responses were reinforcing how difficult this might be to find, but then my colleague Scott Evans made a comment that sparked the eventual solution:

I feel like an e-ink reader of some kind might be your best bet (one that can run Android apps). Then you could install a kiosk style browser and update the page it’s pointing at. Something from Onyx?
https://toot.scott.ee/@scott/statuses/01JKBE3QZWEZCMN2QZZH75R6BY

After some searching I found this wonderful blog post by Jan Miksovsky, MomBoard: E-ink display for a parent with amnesia. Jan described using a BOOX Note Air2 Series e-ink device to create a dynamic sign display for Jan’s mom. They used a web browser to load a simple website that would display the latest customized messages based on details configured in web interface elsewhere, source code here. I love it!

I emailed Jan with some questions about the setup and Jan was kind enough to write back with a clarification that the original BOOX ability to launch a web browser on device restart appears to have been removed, but a request is in to restore this functionality.

Jan also had a helpful thought about how to launch the browser in to full-screen mode, which I haven’t tried yet: “That’s controlled by whether a website has a web app manifest defined for it. If it does, then the browser “Add to Home Screen” command should allow you to add the site to your home screen. When launched, it should open in full screen mode.

I found a used Note Air2 device on eBay for $189 plus shipping and tax. A non-trivial expense, but far less expensive than any of the other options I was finding.

When it arrived I turned off all of the “auto-sleep” type settings so that the device would basically stay on all the time. I tested out having the web browser stay open on one page for days on end, and it worked! Now I just needed to figure out my own web interface to manage the sign message.

Since I already have a Laravel-powered application that manages many functions of our newspaper business, I thought it would make sense to build this functionality in to that. I created two new models, one for storing the current sign message on a given sign (yes, I’m over-engineering this a bit) and one to store templates that can be loaded to populate a sign message, either manually or on a schedule.

class SignMessage extends Model
{
    protected $fillable = [
        'location',
        'message',
        'status',
        'last_loaded_at',
        'is_locked',
    ];

    protected $casts = [
        'is_locked' => 'boolean',
        'last_loaded_at' => 'datetime',
    ];
}

class SignTemplate extends Model
{
    protected $fillable = [
        'location',
        'name',
        'message',
        'is_default',
        'sets_status',
    ];

    protected $casts = [
        'is_default' => 'boolean',
    ];
}

Then, I made use of the always-amazing Filament admin panel building package to whip up an administrative interface to manage the data. Here’s managing and editing sign templates:

Here’s managing the actual sign message:

Since my sign template management suggests defining a sign message that should be the default for certain statuses (open and closed), I needed a way to activate those on a schedule. Here’s the Laravel command that handles that and sends a message to a Slack channel with the result:

<?php

namespace App\Console\Commands;

use App\Models\SignMessage;
use App\Models\SignTemplate;
use Illuminate\Console\Command;
use Spatie\SlackAlerts\Facades\SlackAlert;

class SetSignMessageCommand extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'wwn:set-sign-message
        { --location=ccfront : Location of the sign }
        { --status= : Status to set for the sign }';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Set the status and message of a sign at a certain location.';

    /**
     * Execute the console command.
     */
    public function handle()
    {
        // If current message can be auto-updated / not locked
        $currentMessage = SignMessage::where('location', $this->option('location'))->first();
        if ($currentMessage && $currentMessage->is_locked) {
            return Command::SUCCESS;
        }

        // Get first message that is default status
        $newMessage = SignTemplate::where('location', $this->option('location'))
            ->where('is_default', true)
            ->where('sets_status', $this->option('status'))
            ->first();

        // Set message and status
        if ($newMessage) {
            if ($currentMessage) {
                $currentMessage->update([
                    'message' => $newMessage->message,
                    'status' => $this->option('status'),
                ]);
            } else {
                SignMessage::create([
                    'location' => $this->option('location'),
                    'status' => $this->option('status'),
                    'message' => $newMessage->message,
                    'is_locked' => false,
                ]);
            }

            if (! empty(config('wwn.signs.slack_channels')[$this->option('location')])) {
                SlackAlert::to('ops')
                    ->toChannel(config('wwn.signs.slack_channels')[$this->option('location')])
                    ->message("Sign at {$this->option('location')} set to {$this->option('status')} with new message.");
            }
        }

        return Command::SUCCESS;
    }
}

Then, I can run that command on a schedule according to our office hours:

        /**
         * Sign changes
         */
        // T-F, open at 9 am
        $schedule->command('wwn:set-sign-message --status=open')
            ->timezone(config('wwn.timezone'))
            ->days([Schedule::TUESDAY, Schedule::WEDNESDAY, Schedule::THURSDAY, Schedule::FRIDAY])
            ->at('09:00');
        // M, T, W, F close at 5 pm
        $schedule->command('wwn:set-sign-message --status=closed')
            ->timezone(config('wwn.timezone'))
            ->days([Schedule::MONDAY, Schedule::TUESDAY, Schedule::WEDNESDAY, Schedule::FRIDAY])
            ->at('17:00');
        // Th close at 12 noon
        $schedule->command('wwn:set-sign-message --status=closed')
            ->timezone(config('wwn.timezone'))
            ->days([Schedule::THURSDAY])
            ->at('12:00');

Finally, I needed to render the sign message at a URL where the BOOX device could load it, and in a way that would refresh automatically when the message changed.

I used a simple Laravel blade file with a Livewire component that would poll for updates on a regular basis. Here’s the blade file, with messy CSS courtesy of ChatGPT:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta http-equiv="refresh" content="86400">
    <title>Sign for {{ $signMessage->location }}</title>
    <style>
        html, body {
            width: 100%;
            height: 100%;
            margin: 0;
            padding: 0;
            font-family: Arial, Helvetica, sans-serif;
            display: flex;
            flex-direction: column;
            justify-content: flex-start; /* Reduce excess top margin */
            align-items: center;
        }

        h1 {
            text-transform: uppercase;
            font-size: 20vh; /* Fill top 1/4th of screen */
            white-space: nowrap;
            overflow: hidden;
            text-overflow: clip;
            width: 100%;
            line-height: 1;
            margin: 2vh 0;
            text-align: center;
            flex: 0 0 auto;
        }

        #message {
            flex: 1; /* Fill remaining space */
            font-size: 6vh;
            max-width: 90%;
            max-height: 75vh;
            overflow: hidden;
            display: flex;
            flex-direction: column;
            justify-content: flex-start; /* Reduce excess space at top */
            align-items: flex-start; /* Align message content to the left */
            text-align: left;
            word-wrap: break-word;
            padding: 0 5%;
            margin-top: 3vh; /* Add spacing between status and message */
        }

        #message p {
            margin: 1.5vh 0; /* Slightly more spacing between paragraphs */
            max-width: 100%;
            word-wrap: break-word;
        }

    </style>
</head>
<body>
    @livewire('sign-display', ['location' => $signMessage->location])
</body>
</html>

Here’s the Livewire component:

<div id="sign-display" wire:poll.{{ config('wwn.signs.refresh_rate.default_ms') }}ms="updateSignData">
    @if($signMessage->status)
        <h1 id="status">{{ $signMessage->status_label }}</h1>
    @endif
    <div id="message">
        {!! $signMessage->message !!}
    </div>
</div>

You’ll notice that there are actually two refresh mechanisms in use, one in the HTML header to refresh the entire page once per day, and one in the Livewire component to refresh its content at a configurable rate, currently every 5 minutes. This ended up being necessary because I discovered that even for anonymous/logged-out web requests, Laravel still sets a CSRF token that expires after a few days, and so the Livewire component would eventually stop working. The whole-page refresh solves this.

I did try for a while to have the Livewire component dynamically set its polling interval, as there are some hours of the day where the message is almost certain not to change and a longer wait time would be fine, and others where a faster refresh would be ideal, but this was more trouble than it was worth given the technical complexity it added. So for now, our office staff is content to know that from the time you change the message in the web interface to the time it refreshes on the device may be as much as five minutes. If you need to change it more urgently than that…use a paper sign? 😅

Here’s the Livewire component code:

<?php

namespace App\Livewire;

use App\Models\SignMessage;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Livewire\Component;

class SignDisplay extends Component
{
    public $signMessage;
    public $location;

    public function mount(string $location)
    {
        $this->location = $location;
        $this->loadSignMessage();
    }

    public function updateSignData(): void
    {
        $this->loadSignMessage();
    }

    public function loadSignMessage(): void
    {
        $this->signMessage = SignMessage::where('location', $this->location)->first();

        if (! $this->signMessage) {
            abort(404);
        }

        $this->signMessage->touch('last_loaded_at');
    }

    public function render()
    {
        return view('livewire.sign-display');
    }
}

Finally, I needed a Laravel HTTP controller to serve up the sign message:

<?php

namespace App\Http\Controllers;

use App\Models\SignMessage;
use Illuminate\Http\Request;

class SignMessageController extends Controller
{
    /**
     * Handle the incoming request.
     */
    public function __invoke(Request $request, string $location)
    {
        $signMessage = SignMessage::where('location', $location)->first();

        abort_if(! $signMessage, 404);

        $signMessage->touch('last_loaded_at');

        return view('signs.sign-message', compact('signMessage'));
    }
}

and a web route to point to it:

    // Office signs
    Route::get('/signs/{location}', \App\Http\Controllers\SignMessageController::class);

The web route is a part of a middleware group that ensures requests can only come from known IP addresses of our office locations, as one form of authentication.

I use the “last_loaded_at” model attribute as a quick way to confirm that the sign is successfully loading the latest message, even if I can’t put my eyes on it. In theory I will set up monitoring and alerting for when this value becomes scale; in reality I will not.

I set the device’s browser’s home tab to the desired URL, launched the browser, and it worked!

I went looking for a way to securely mount the BOOX device to the window and it was actually kind of hard to find something suitable since most people apparently don’t mount their e-readers to windows. In the end I found a $30 mounting kit designed to mount a satellite communications device to your vehicle’s sunroof for mobile internet access (affiliate link). Again, way over-engineered but it’s going to hold that sucker in place through a lot.

The end result? It’s beautiful!

I’ve used it a few times now to post special messages welcoming certain office visitors. If I have time I may add in an announcement about when the latest issue of the paper has arrived for sale, breaking news headlines, etc. What dreams may come.

I’m glad for any feedback or suggestions, especially if you try your own version of this. Thanks again to Jan Miksovsky and Scott Evans for the breakthrough ideas, and to others on Mastodon who helped with ideas and details.

Published by

Chris Hardie

Journalist, publisher, software developer, entrepreneur

Leave a Reply

Your email address will not be published. Required fields are marked *