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 :

hardwaremicrocontrôleurCapteurs I2CuartAfficheurs LEDsolénoïdeIBR (remise en jeu)c extensionext/tekbak/i2c/i2c.c + i2c-core.cruby dsllib/tekbak/I2CMapper DSL + Sensors::MapperservicessensorsPolling 100msrefereeState machinedisplayFont renderingioWebSocket bridgeamqpRabbitMQ — bus d’événements interneCore (serveur)ibr control

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 :

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) {
    close(file);
    return -2;
  }
  return file;
}

int i2c_read(int i2c_file, __u8 read_register) {
  __s32 result = i2c_smbus_read_byte_data(i2c_file, read_register);
  return (result < 0) ? -1 : result;
}

int i2c_write(int i2c_file, __u8 write_register, __u8 value) {
  __s32 result = i2c_smbus_write_byte_data(i2c_file, write_register, value);
  return (result < 0) ? -1 : 0;
}

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 :

void Init_i2c() {
  VALUE module = rb_const_get(rb_cObject, rb_intern("Tekbak"));
  VALUE klass = rb_const_get(module, rb_intern("I2C"));

  rb_define_alloc_func(klass, i2c_alloc);
  rb_define_method(klass, "initialize", i2c_initialize, 2);
  rb_define_method(klass, "get", i2c_get, 1);
  rb_define_method(klass, "set", i2c_set, 2);
}

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 :

adresseregistretyperuby dsl0x0_Version firmwarereadread :version, 0x00Reset microcontrôleurwritewrite :reset, 0x010x1_Hit détecté ? (piézo)read boolread :hit_detected?, 0x10Calibration seuilwrite 2Bwrite :hit_level_setup, 0x11..0x12Force de l’impactread 2Bread :hit_level, 0x13..0x140x2_Barrière IR (but franchi)read boolread :ball_detected?, 0x200x3_Badge RFID #1 (actif + ID)read 4B hexread :rfid1_id, 0x31..0x340x4_Badge RFID #2 (actif + ID)read 4B hexread :rfid2_id, 0x41..0x440x5_IBR (remise en jeu)read/writewrite :ibr_up, 0x51 / :ibr_down0x6_Buzzer (feedback sonore)readread :buzzer_tone, 0x61

Côté Ruby, le DSL I2CMapper rend cette memory map complètement transparente :

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

:waitingIBR verrouillé:registeringjoueurs détectésbadge RFID2 équipesIBR libéré (both)2 côtés OK:playingpartie en coursrègles reçuesSmart IBR LogicPour chaque côté :si prochainBut == victoire→ verrouiller IBRsinon→ libérer IBRchaque butInoutIBR libéré 2shit sans IRaprès 2s → update_ibr_states:tie_breakprolongationscores égaux:finishedIBR verrouillévictoireauto-reset après 30s

Le « smart locking »

La partie la plus subtile est update_ibr_states, appelée après chaque but :

def update_ibr_states
  potential_winners = potential_winners_with_next_goal
  %i{hinge lock}.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 :

def tempory_release_ibr(side)
  release_ibr(side)
  ::EM::Timer.new(2) { update_ibr_states }
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 :

scénario 1 : butHIT 0x10capteur piézo~200msIR 0x20barrière franch.→ GOAL + vitessescénario 2 : inoutHIT 0x10capteur piézo2s timeoutpas d’IRtimer expire→ INOUTcalcul de vitessevitesse = coeff × hit_level + offset (borné entre 2 et 35 km/h)

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 :

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 :

@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, encoder les règles du jeu dans un AST, et l’architecture Rails qui orchestre le tout.