La construction de notre panneau LED

Développement Hardware
Article écrit par Gabriel le 5 janv. 2020

Si vous tombez sur cet article, c'est que vous avez essayé notre petit module de panneau LED que nous avons développé à l'occasion de la refonte de notre site (sinon nous vous encourageons à le faire ici)

Notre panneau LED affichant le personnage de Mario en pixel art

Nous vous proposons d'en découvrir plus sur la conception technique et de plonger avec nous dans ce petit projet mêlant électronique sur le célèbre Raspberry Pi, développement frontend avec l'élément canvas, développement backend en Python pour contrôler l'électronique, RabbitMQ pour la gestion des messages et Express pour la gestion de l'API déployée sur une architecture FaaS (serverless).

Si vous voulez vous rendre directement vers la technique, les dépots sont tous disponibles sur notre Github :

En bref

Notre solution comporte 4 composants majeurs, à savoir :

Lorsque vous dessinez sur notre site, un petit composant de code calcule votre dessin et le transmet à notre 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 !

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.

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>.

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. Comme 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é précédement, 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 fonction 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 l'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. 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 parcourus, 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.

5) Mise en place

Matériel

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

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.

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.

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: 2. 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 outils, le rendant facile à intégrer à un projet.

Contactez l'agence Webup

Besoin d'aide ?

Laissez-nous un message pour être recontacté par notre chargé de compte, et discutons de votre projet autour d’un café (ou un thé) !

Nous contacter