Discord Connection Status Monitor using an Arduino and MAX7219

Never worry about leaving your mic open again!

TL;DR: Source code here.

In my last post, I talked about how one could rewrite an Arduino sketch in Python using PyFirmata, and how we could leverage that to create an easy interface to a MAX7219 LED Matrix. Here, I’m going to show you one of the applications of this interface (and what initially inspired me to get the matrix working in the first place).

I love Discord. It’s like a mix between Slack and Ventrillo, and has been fundamental in allowing me to keep in touch with friends on the other side of the country. Writing extensions for an existing Discord Bot framework (Red-Bot) was my first real foray into writing open-source software, and a very positive one at that.

Since I am on Discord very often, I will sometimes forget that I’m still voice-connected and leave the computer. This usually results in a torrent of messages from my friends complaining about my dogs barking into my microphone while I’m away (they’ve gotta hold down the fort somehow). However, it’s become enough of an inconvenience that I decided to come up with a simple solution. While I have 2 monitors, like any self-respecting developer1, the actual Discord window still somehow finds itself at buried below a couple of browser windows within no-time. So, at a glance, I cannot tell if I’m A) in the server and B) muted/deafened. This can be additionally challenging while playing a game that can’t be paused or alt-tabbed to check on Discord. My first attempt at tackling the problem was to try and write my own custom Discord overlay. Discord has an official overlay, but it only works inside running games, and only certain ones at that. Plus, it’s really bloated, displaying everyone in your voice server and taking up significant screen real-estate. My idea was to have 2 small squares which would indicate if you were connected (left) and unmuted (right). The major benefits being that it takes up much less screen-space, and that it wasn’t tied to any game. For example, in the screenshot below, the squares are on top of a PyCharm window.

The major problem I encountered was there’s really no way to guarantee a GUI window (Tkinter in this case) stays on top of all other windows. A lot of games work really hard to be the top window, and even if we’re constantly calling some Tk function to put our window on top, the game will win in the end. Also, several games suffered large framerate drops due to the overlay, with the working hypothesis being the aforementioned struggle for the top window. It was a good exercise, but ultimately not the solution I was hoping for.

Ideally, I thought, I could get some kind of “3rd Monitor”, one that could just tell me info about my Discord connection status. After all, I did solve the problem of getting real-time connection status from the Discord API (which we’ll discuss later in the code). I had been playing around with the MAX LED matrix for a voice spectrum experiment, and thought it would be a good candidate for this hypothetical “stripped-down” 3rd monitor. All I had to do was put the pieces together.

Materials

  • PC with Python 3 and the following packages:
    • discord
    • pyfirmata
  • Arduino Uno
  • MAX7219 Dot Matrix Module ($1.31 USD on AliExpress)

The Code

Final File Structure
  • DiscordMatrix/
    • config.ini
    • default.ini
    • discord_matrix.py
    • led_matrix.py

In my last article, I wrote an explanation of the LED-Matrix “driver” written in Python with PyFirmata. Take that code and put it in a file called led_matrix.py. Save it for later.

Now, on to the important part of the program. How do we actually figure out our Discord connection status? Luckily, there’s a Discord module for Python, aptly named discord. Go ahead and pip install discord, as well as pyfirmata if you haven’t already.

import atexit
import configparser
import os
import sched
import sys
import threading
import time
from shutil import copyfile

import discord
from pyfirmata import Arduino

from led_matrix import LedMatrix

REFRESH_RATE = 0.5  # seconds

ZEROS = [[0 for _ in range(8)] for _ in range(8)]

DISCONNECTED = [[1, 0, 0, 0, 0, 0, 0, 1],
                [0, 1, 0, 0, 0, 0, 1, 0],
                [0, 0, 1, 0, 0, 1, 0, 0],
                [0, 0, 0, 1, 1, 0, 0, 0],
                [0, 0, 0, 1, 1, 0, 0, 0],
                [0, 0, 1, 0, 0, 1, 0, 0],
                [0, 1, 0, 0, 0, 0, 1, 0],
                [1, 0, 0, 0, 0, 0, 0, 1]]

CONNECTED = [[0, 0, 0, 0, 0, 0, 0, 0],
             [0, 0, 0, 0, 0, 0, 0, 0],
             [0, 0, 0, 0, 0, 0, 0, 1],
             [0, 0, 0, 0, 0, 0, 1, 0],
             [0, 0, 0, 0, 0, 1, 0, 0],
             [1, 0, 0, 0, 1, 0, 0, 0],
             [0, 1, 0, 1, 0, 0, 0, 0],
             [0, 0, 1, 0, 0, 0, 0, 0]]

MUTED = DEAFENED = [[0, 0, 0, 0, 0, 0, 0, 0],
                    [0, 0, 0, 0, 0, 0, 0, 0],
                    [0, 0, 1, 0, 0, 1, 0, 0],
                    [0, 0, 0, 1, 1, 0, 0, 0],
                    [0, 0, 0, 1, 1, 0, 0, 0],
                    [0, 0, 1, 0, 0, 1, 0, 0],
                    [0, 0, 0, 0, 0, 0, 0, 0],
                    [0, 0, 0, 0, 0, 0, 0, 0]]

We start by doing all our imports, then by defining a couple constants. REFRESH_RATE is how often the program will check Discord for voice-status updates. ZEROS is an 8x8 matrix of 0s, which is useful for clearing the display.

We’re going to quickly extend the base LedMatrix class so it has internal memory of its currently drawn matrix. This will aid us in reducing how often we have to communicate with the Arduino, which is a relatively lengthy operation.

class ExtendedMatrix(LedMatrix):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._matrix = ZEROS

    def __eq__(self, other):
        if isinstance(other, ExtendedMatrix):
            return self._matrix == other._matrix
        else:
            return self._matrix == other

    def clear(self):
        super().clear()
        self._matrix = ZEROS

    def draw_matrix(self, point_matrix):
        super().draw_matrix(point_matrix)
        self._matrix = point_matrix

We override 2 magic functions, and 2 LedMatrix functions. __init__, __clear__, and draw_matrix all get overridden simply to update the private _matrix variable when the actual hardware state is changed. __eq__ defines comparison for 2 matrices, by directly comparing their LEDs. It also allows comparison with plain 2D arrays. This will come in use when we decide whether to update the display or not.

With that out of the way, we can finally get to interfacing with Discord. We’ll do this through a class called DiscordListener, which will periodically check on our connection.

class DiscordListener:
    def __init__(self):
        # Initialize client
        self.client = discord.Client()

We start our initialization by creating a discord.Client object, which will represent our connection to Discord. However, it’s not logged in yet, and won’t be until the end of initialization. I chose to acquire that information through a config file, which we will now read from.

        # Try and find username and password from config file
        self.config = configparser.ConfigParser()
        if not os.path.isfile("config.ini"):
            copyfile("default.ini", "config.ini")
        self.config.read("config.ini")
        self.username = self.config['LoginInfo']['Username']
        self.password = self.config['LoginInfo']['Password']

It might be a little safer to pickle the password instead of adding it to the config file, but I like to live dangerously. Just know that your token should go in config.ini and not default.ini. And don’t ever share a config.ini with anyone if it has your token in it. And especially don’t commit your config.ini to a public repository2.

The other benefit of using a custom config.ini is that you can save custom Arduino pin-outs for the MAX, as we’ll see below. This code should look a lot like the __main__ function from the original LedMatrix article. Also, remember we’re using our ExtendedMatrix class, instead of the “vanilla” LedMatrix.

        # Initialze Arduino and LED-matrix
        board = Arduino('COM3')
        # grab pinout from config file
        dataIn = int(self.config['Pins']['dataIn'])
        load = int(self.config['Pins']['load'])
        clock = int(self.config['Pins']['clock'])
        self.matrix = ExtendedMatrix(board, dataIn, load, clock)
        self.matrix.setup()

We’re going to use sched in combination with threading to repeatedly check for voice status updates in the background. We’ll also save a reference to the thread if we need it later. sched will be calling a function called self.update_status() which will be what actually checks our current connection status, and adjusts the matrix accordingly.

        # Will hold a reference to each running thread
        self.threads = {}

        # Schedule update callback
        self.sched = sched.scheduler(time.time, time.sleep)
        self.sched.enter(REFRESH_RATE, 1, self.update_status)
        t_sched = threading.Thread(target=self.sched.run, daemon=True)
        t_sched.start()
        self.threads['t_sched'] = t_sched

And finally, we’ll run a function called attempt_login. This will connect our Client to the Discord servers using the provided username and password. This also happens in a background thread (t_client).

        self.attempt_login()
        
    def attempt_login(self):
        print("Attempting to log in as {}...".format(self.username))
        # Kill the existing client thread if it already exists
        if self.threads.get("t_client"):
            self.client.logout()
        t_client = threading.Thread(target=self.client.run, args=(self.username, self.password), daemon=True)
        t_client.start()
        self.threads['t_client'] = t_client
        print("Done")

And finally, here is update_status, the core of the DiscordListener. The reason we had to provide our own login info is so the listener can know which servers to check. As far as I’ve been able to figure, this is the only way to check one’s voice connection status. If we’re able to find one instance of “non-disconnected behavior” (i.e. CONNECTED or MUTED), then we immediately break and update the matrix to the new icon matrix.

    def update_status(self):
        state = DISCONNECTED
        if self.client.is_logged_in:
            for server in self.client.servers:
                mem = server.get_member(self.client.user.id)
                vs = mem.voice
                if vs.voice_channel is not None:
                    if vs.mute or vs.self_mute:
                        state = MUTED
                        break
                    else:
                        state = CONNECTED
                        break
        if self.matrix != state:
            self.matrix.draw_matrix(state)
        self.sched.enter(REFRESH_RATE, 1, self.update_status)

Note that we check if self.matrix != state before writing the new icon. This prevents us from doing unnecessary serial-transactions that can tie up the CPU and I/O bandwidth.

Also, here’s the exit function, which simply displays a goodbye message, clears the display (really helpful for knowing when the program is running or not) and closes all the daemon threads with sys.exit().

    def exit(self):
        print("Exiting...")
        self.matrix.clear()
        sys.exit()

And finally, our main function:

def main():
    dl = DiscordListener()
    atexit.register(dl.exit)


if __name__ == "__main__":
    main()

If all goes well, you should be seeing the appropriate connection icon on your LED Matrix when you run the code. The full source code along with the default icon legend can be found here. This version also includes a system tray icon, which is pretty neat but makes it restricted to Windows. I believe with the code found only in this tutorial, it should be platform independent.

This display has been hugely helpful for me. I’m always able to instantly tell if I’m in a server and people are able to hear me, even if I’m not sitting at my desk (that display is pretty bright!). And there are still plenty of improvements to be made, like a separate icon for being “deafened” than “muted”. Let me know if you find any bugs or are unable to get the code to work.


1Read: Nerd

2This may or may not be autobiographical

Written on July 7, 2018