Club robotique de Sophia-Antipolis

Accueil > Projets et études > Nos réalisations > Youpi > Youpi 2.0 > Le panneau de commande

Le panneau de commande

dimanche 27 novembre 2016, par Eric P.

 Le besoin

Il est assez facile de contrôler Youpi en s’y connectant par réseau. Diverses options existent pour cela :

  • le mode texte, en ligne de commande ou, plus user friendly, avec curses,
  • un navigateur Web accédant à une application Web hébergée sur un serveur HTTP embarqué.

La première est à réserver pour les interventions d’administration des personnels avertis. Quant à la deuxième.... eh bien elle fait l’objet d’un autre article (pas encore écrit).

Nous voulons pouvoir également interagir directement avec Youpi, sans équipement complémentaire. Comprenez : sans s’y connecter depuis un autre ordinateur.

 La solution

Il a donc été doté d’un panneau de contrôle composé :

  • d’un écran LCD alphanumérique 4 lignes de 20 caractères
  • de quatre touches, équipées chacune d’une LED

En plus de cela, le panneau de contrôle est équipé d’un interrupteur à clé, permettant de verrouiller l’usage du clavier dans le cadre de démonstration unattended en public.

JPEG - 324.2 ko
Le panneau de contrôle

 La réalisation

Un LCD doté d’un contrôleur disposant d’une liaison série ou I2C a été sélectionné afin de réduire le nombre de signaux à mobiliser pour cette fonction. Le modèle utilisé est le LCD05, fabriqué par la société Devantech, dans sa version blanc sur fond bleu (je trouve ça plus classe que le classique noir sur fond vert).

Une autre raison du choix de ce modèle est le fait qu’il dispose d’un scanner pour clavier matricé jusqu’à 4 lignes de 3 colonnes. On peut donc lui confier la gestion de nos 4 touches, en les câblant selon un matrice 2x2.

Le contrôle des 4 LEDs est confié à un expander I2C PCF8574 déjà connu de nos services. Une 5ème de ses IOs a été assignée à la lecture de l’interrupteur à clé, car l’ajouter à la matrice de touches aurait posé un problème du fait qu’il aurait alors manqué un élément pour qu’elle soit régulière.

Tout cela a été monté et câblé sur une platine à pastille, en utilisant la même technique de câblage que pour la carte d’interconnexion des dSPINs.

JPEG - 701.1 ko
L’envers du décor

On peut voir sur le côté droit le départ de la liaison I2C vers la Raspberry.

Voici le schéma de câblage pour compléter la documentation :

PNG - 23.8 ko
Schéma électronique

 Le logiciel

Ah, c’est là que ça commence à devenir rigolo :)

La solution à laquelle on pense spontanément pour utiliser le résultat dans une application est de s’écrire une petit lib qui présente une abstraction du panneau de contrôle sous forme d’une classe, et qui va s’occuper des dialogues I2C avec le LCD d’une part et l’expander d’autre part. On utilise cette lib dans l’appli et le tour est joué.

Oui mais...

On ne veut pas que le logiciel embarqué dans Youpi soit une unique application façon Arduino. Pour une simple raison : les fonctionnalités de Youpi doivent pouvoir évoluer au gré de l’imagination des contributeurs, et couvrir des aspects aussi diversifiés que contrôle par IHM Web, par Web service, démonstration autonomes,... Bref, impossible de mettre tout cela dans une application unique et de la faire évoluer librement. La philosophie sur laquelle l’environnement logiciel de Youpi est basée est la même que celle d’Unix : un outil par tâche et une tâche par outil. Traduit ici, cela donne une appli par fonction, le shell de base qui permet de naviguer et contrôler les diverses fonctionnalités en étant une.

De plus, il y a des sensibilités différentes au sein de l’assoc, et contraindre tout le monde à utiliser le même langage pour implémenter ces applis n’est pas l’objectif.

Par conséquent, il faut penser à une interface non dédiée à un langage. Or sous Unix, tout est fichier, y compris les devices et autres ressources. Quoi de plus simple que lire ou écrire dans un fichier et quoi de plus universel quel que soit le langage ?

Du coup le choix est fait : le panneau de contrôle sera utilisé au travers d’une collection de fichiers virtuels, représentant entre autres :

  • le contenu de l’affichage
  • l’état du rétro-éclairage du LCD
  • l’état des touches
  • l’état des LEDs
  • l’état de l’interrupteur à clé

Implémenter cela peut se faire en écrivant un module noyau. Mais pas que, grâce à FUSE. Et pour nous rendre les choses encore plus agréables, il y a des libraires pour implémenter un file system virtuel en Python. Elle est pas belle la vie ?

 Le codage

La libraire utilisé ici est fusepy. Implémenter le file system se résume à développer une classe dérivée de fuse.Operations qui fournit un certain nombre de méthodes correspondant aux différentes opérations de base :

  • création, suppression,
  • lecture, écriture,
  • retour d’informations telles que taille, date de modification...,
  • liste du contenu d’un répertoire
  • ...

Toutes ne sont pas obligatoires, en fonction de l’étendue du support à fournir. Un certain nombre sont par contre indispensables :

  • readdir qui retourne le contenu d’un répertoire (liste de fichiers)
  • getattr qui retourne les attributs d’un fichier ou répertoire, dont les valeurs doivent être cohérentes et conformes avec le fonctionnement d’un file-system, notamment concernant les valeurs des nombre de liens des inodes,
  • open, read et write bien entendu, ainsi que close notamment si vos accès utilisent une quelconque forme de bufferisation, ce qui n’est pas le cas ici
  • truncate de manière surprenante à priori, mais requise pour éviter l’erreur "read-only file system" générée par l’implémentation par défaut.

Notre file system est composé d’un unique répertoire, contenant les fichiers suivants :

backlightRWétat on/off du rétro-éclairage (0=off, autre valeur = on)
brightnessRWniveau de luminosité du rétro-éclairage (0-255)
contrastRWniveau de contraste du rétro-éclairage (0-255)
displayWutilisé pour modifier le contenu de l’affichage, en y écrivant le texte, éventuellement complété de séquences ANSI standard pour son positionnement, l’effacement partiel ou total de l’écran...
infoRinformations techniques sur le device (inspiré par le contenu de /proc/cpuinfo)
keysRbit pattern des touches appuyées au moment de la lecture, sous forme de l’entier correspondant
ledsRWbit pattern de l’état des LEDs, sous forme de l’entier correspondant
lockedRétat de l’interrupteur à cle de verrouillage du clavier (0=unlocked, autre valeur = locked)

A noter que pour faire les choses bien, le file system est adaptatif et son contenu est modulé par les capacités du LCD utilisé. Ainsi avec un LCD03 (version précédente du LCD05), les fichiers brightness et contrast ne sont pas créés, ces réglages n’étant pas accessibles avec ce modèle. Le type de matériel utilisé est indiqué par une des options de la ligne de commande du script que lance le daemon qui gère le file system.

L’implémentation des méthodes mentionnées plus haut pour chacun de ces fichiers n’appelle pas de commentaire particulier, le code étant lié à la nature des opérations à réaliser. Le mieux est de parcourir les sources disponibles sur GitHub, dans le module lcdfs.py du package pybot.lcd_fuse.

 Mise en oeuvre du file system

Là encore c’est assez simple. Il suffit de créer une instance de la classe FUSE du package fuse de la librairie fusepy, en lui passant en argument :

  • une instance de notre classe qui implémente les opérations vu ci-dessus,
  • le path du point de montage du file system.

D’autres arguments optionnels permettent d’en régler le fonctionnement :

nothreadsdéfini à True ici car nous gérons la daemonisation du processus nous-même
foregrounddéfini à False car nous allons tourner en daemon
direct_iodéfini à True pour ne désactiver toute bufferisation des IOs au niveau du système
allow_otherdéfini à True pour ne pas imposer d’être root pour accéder au file system

Tous les détails de mise en oeuvre sont présents dans le module daemon.py du package pybot.lcd_fuse, qui fournit le point d’entrée du process.

 Intégration dans l’init du système

Pour simplifier les opérations, ce daemon est lancé automatiquement lors de l’init du système. Etant donné que la version actuelle de Raspbian est basée sur Debian Jessie, c’est systemd qui a pris le relais des scripts init.d (même si ceux-ci continuent à être supportés bien entendu). On aurait pu continuer à écrire un script init.d, mais autant en profiter pour découvrir et se familiariser avec systemd.

Il a fait couler beaucoup d’encre et mobilisé pas mal de bande passante sur les réseaux tant il a pu être décrié par ses détracteurs. Il faut cependant reconnaître que systemd est maintenant arrivé à maturité (sinon il n’aurait pas été adopté pas plusieurs des distributions majeures) et qu’il apporte beaucoup plus de puissance et de commodité par rapport à son prédécesseur, notamment dès lors qu’on entre dans le domaine des dépendances entre services.

Je ne vais pas détailler ici comment on configure un service sous systemd, car il existe une abondante littérature suffisamment détaillée et de qualité sur le Web, et je vous suggère en premier lieu sa documentation de référence.

Toujours est-il qu’au lieu de coder un de ces sempiternels scripts bash plus ou moins cryptique sur le modèle de /etc/init.d/skeleton, la définition de notre service se résume au fichier suivant (/etc/systemd/system/lcdfs.service) :

[Unit]
Description=LCD FUSE file system
After=syslog.target
PartOf=youpi2.target
[Service]
Type=forking
Environment=PYTHONPATH=/home/pi/.local/lib/python2.7/site-packages/ LCDFS_MOUNT_POINT=/mnt/lcdfs
ExecStart=/home/pi/.local/bin/lcdfs -t pybot.youpi2.ctlpanel.devices.direct.ControlPanelDevice $LCDFS_MOUNT_POINT
ExecStop=/bin/fusermount -u $LCDFS_MOUNT_POINT
[Install]
WantedBy=youpi2.target

Reportez-vous à la documentation de référence pour le rôle des différents paramètres. Les points particuliers à noter ici sont :

  • PartOf qui nous permet de déclarer que ce service fait partie d’une target systemd, c’est à dire un état défini du système. Le concept de target est une généralisation de celui de runlevel, et permet par exemple de demander un start ou un stop d’un ensemble de services liés, ici youpi2, qui regroupe tout ce qui a trait à notre bras. Là encore, la doc de référence est ton amie ;)
  • Environment, qui définit les variables d’environnement, et notamment le PYTHONPATH si nécessaire dans notre cas,
  • ExecStart qui spécifie l’exécutable à lancer au démarrage, ici le script généré par l’installation du package et qui utilise le contenu du module daemon.py évoqué plus haut,
  • ExecStop qui utilise la commande fuse pour faire l’unmount du file system, ce qui entraînera la fin de l’exécution de notre process,
  • WantedBy inclut ce service dans ceux de la target youpi2, ce qui permet de le lancer automatiquement lors de l’utilisation de la commande systemctl start youpi2.

Ce dernier point et le concept de target sont un vrai bonheur lorsqu’on doit gérer un sous-système composé de plusieurs services. Ayant eu à le faire dans le cadre d’un projet professionnel, on se retrouve à implémenter dans le script init toute la gestion des dépendances et du calcul de séquence de lancement et d’arrêt, ce qui représente un paquet de lignes de bash pas particulièrement passionnantes. Plus besoin de se prendre la tête ici, alors rien que pour cela, et au risque de me faire brocarder par ses détracteurs, je dirais "vive systemd" :)

 Déploiement

Un tout dernier point pour conclure cet article, concernant le déploiement du package Python.

Il est géré classiquement par la définition du script setup.py à la racine du projet, faisant appel aux services de setuptools, à préférer à distutils censé disparaître à terme.

Je le reproduit ici pour documentation, car cela permet d’illustrer plusieurs aspects qui me semblent intéressants à connaître. Reportez-vous à la documentation de référence de setuptools pour les informations de base.

from setuptools import setup, find_packages
setup(
    name='pybot-lcd-fuse',
    setup_requires=['setuptools_scm'],
    use_scm_version={
        'write_to': 'src/pybot/lcd_fuse/__version__.py'
    },
    namespace_packages=['pybot'],
    packages=find_packages("src"),
    package_dir={'': 'src'},
    url='',
    license='',
    author='Eric Pascual',
    author_email='eric@pobot.org',
    install_requires=['pybot-lcd', 'fusepy', 'evdev'],
    extras_require={
        'systemd': ['pybot-systemd']
    },
    download_url='https://github.com/Pobot/PyBot',
    description='LCD access through fuse',
    entry_points={
        'console_scripts': [
            'lcdfs = pybot.lcd_fuse.daemon:main',
            # optionals
            "lcdfs-systemd-install = pybot.lcd_fuse.setup.systemd:install_service [systemd]",
            "lcdfs-systemd-remove = pybot.lcd_fuse.setup.systemd:remove_service [systemd]",
        ]
    },
    package_data={
        'pybot.lcd_fuse.setup': ['pkg_data/*']
    }
)

Hormis l’import qui référence setuptools comme évoqué juste avant, voici quelques remarques importantes :

  • setup_requires : on fait ici appel à un package que j’estime indispensable si vous utilisez un gestionnaire de version pour vos projet (ce qui devrait être la règle pour tout développeur un tant soit peu sérieux, même pour des projets perso ;)). Ce package permet de faire la liaison automatique entre la version calculée par le SCM (source code management) et celle du setup, évitant de devoir recopier cette information manuellement à chaque génération d’une distribution.
  • use_scm_version permet de spécifier quelques options de setuptools_scm, ici le fait que la version va être écrite dans un module Python sous la forme de l’instruction version = "...". Grâce à cela, il suffit d’importer ensuite cette variable dans tout module où on veut utiliser la version de la version courante (par exemple dans une affichage de type "A propos" ou dans l’aide d’une commande en ligne.
  • extra_requires permet d’ajouter des dépendances conditionnelles, en fonction de l’utilisation de certains options spécifiées dans la commande d’installation du package. Ici il s’agit de ce qui est spécifique à systemd, afin que les utilisateurs non concernés puissent omettre cette partie du package,
  • entry_points est la grande fonctionnalité du setup, qui permet de générer et installer automatiquement les scripts correspondant à des commandes spécifiques, et ce en tenant compte des spécificités de l’OS . On se moque un peu ici de cet aspect universel vu qu’on ne s’intéresse qu’à Linux, mais cela évite quand même de devoir coder un script pour la ligne de commande, l’installer dans un répertoire inclus dans le path et autres salamalecs peu passionnants... Sans vouloir se substituer à la doc de référence de setuptools que je vous conseille d’étudier, en résumé la configuration spécifiée ici définit plusieurs scripts pour la ligne de commande (section console_scripts), dont celui qui correspond à notre daemon (cf les explications concernant systemd plus haut) qui va être généré par setuptools pour enrober le lancement de la fonction main définie dans le module pybot.lcd_fuse.daemon. A noter que la fonction pointée doit être obligatoirement une fonction sans paramètre. On fait de même pour deux commandes utilitaires concernant l’installation et la désinstallation du service systemd,
  • package_data définit la liste de fichiers et répertoires constituant des ressources du package, embarquées et déployées avec lui. On y accède ensuite via les utilitaires du package pkg_resources, qui se charge de calculer les path physiques sur la base de l’identification du package d’appartenance et du path local. On s’en sert ici pour embarquer et déployer la définition du service systemd. Je trouve cette technique très pratique et évite de devoir embarquer le package Python dans un package Debian par exemple pour prendre en charge les opérations liées au système hôte.

 Utilisation

On peut bien entendu lire et écrire directement dans les fichiers virtuels constituant l’interface du LCD. D’ailleurs, un simple echo ou cat depuis la ligne de commande fonctionne parfaitement bien entendu.

Pour plus de lisibilité, on pourra écrire un client basique qui présentera les différentes actions au travers d’une classe par exemple. Un exemple d’un tel client fait partie du code base de Youpi, dans le repository GitHub du projet associé. Il est reproduit ci-après par commodité :

# -*- coding: utf-8 -*-
""" This module provides classes for managing Youpi's control panel.
The version of the :py:class:`ControlPanel` class defined in this module
offers functionality similar as the one in the `direct` sibling module,
but works with the virtual file system interface instead of the I2C
direct access.
It thus requires that the `lcdfs` FUSE file system is available on the
system.
"""

import os
 
__author__ = 'Eric Pascual'
 
 
class FileSystemDevice(object):
    F_BACKLIGHT = 'backlight'
    F_BRIGHTNESS = 'brightness'
    F_KEYS = 'keys'
    F_DISPLAY = 'display'
    F_INFO = 'info'
    F_CONTRAST = 'contrast'
    F_LEDS = 'leds'
    F_LOCKED = 'locked'
 
    _READ_WRITE = {
        F_BACKLIGHT: True,
        F_KEYS: False,
        F_DISPLAY: True,
        F_INFO: False,
        F_CONTRAST: True,
        F_BRIGHTNESS: True,
        F_LEDS: True,
        F_LOCKED: False,
    }
 
    KEYPAD_SCAN_PERIOD = 0.1
 
    def __init__(self, mount_point, debug=False):
        """
        :param str mount_point: the path of the mount point where the panel FUSE file system is mounted
        :param bool debug: activates the debug mode
        """

        if not os.path.isdir(mount_point):
            raise ValueError('mount point not found : %s' % mount_point)
 
        self._debug = debug
 
        self.was_locked = False
 
        self._mount_point = mount_point
        self._fs_files = {
            n: open(os.path.join(mount_point, n), 'r+' if self._READ_WRITE[n] else 'r')
            for n in os.listdir(mount_point)
        }
 
        self._info = {}
        for line in self._fs_files[self.F_INFO]:
            attr, value = (j.strip() for j in line.split(':'))
            try:
                value = int(value)
            except ValueError:
                pass
            self._info[attr] = value
        self._width = self._info['cols']
        self._height = self._info['rows']
 
    @property
    def width(self):
        return self._width
 
    @property
    def height(self):
        return self._height
 
    def _fp(self, name):
        try:
            fp = self._fs_files[name]
            fp.seek(0)
            return fp
        except KeyError:
            raise TypeError("not supported")
 
    def _device_write(self, fp, s):
        try:
            fp.write(s)
            fp.flush()
        except IOError:
            # IOError can happen at system shutdown time (race condition with device unmount)
            pass
 
    def get_leds_state(self):
        return int(self._fp(self.F_LEDS).read())
 
    def set_leds_state(self, state):
        self._device_write(self._fp(self.F_LEDS), str(state))
 
    def is_locked(self):
        """ Tells if the lock switch is on or off.
        """

        return bool(int(self._fp(self.F_LOCKED).read()))
 
    def get_backlight(self):
        return bool(int(self._fp(self.F_BACKLIGHT).read()))
 
    def set_backlight(self, on):
        self._device_write(self._fp(self.F_BACKLIGHT), '1' if on else '0')
 
    def reset(self):
        """ Resets the panel by chaining the following operations :
 
        * clear the display
        * turns the back light on
        * turns the cursor display off
        * turns all the keypad LEDs off
        * set the keypad scanner of the LCD controller in fast mode
        """

        self.clear()
        self.set_backlight(True)
        self.set_leds_state(0)
 
    def clear(self):
        self._device_write(self._fp(self.F_DISPLAY), '\x0c')
 
    def write_at(self, s, line=1, col=1):
        """ Convenience method to write a text at a given location.
 
        Arguments:
            s:
                the text
            line, col:
                the text position
        """

        self.write("\x1b[%d;%dH%s" % (line, col, s))
 
    def write(self, s):
        self._device_write(self._fp(self.F_DISPLAY), s)
 
    def get_keypad_state(self):
        # handle potential race concurrency by retaining the latest information
        raw = self._fp(self.F_KEYS).read().strip()
        try:
            return int(raw.split('\n')[-1])
        except ValueError:
            # handle possible invalid data
            return 0

Comme vous pouvez le constater, ce n’est du code rocket science, mais quelque chose de très simple. On notera l’utilisation de séquence ANSI dans les fonctions write_at ou clear par exemple.

 Conclusion

Nous voici au terme d’un article assez copieux et qui a dépassé le cadre du strict point de départ, en profitant du contexte pour présenter quelques sujets associés mais non spécifiquement liés : FUSE, FUSE et Python, systemd, la création de packages de distribution Python.

Ce parcours est bien entendu très simplifié afin de rester raisonnable quant à la longueur de l’article. Le but est de vous donner un fil conducteur (les sources disponibles sous GitHub), les pointeurs vers les documentations relatives aux techniques mises en oeuvre et quelques indications de départ pour se lancer dans leur étude.

N’hésitez pas à faire part de vos remarques, questions,... via les commentaires associés à cet article.

Un message, un commentaire ?

modération a priori

Attention, votre message n’apparaîtra qu’après avoir été relu et approuvé.

Qui êtes-vous ?

Pour afficher votre trombine avec votre message, enregistrez-la d’abord sur gravatar.com (gratuit et indolore) et n’oubliez pas d’indiquer votre adresse e-mail ici.

Ajoutez votre commentaire ici
  • Ce formulaire accepte les raccourcis SPIP [->url] {{gras}} {italique} <quote> <code> et le code HTML <q> <del> <ins>. Pour créer des paragraphes, laissez simplement des lignes vides.

Ajouter un document