# Écrire une extension C pour Ruby sur de l'embarqué > Comment nous avons connecté Ruby à une électronique custom via une extension C pour piloter des capteurs I2C sur un BeagleBone — et les pièges qu'on n'avait pas vus venir. Date : 15/01/2014 Auteur : Aurélien N. Tags : Ruby, C, Embarqué, I2C, IoT --- On ne va pas se mentir : quand Tekbak nous a proposé de coder le cerveau de leur baby-foot connecté, on n'a pas tout de suite pensé à Ruby. Et pourtant. Le projet de cette jeune startup — un baby-foot avec détection de buts, lecteurs RFID, affichage LED et remontée de stats en temps réel — tourne intégralement sur un stack Ruby, déployé sur des cartes BeagleBone. Le problème, c'est que Ruby ne sait pas parler à un bus I2C. Il a fallu descendre d'un cran et écrire une extension en C. Voici ce qu'on a appris. ## Le contexte : Ruby sur un BeagleBone Le choix de Ruby n'était pas absurde. On avait besoin d'un langage expressif pour modéliser une machine à états complexe (arbitrage, gestion des joueurs, règles configurables), d'un écosystème réseau solide (RabbitMQ, WebSockets) et d'une capacité de déploiement à distance — les tables de Tekbak sont installées dans des bars et des entreprises, pas dans notre bureau. Le BeagleBone tourne sous Debian. On a un Linux complet, avec `apt`, `ssh`, `bundler`. On peut déployer du code Ruby comme sur un serveur classique. C'est exactement ce qu'on voulait : **pas de module kernel custom**, pas de cross-compilation exotique, juste un `bundle install` et un `foreman start`. Sauf que pour lire des capteurs physiques (accéléromètre de but, barrière IR, lecteurs RFID), il faut parler I2C. Et ça, c'est du `ioctl` sur `/dev/i2c-1`. Pas vraiment le territoire de Ruby. ## L'architecture complète Avant de plonger dans le C, un aperçu de comment tout s'assemble : L'extension C ne concerne qu'une seule couche — le pont entre Linux et le microcontrôleur I2C. Tout le reste est du Ruby pur. ## Le pont C : 160 lignes pour tout changer L'extension C tient dans deux fichiers. Le premier, `i2c-core.c`, encapsule les appels système Linux : ```c int open_i2c(const char *device, __u8 address) { int file = open(device, O_RDWR); if (file < 0) return -1; if (ioctl(file, I2C_SLAVE, address) < 0) return file; } int i2c_read(int i2c_file, __u8 read_register) int i2c_write(int i2c_file, __u8 write_register, __u8 value) ``` Trois fonctions. Ouvrir un bus, lire un registre, écrire un registre. C'est tout ce dont on a besoin. Le second fichier, `i2c.c`, fait le lien avec Ruby via l'API C de MRI. On expose une classe `Tekbak::I2C` avec `initialize`, `get` et `set` : ```c void Init_i2c() ``` Le `i2c_alloc` alloue une struct C avec `Data_Make_Struct` et enregistre un callback de libération pour le garbage collector — sinon on laisse des file descriptors ouverts à chaque cycle GC. Le genre de bug qu'on ne voit pas en dev mais qui plante la table au bout de 3 heures en bar. ## La memory map du microcontrôleur Le microcontrôleur expose ses capteurs via une memory map I2C. Chaque bloc fonctionnel occupe une plage de 16 adresses : Côté Ruby, le DSL `I2CMapper` rend cette memory map complètement transparente : ```ruby class Sensors::Mapper < I2CMapper # 0x1x hit sensor read :hit_detected?, 0x10 read :hit_level, 0x13..0x14 write :hit_level_setup, 0x11..0x12 # 0x2x IR ball detection read :ball_detected?, 0x20 # 0x3x / 0x4x RFID read :rfid1_id, 0x31..0x34, type: :hex_string read :rfid2_id, 0x41..0x44, type: :hex_string # 0x5x IBR (Intelligent Ball Return) write :ibr_up, 0x51, value: 0x01 write :ibr_down, 0x51, value: 0x00 end ``` Le `I2CMapper` infère les types depuis les noms de méthodes (`?` → booléen), gère les lectures multi-octets en big-endian, et délègue au code C via `get`/`set`. Un développeur Ruby peut écrire `sensors.hit_detected?` sans jamais penser au bus I2C en dessous. ## Le protocole IBR : remettre la balle en jeu intelligemment L'IBR (*Intelligent Ball Return*) est un mécanisme physique — un solénoïde qui verrouille ou libère la balle dans chaque cage de but. C'est le seul **actuateur** du système (tout le reste, ce sont des capteurs). Et c'est là que la logique métier devient intéressante. ### Le problème On ne veut pas que les joueurs puissent récupérer la balle n'importe quand. Si une équipe est à un point de la victoire, l'adversaire ne doit pas pouvoir bloquer la balle dans sa cage pour empêcher le dernier but. Inversement, on veut que la balle soit libérée rapidement après un inout (balle sortie du terrain sans marquer). ### La machine à états de l'IBR ### Le « smart locking » La partie la plus subtile est `update_ibr_states`, appelée après chaque but : ```ruby def update_ibr_states potential_winners = potential_winners_with_next_goal %i.each do |side| if potential_winners.include?(side) lock_ibr(side) # → write 0x51, 0x00 else release_ibr(side) # → write 0x51, 0x01 end end end ``` Le referee simule *tous les scénarios possibles* du prochain but (goal côté hinge, goal côté lock, inout hinge, inout lock) et verrouille l'IBR des côtés qui pourraient gagner. C'est du game theory appliqué à un solénoïde. Et pour les **inouts** (balle sortie sans but), on fait une libération temporaire de 2 secondes : ```ruby def tempory_release_ibr(side) release_ibr(side) ::EM::Timer.new(2) end ``` Tout ça traverse la stack complète : Ruby → I2CMapper DSL → extension C → `ioctl` → bus I2C → microcontrôleur → solénoïde. Un `write 0x51, 0x01` dans du Ruby qui fait cliquer un mécanisme physique à l'autre bout. ## La détection de but : fusion de capteurs La détection de but est un autre exemple de logique métier rendue élégante par Ruby, même si elle repose sur du hardware brut : Le `GoalTypeDetector` attend un HIT (impact piézo), lance un timer de 2 secondes, et regarde si une IR (barrière infrarouge franchie) arrive dans ce délai. HIT + IR = but. HIT seul = inout. La force de l'impact est convertie en vitesse affichée sur l'écran LED. ## Le piège d'extconf.rb Pour compiler une extension C en Ruby, on passe par `extconf.rb` et la lib `mkmf`. Le nôtre fait 5 lignes : ```ruby require "mkmf" if find_header "linux/i2c-dev.h" create_makefile "tekbak/i2c/i2c" else puts "*** need a linux box with i2c-dev.h !" end ``` Cinq lignes, et pourtant on y a passé du temps. Quelques leçons : **La documentation de `mkmf` est… spartiate.** La doc officielle liste les méthodes (`find_header`, `find_library`, `have_func`, `dir_config`…) sans vraiment expliquer les interactions entre elles. On a souvent fini sur le code source de gems existantes (`pg`, `nokogiri`, `ffi`) pour comprendre les patterns. **Le chemin de sortie du `.so` est implicite.** `create_makefile "tekbak/i2c/i2c"` génère un `i2c.so` qui doit se retrouver dans `lib/tekbak/i2c/`. Si l'arborescence ne matche pas exactement, le `require` Ruby échoue silencieusement. On a perdu une demi-journée là-dessus. **Le build est platform-specific par nature.** Notre `find_header "linux/i2c-dev.h"` échoue sur macOS — c'est voulu, on ne peut pas parler I2C depuis un Mac. Mais ça signifie que `bundle install` sur la machine d'un dev ne compile pas l'extension. Il faut gérer ce cas proprement côté Ruby (un `require` conditionnel avec fallback) pour que le reste du code puisse tourner en mode mock. **Les erreurs de compilation sont cryptiques.** Quand `mkmf` échoue, il génère un `mkmf.log` dans le répertoire de build. Sauf qu'en contexte `bundle install`, ce répertoire est temporaire et nettoyé automatiquement. Il faut savoir aller chercher le log *avant* qu'il disparaisse, ou configurer `bundle` pour garder les sources. ## L'électronique sans module kernel La contrainte forte du projet : on ne voulait pas écrire de module kernel. Les tables sont déployées chez les clients de Tekbak, mises à jour à distance via SSH. Un module kernel, c'est : - recompilé à chaque mise à jour du noyau, - spécifique à une version exacte de board, - impossible à débugger à distance sans accès physique. À la place, on passe par le **userspace I2C** de Linux. Le kernel expose `/dev/i2c-1`, `/dev/i2c-2` etc. via le module `i2c-dev` (qui lui est standard et maintenu). Notre code C fait juste des `open()` + `ioctl()` sur ces fichiers. Pas de module custom, pas de `insmod`, pas de `dkms`. Le trade-off, c'est le **polling**. Sans interruptions kernel, on interroge les capteurs toutes les 100ms via un timer EventMachine : ```ruby @timer = EM::add_periodic_timer(0.1) do @sensors.poll_for_changes do |change_type, params| message(change_type, params) end end ``` Pour un baby-foot c'est largement suffisant — un tir prend 200-300ms entre l'impact et le passage de la barrière IR. Mais c'est un choix qu'on ne ferait pas sur un système temps-réel strict. ## Ce qu'on referait (et ce qu'on ne referait pas) **On referait** le choix Ruby + extension C minimale. Le ratio expressivité/complexité est excellent. 160 lignes de C pour débloquer tout un écosystème Ruby, c'est un bon investissement. **On referait** le choix userspace I2C. La contrainte de déploiement à distance élimine toute solution qui demande une recompilation kernel. Le polling à 100ms n'a jamais été un problème. **On ne referait pas** confiance à la doc de `mkmf` sans avoir un projet de référence sous la main. Aujourd'hui on conseillerait de partir d'une gem existante avec extension C (`pg` est un bon modèle) et d'adapter. **On documenterait mieux** le fallback macOS dès le jour 1. On a perdu du temps à chaque onboarding de dev parce que le `bundle install` échouait sur leur Mac sans message clair. Au final, le stack Ruby de Tekbak commence à tourner en production sur les premières tables, avec des mises à jour déployées à distance par un simple `git pull && bundle install && foreman restart`. C'est exactement ce qu'on visait — et c'est cette extension C de 160 lignes qui a rendu tout ça possible. --- *Cet article est le premier d'une série de quatre sur le baby-foot connecté Tekbak. La suite : [afficher du texte sur des écrans LED de 7 pixels de haut](/blog/affichage-texte-led-custom-ruby-freetype), [encoder les règles du jeu dans un AST](/blog/moteur-regles-baby-foot-ast-ruby), et [l'architecture Rails qui orchestre le tout](/blog/architecture-rails-baby-foot-connecte).*