La construction de notre panneau LED

Afin d'égayer un petit peu les bureaux de l'Agence, nous avons décidé d'y installer un panneau d'affichage LED. La question qui se posait donc était de savoir ce qu'on allait afficher dessus… Serait-ce l'état de nos serveurs ? Des statistiques de visite ? Le logo de l'Agence Webup pour assouvir des besoins d'égo ?

Malgré toutes les possibilités s'offrant à nous, nous n'avions pas vraiment d'idée qui nous semblait intéressante – jusqu'au moment où il était question de faire une refonte intégrale de notre site : on avait notre idée !

Pourquoi pas concevoir et développer une solution pour permettre aux visiteurs de notre site d'afficher un dessin de leur choix sur notre écran ?

Vous pouvez aller l'essayer sur notre page d'accueil si vous ne l'avez pas déjà fait

Vous vous posez sûrement une question très pertinente – comment est-ce qu'on s'y est pris ?

En bref

Notre solution comporte 4 composants majeurs, à savoir :

  • Notre site, sur lequel vous pouvez faire des dessins à nous envoyer
  • Un composant “API” qui traite les dessins envoyés pour les ajouter à la file d'attente
  • Une “file d'attente” des dessins
  • Le panneau LED, contrôlé par un Raspberry Pi

Tout cela peut sembler compliqué mais c'est assez simple à comprendre, je vais vous expliquer !

Lorsque vous dessinez sur notre site, un petit composant de code calcule votre dessin et le transmet à notre composant “API” lorsque vous cliquez sur le bouton “Envoyer”.
Ensuite, notre API reçoit ces données et fait quelques vérifications pour s'assurer que le dessin peut être affiché. Normalement si vous l'avez dessiné depuis notre site, ça devrait être OK. Après cela, l'API ajoute votre dessin à la file d'attente… Patience ! (En réalité, votre dessin nous arrive très rapidement, ne vous inquiétez pas)
Une fois que votre dessin est en première position dans la file d'attente, il est récupéré par le Raspberry Pi et il est affiché pendant 30 glorieuses secondes dans nos bureaux !

[+ illustrations ?]

C'est aussi simple que ça !

Si vous souhaitez en savoir plus, mais pas seulement un peu plus, on parle de beaucoup plus, nous vous expliquons ça ici.

Je veux tout savoir !

Réalisation

1) Montage de l'adaptateur sur le Raspberry Pi

L'adaptateur utilisé pour connecter le panneau LED est fourni avec un élément permettant de ne pas avoir à souder directement sur le Raspberry Pi. Ce dernier se branche ensuite facilement sur les 40 pins GPIO, et peut être retiré si jamais l'on souhaite le démonter.

2) Conception de l'interface utilisateur

L'interface utilisateur qui permet une saisie d'un dessin ou d'un texte se compose d'un élément qui représente le panneau LED.

L'utilisateur dessine sur celui-ci ou saisit du texte à envoyer dans une zone de texte pévue à cet effet, puis appuie sur “envoyer”

L'interface est écrite et HTML/CSS, et est gérée par un composant écrit en JS.

Le code JS est conçu pour être intégré à une page en tant que “plugin”, c'est-à-dire qu'il peut y être ajouté puis configuré pour fonctionner sur n'importe quelle page, du moment que celle-ci y a été préalablement préparée.

Une fois le dessin ou le message saisi, un clic sur le bouton “envoyer” contacte l'API afin de le faire apparaître sur le panneau LED.

Le composant générant l'interface crée des éléments de DOM grâce à des méthodes JavaScript, puis y attache des EventListener afin de détecter les différents évènements d'intéraction tels que le clic sur un bouton, et y réagir en conséquence. Par exemple, lorsqu'un utilisateur change la couleur du pinceau utilisé pour dessiner, un évènement est déclenché à chaque fois que cette valeur change, afin de la représenter visuellement sur l'interface, qui change de couleur en temps réel, avant même que la couleur ait été validée.

La partie de l'interface permettant de représenter le panneau LED sur lequel l'utilisateur réalise son dessin consiste en un élément <canvas>, qui est piloté par le composant JavaScript.

L'élément canvas nous permet d'afficher dynamiquement n'importe quelle image, à condition de savoir la créer. Les API DOM des différents navigateurs mettent à disposition plusieurs méthodes afin de faciliter cela. La plupart des opérations que nous réalisons dans notre cas pour générer l'interface se résument à remplir des surfaces rectangulaires d'une certaine couleur en spécifiant les dimensions souhaitées, et ce de façon répétée afin d'en arrriver au résultat final.

Voici la méthode en JS qui génère l'interface du panneau sur le canvas. Remarquez l'utilisation des méthodes fillStyle pour définir la couleur ainsi que fillStyle et fillRect pour dessiner un rectangle.

drawCanvas () {
    if (this.canvas.getContext) {
      var ctx = this.canvas.getContext('2d')
      ctx.imageSmoothingEnabled = false
      ctx.lineWidth = 1

      // start with x = y = 0
      // these are used as coordinates for drawing each "pixel"
      let x = 0
      let y = 0

      // start with x2 = y2 = 3
      // as x and y above, but for the inner square, which actually displays the color
      let x2 = 3
      let y2 = 3

      // loop through rows and draw them
      for (let i = 0; i < this.matrix.length; i++) {
        // loop again through items in row, draw them
        for (let j = 0; j < this.matrix[i].length; j++) {
          ctx.strokeStyle = '#8E8E8E'
          ctx.strokeRect(x, y, 13, 13)
          ctx.fillStyle = '#5A5A5A'
          ctx.fillRect(x + 1, y + 1, 12, 12)
          ctx.fillStyle = this.matrix[i][j] === '#000' ? '#CACACA' : this.matrix[i][j]
          ctx.fillRect(x2, y2, 8, 8)

          // bump up x coordinates for next iteration
          y += 13
          y2 += 13
        }

        // reinitialize coordinates for next line
        x += 13
        y = 0

        x2 += 13
        y2 = 3
      }
    }
  }

3) Conception de l'API

Le composant API de ce projet était au départ une petite application Node.js utilisant un serveur Express. Par la suite, du fait de nos besoins de déploiement sur la plateforme Serverless que propose ZEIT, et a été réécrit sous la forme de fonction destinée à fonctionner sur les services ZEIT. Les deux versions subsistent aujourd'hui, permettant de déployer l'API chez ZEIT ou sur son propre serveur.

Ce dernier récupère les informations concernant un dessin (ou message) envoyées de façon structurée par l'interface utilisateur.

Ces données sont une représentation en JSON du dessin. Étant donné que l'interface utilisateur est conçue pour ressembler au réel panneau LED, le dessin sera affiché sur ce dernier quasiment à l'identique.

Chaque “point” du dessin correspond à un pixel du panneau LED, auquel correspond une valeur de couleur en RGB (Rouge, Bleu, Vert).

Ce composant reçoit les requêtes puis les valide, avant de les envoyer par la suite dans une file d'attente AMQP. Pour notre implémentation nous avons utilisé RabbitMQ.

La validation des données consiste à vérifier que les valeurs de la couleur de chaque pixel soit cohérente avec ce que peut afficher le panneau LED, à savoir une valeur de 0 à 255 pour le Rouge, le Vert ainsi que le Bleu. Sont également validées les coordonnées de chaque “point”, qui doivent correspondre aux dimensions de notre panneau, c'est-à-dire une position en x comprise entre 0 et 63, et une position en y comprise entre 0 et 31.

Une fois mise en place, cette validation fonctionne comme ceci dans notre code

if (validateDrawing(request.body) || validateText(request.body)) {
    // Si les données sont valides, traiter le dessin ou le texte

    ...

}
else {
    // Si la validation échoue, retourner une erreur 422
    response.statusCode = 422
    response.json({ "error": "Invalid data" })
}

Comme évoqué ci-avant, nous avons utilisé ZEIT pour déployer ce composant sous forme de fonction Serverless. Cette démarche présente non seulement l'avantage de ne pas avoir à se soucier de serveur car des ressources sont allouées automatiquement à notre fonction lorsqu'une requête a lieu, mais permet également de ne pas écrire le même code plusieurs fois pour chaque fonctionnalité.

Dans notre implémentation Express nous avons deux méthodes, une pour rajouter un dessin à la file d'attente, ainsi qu'une autre pour faire de même pour un message texte.

Dans le cas de notre fonction Serverless, nous utilisons une technique proposée par la plateforme ZEIT afin d'utiliser une seule fonciton pour les deux fonctionnalités. La convention de nommage que nous impose ZEIT peut sembler assez déroutante et n'est pas nécessairement très agréable à utiliser mais fonctionne comme annoncée.

Pour passer une variable à notre fonction directement dans le segment d'URL de la requête, nous nommons le fichier contenant celle-ci avec le même nom que le dossier dans lequel il est contenu, en y ajoutant des crochets. Nous avons donc ce chemin : api/send/[send].ts.

Dans notre fonction, nous avons accès à une variable send qui contient le fragment d'URL correspondant.

Par exemple, pour une requête vers api/send/drawing, la variable send aura pour valeur "drawing".

Au sein de la fonction nous pouvons donc récupérer cette variable comme ceci.

const {
    query: { send }
} = request

const type = send

Ensuite nous pouvons vérifier si cette variable correspond soit à “drawing” soit à “message”. Si ce n'est pas le cas nous retournons une erreur 404.

if (type !== "drawing" && type !== "message") {
    response.statusCode = 404
    response.json({})
    return
}

Nous pouvons ensuite utiliser cette variable lors de l'ajout de la tâche à la file d'attente. Ici nous utilisons des opérateurs ternaires afin de tout garder sur une ligne.

let data = ((type === "drawing") ? request.body.drawing : (type === "message") ? request.body.text : null)

4) Conception du “Worker” Python

Une fois les données du dessin ou message envoyées puis traitées, elles sont ajoutées à une file d'attente. Cela a pour objectif de permettre à plusieurs personnes d'envoyer des dessins en même temps sans que tous les dessins ne soient affichés en même temps, ou bien que certains dessins soient perdus.

La file d'attente suit un principe de First In First Out (FIFO), ce qui signifie que les dessins arrivent dans cette file puis sont traités pour être affichés dans l'ordre dans lequel ils ont été envoyés.

Nous avons conçu un worker, écrit en Python, qui “observe” cette file d'attente, et récupère le prochain dessin à afficher.

Lorsqu'un dessin arrive dans la file d'attente, ce dernier est récupéré par le Raspberry Pi. Le worker décode les informations du dessin, représentées en JSON, et configure l'affichage du panneau LED grâce à la librairie de code C en parcourant les données du dessin pixel par pixel. Une fois l'intégralité des pixels parcourue, un signal est envoyé au panneau LED pour lui indiquer d'afficher sa nouvelle configuration de pixels.

Le dessin est affiché sur le panneau LED pendant une durée de 30 secondes. Une fois ce délai écoulé, le dessin est détruit et le worker passe au dessin suivant dans la file d'attente.

Un fonctionnement supplémentaire a également été imaginé mais n'a pas encore été développé (pour l'instant). L'idée est de stocker tous les messages et dessins de la file d'attente principale dans une deuxième file d'attente. Cela permettrait de définir des horaires particuliers auxquels les messages et dessins reçus seraient tous affichés en boucle penant cette période, l'idée étant de nous permettre de voir lors de la pause déjeuner tout ce qui aura été envoyé depuis la veille.

En dehors de ces horaires, le dispositif suivra son mode de fonctionnement normal.

5) Mise en place

Matériel

Pour réaliser notre implémentation, nous utilisons:

  • Un Raspberry Pi 3 Model B+
  • Un panneau LED 64x32
  • Un Adafruit RGB Matrix HAT
  • Un fer à souder et de la soudure

Quelques soudures sont requises pour faire fonctionner le HAT. Il y a minimum 56 points de soudure à réaliser. Dans notre cas, nous avons en plus de cela fait deux soudures suppplémentaires, en connectant les pins n°4 et n°18 du HAT pour palier à des problèmes d'affichage. Dans notre cas, nous avons donc réalisé 58 points de soudure.

Note: Si vous souhaiter réaliser ce projet vous-même, pensez à utiliser une panne assez fine pour souder sur votre Pi, car en effet les pins GPIO de ce dernier sont très petits.
De manière générale, mais également du fait du grand nombre de soudures à réaliser, c'est une bonne idée de prendre son temps et de bien vérifier son travail après la soudure de chaque point. Cela permet d'éviter des problèmes par la suite dont l'origine peut sembler obscure, alors qu'il s'agit simplement d'un pin mal soudé.
Malgré cela, la soudure de ces composants reste assez simple et entièrement réalisable par un soudeur novice, ce qui a été le cas pour notre projet.

Alimentation

Afin d'alimenter correctement le Raspberry Pi ainsi que le panneau LED, nous utilisons une alimentation externe branchée directement sur l'adaptateur (HAT) et non sur le Raspberry Pi. Ce dernier alimente simultanément le panneau LED et le Raspberry Pi, par le biais des pins GPIO par lesquels il a été connecté à l'écran.

Il est à noter qu'un panneau LED de 32*32 pixels consomme environ 0.12A par “ligne” de pixels, soit 32 * 0.12 = 3.85A. Dans notre configuration l'écran consomme jusqu'à 64 * 0.12 = 7.68A. Nous utilisons donc une alimentation de 10A afin de ne pas être à l'étroit.

Bien sûr, notre panneau LED n'utilise pas toujours 7.68A, cette valeur représente sa consommation électrique si tous les pixels sont affichés en blanc (R:255, G:255, B:255) à pleine puissance.
C'est pour cela que nous avons choisi une alimentation capable de fournir 10A au cas où le panneau LED affichait uniquement du blanc sur tous ses pixels, car nous l'utilisons également pour alimenter le Raspberry Pi.
Ce serait évidemment assez embêtant si ce dernier n'avait pas assez de courant ou pire: si nos composants “tiraient” trop sur l'alimentation, ce qui peut s'avérer dangereux.

Logiciel

1: Sur le Raspberry Pi

Afin de bénéficier de meilleures performances, nous avons choisi d'utiliser DietPi comme système d'exploitation pour notre Raspberry Pi. Le pilotage par le Pi du panneau LED étant assez lourd en calculs, ce système d'exploitation optimisé pour de meilleures performances nous permet d'éviter des clignotements intempestifs sur les LED, assez fréquents lors de l'utilisation de l'OS Raspbian fourni par les constructeurs du Raspberry Pi.

Les informations nécessaires à l'installation de ce système d'exploitation sur un Raspberry Pi sont disponibles sur cette page du site de DietPi.

Une fois le système d'exploitation installé, nous nous connectons en root et pouvons cloner la bibliothèque C de hzeller depuis GitHub.

git clone https://github.com/hzeller/rpi-rgb-led-matrix.git

Ensuite, nous devons compiler la librairie afin de nous en servir. De plus, nous pouvons spécifier des paramètres de compilation afin d'obtenir un binaire adapté à notre modèle de Raspberry Pi ainsi que le HAT et panneau LED utilisés.

Dans notre cas, nous utilisons un HAT fait par Adafruit sur lequel nous avons soudé une connexion entre les GPIO 4 et 18.

HARDWARE_DESC=adafruit-hat-pwm make

Si vous utilisez le même HAT mais que vous n'avez pas soudé les pins n°4 et n°18:

HARDWARE_DESC=adafruit-hat make

Notre implémentation utilise les bindings Python de la librairie C de hzeller. Dans un souci de simplicité, notre worker se place donc dans le répertoire contenant les exemples de code des bindings Python, à savoir bindings/python/samples.

Afin de lancer le worker, nous avons besoin de lui spécifier l'URL de connexion au serveur AMQP. (Voir: II. Cloud)

AMQP_URL="AMQP://" python worker.py

Le worker affiche ensuite Listening for tasks....

2: Cloud

Lors du lancement du worker, nous avons évoqué un serveur AMQP. Dans notre implémentation, nous utilisons un serveur RabbitMQ en Cloud hébergé chez CloudAMQP.

C'est à partir du panneau d'administration de notre serveur chez CloudAMQP que nous pouvons récupérer l'URL AMQP précisée en paramètre au lancement du worker Python.

3: Serveur Web

La partie API du projet consiste en une fonction qui reçoit les informations du dessin ou du message envoyé. Ces informations sont ensuite validées et si elles correspondent au modèle de données que nous avons conçu, elles sont directement envoyées à la file d'attente AMQP. Cette validation nous permet de ne pas envoyer des données corrompues ou inexploitables au worker, et donc d'éviter assez facilement un bon nombre d'erreurs.

Notre API est déployée en tant que fonctions “Serverless” chez ZEIT, écrites en TypeScript.

Le code peut également être déployé sur un petit serveur, comme ceux proposés par DigitalOcean par exemple. Dans ce cas, il est plus simple d'utiliser notre implémentation utilisant le framework Express.js.

4: Navigateur

Nous en arrivons à la partie visible par le visiteur du site, l'interface utilisateur.

Cette dernière est écrite sous forme de module JavaScript qu'il est possible de mettre en place sur n'importe quelle page web.

Le module JavaScript construit les éléments d'interface requis pour une intéraction avec l'utilisateur. Il gère également le formatage des données selon le modèle évoqué précédemment, ainsi que leur envoi à l'API.

Le module est écrit en JavaScript “pur”, et ne dépend d'aucun autre module ou outil en JavaScript, le rendant facile à intégrer à un projet.
Par ailleurs, l'interface est conçue pour fonctionner à la fois sur un ordinateur et sur un mobile, que ce soit au niveau du module JavaScript ou de la feuille de style CSS associée.