ZeKube : notifieur internet avec Arduino
par , le samedi 9 juin 2018 à 15:57

Catégorie : Général
Mots clés : Arduino

Souhaitant remplacer mon radio-réveil par une nouvelle horloge, je me suis lancé dans le développement matériel, avec Arduino, d'un système de message et notification relié à internet.


Idée générale

L'idée générale est de déporter toute la scénarisation et le calcul de l'affichage sur un serveur web.

Le périphérique ZeKube est donc un 'simple' micro-écran déporté avec fonction d'interaction utilisateur. De base le périphérique demande régulièrement ce qu'il faut afficher. Le type de vidéo diffusé est adapté à chaque modèle de ZeKube en fonction des caractéristique envoyé par celui-là dans la requête HTTP.

Du coup, il est facile d'étendre le système tant pour la logique de notification que pour les type d'affichage, tout ce fait coté serveur web.

Le système étant multi-utilisateur et multi-ZeKube, il y a une logique d'enregistrement des utilisateurs et des ZeKube. Cette logique est entièrement gérée coté serveur web et passe par la saisie d'un code unique affiché sur ZeKube, prouvant que l'utilisateur possède bien le périphérique à ajouter.

La logique du Kube est la suivante :

  1. envoyer une requête HTTP au serveur avec les caractéristiques du Kube dans des en-tête spécifiques, ajout de la référence du Kube (numéro de série, modèle) et de l'état des éventuelles capteurs (accéléromètre...) à la requête CGI
  2. récupération de la réponse et affichage à la volée (streaming) après décodage du début du 'film' indiquant le format (entrelacement audio-vidéo) et le débit d'affichage (nombre d'images par seconde)
  3. s'il y a interaction physique (mouvement, tape...) sur le Kube => interruption de l'affichage et retour à l'étape 1
  4. s'il le 'film' est terminé ou s'il y a une coupure => retour à l'étape 1

La logique du serveur web, recevant une requête d'un Kube est :

  • avec le numéro de série envoyé dans la requête HTTP du Kube :
    • si le Kube n'est pas présent dans la base de donnée => enregistrement dans la base de donnée sans rattachement à un utilisateur
    • si le Kube est présent dans la base mais non attaché à un utilisateur => affichage par défilement de l'URL du site sur le Kube => l'utilisateur peut alors saisir le numéro de série (inscrit ou fourni avec le Kube) sur l'interface web
    • lors de la saisie d'un numéro de série de Kube sur l'interface web => génération d'un code unique pour ce Kube + affichage par défilement du dernier code généré => l'utilisateur doit alors saisir ce code sur l'interface web
    • saisie du code unique d'un kube => rattachement du Kube à l'utilisateur qui vient de prouver qu'il est bien devant ce Kube
  • à partir de maintenant à chaque requête HTTP du Kube, les notifications sont dépilés et génère un film à afficher sur le Kube

Notes :

  • Le Kube, ne sait qu'afficher un film, la logique d'enregistrement et de notification est entièrement générée coté serveur web.
  • Le serveur web connaît les caractéristiques du Kube, grâce à la requête HTTP, et adapte le calcul du film (résolution, plan des pixels...) en fonction de ces informations.
  • Les notification sont gérées (ajout, suppression) par une pile dans la base de données.
  • Les utilisateurs ont accès à un micro tableau de bords à travers la base de données : liste de leur Kube, attachement/détachement des Kube, ajout de notifications...
  • Enfin, une API HTTP est disponible pour insérer des notifications depuis des programmes externes (script en ligne de commande utilisant curl/wget, bot Jabber...).

La forme de la requête HTTP, envoyée par le Kube est :

[http://zekube.toto.com/device/movie?serial=xxxxx&orientation=yyyy&move="zzzzzz](http://zekube.toto.com/device/movie?serial=xxxxx&orientation=yyyy&move="zzzzzz)
avec l'ajout des 2 en-têtes HTTP suivant : "User-Agent: ZeKube/1" et "ZEKUBE_SESSION=sdfqfqsdfqsfqsf"

Montage WiFi avec TinyDuino

TinyDuino est une série de micro-carte compatible Arduino. Il existe déjà tout une série de shield pour cette série.

Nous allons utiliser ici la suite :

  • carte processeur TinyDuino
  • carte interface USB pour la programmation et l'alimentation
  • shield WiFi ATWINC1500
  • shield Accelerometer ASD2611
  • shield Proto Terminal pour de connecter au circuit d'affichage

Les carte TinyDuino utilisés Empilement des cartes TinyDuino Connexion de l'ensemble TinyDuino

Pour le montage électronique de l'affichage sur matrice LED :

  • MAX7219
  • Matrix LED bicolor 8*8
  • 3 résistances
  • 2 condensateurs

Montage afficheur sur plaquette d'essai

Ce qui donne l'ensemble :

Ensemble du montage

/*-----------------------------------------------------------------------
 * ZeKube
 *
 * (c) Antoine Emerit 
 *-----------------------------------------------------------------------*/

//#define DEBUG
#define LOG

#define WIFI101
//#define CC3000
//#define ETHERNET

#define MATRIX_LED
//#define MATRIX_NEO

//-----------------------------------------------------------------------
// Bibliothèques
//-----------------------------------------------------------------------
#ifdef WIFI101
   #include <SPI.h>
   #include <WiFi101.h>

   WiFiClient client;
#endif

#ifdef CC3000
   #include <Adafruit_CC3000.h>
   #include <ccspi.h>
   #include <SPI.h>
   #include <string.h>

   Adafruit_CC3000 cc3000 = Adafruit_CC3000(8, 2, A3, 8);
#endif

#ifdef MATRIX_LED
   #include "LedControl.h"
#endif

#ifdef MATRIX_NEO
   #include "FastLED.h"
   #include <SPI.h>
   #include <Ethernet.h>
#endif

//-----------------------------------------------------------------------
// Data
//-----------------------------------------------------------------------
// Movie stream format
char         *stream_marker = (char*)"ZK1";
unsigned char stream_format;
unsigned int  stream_speed;
unsigned int  stream_audio_trunk_size;
unsigned int  stream_video_trunk_size;

// Device attributs
#ifdef MAXTRIX_LED
char device_serial[]      = "1111";
#else
char device_serial[]      = "2222";
#endif
char device_session[]     = "sdfqsdlfkjqhsdflqkjdlhh654654654";
char device_orientation[] = "bottom";
char device_move[]        = "freeze";

#ifdef MATRIX_LED
   // Matrix display
   LedControl lc = LedControl(3,4,5,1);
#endif

#ifdef MATRIX_NEO
   CRGB leds[64];
   byte mac[] = { 0xDE, 0xAD, 0xBE, 0xEF, 0xFE, 0xED };
   IPAddress ip(192, 168, 100, 177);
   EthernetClient client;
#endif


//-----------------------------------------------------------------------
// Initialisation (network, memory...)
//-----------------------------------------------------------------------
void setup() {
   // Loging/debug init
#ifdef LOG   
   Serial.begin(9600);

   Serial.println(F("============"));
#endif

   // Network init
#ifdef WIFI101
   WiFi.setPins(8, 2, A3, -1);
   if (WiFi.status() == WL_NO_SHIELD) {
#ifdef LOG
      Serial.println(F("WiFi shield not present")); 
#endif      
      while (true)
    delay(500);
   } 

   if (WiFi.begin(F("kozodo"), F("sdfgsdgsdgsdfgs")) != WL_CONNECTED) {
#ifdef LOG
      Serial.println(F("No WiFi ?!"));
#endif
      while (true)
    delay(500);
   }

#ifdef LOG
   IPAddress ip = WiFi.localIP();
   Serial.print(F("IP: "));
   Serial.println(ip);
#endif

#endif

#ifdef CC3000
   if (cc3000.begin()) {
      while (!cc3000.connectToAP("antoine", "internetcestsuper", WLAN_SEC_WPA2)) {
#ifdef LOG
    Serial.print(F("."));
#endif        
    delay(1000);
     }
   } else {
#ifdef LOG
    Serial.println("No WiFi card ?");
#endif    
    while (true)
       delay(1000);
   }

/*   
   if (!cc3000.begin(false, true, "zekube")) {
     while (!cc3000.startSmartConfig("zekube")) {
#ifdef LOG
    Serial.print(F("."));
#endif        
    delay(1000);
     }
   }
*/

/*  
#ifdef LOG
   uint8_t major, minor;
   uint16_t version;

   if(!cc3000.getFirmwareVersion(&major, &minor)) {
    Serial.println(F("\nUnable to retrieve the firmware version!\r\n"));   
    version = 0;
   } else {
    Serial.print(F("\nFirmware V. : "));
    Serial.print(major); Serial.print(F(".")); Serial.println(minor);
    version = major; version <<= 8; version |= minor;
   }
#endif   
*/
   while (!cc3000.checkDHCP()) {
      delay(500);
   }  

#ifdef LOG
   uint32_t ipAddress, netmask, gateway, dhcpserv, dnsserv;
   if (!cc3000.getIPAddress(&ipAddress, &netmask, &gateway, &dhcpserv, &dnsserv)) {
      Serial.println(F("Unable to retrieve the IP Address!\r\n"));
    } else {
       Serial.print(F("\nIP Addr: ")); cc3000.printIPdotsRev(ipAddress);
       Serial.print(F("\nNetmask: ")); cc3000.printIPdotsRev(netmask);
       Serial.print(F("\nGateway: ")); cc3000.printIPdotsRev(gateway);
       Serial.print(F("\nDHCPsrv: ")); cc3000.printIPdotsRev(dhcpserv);
       Serial.print(F("\nDNSserv: ")); cc3000.printIPdotsRev(dnsserv);
       Serial.println();
  }
#endif

#endif

#ifdef ETHERNET
  // start the Ethernet connection:
  if (Ethernet.begin(mac) == 0) {
#ifdef LOG
    Serial.println("Failed to configure Ethernet using DHCP");
#endif    
    // try to congifure using IP address instead of DHCP:
    Ethernet.begin(mac, ip);
  }
    // give the Ethernet shield a second to initialize:
  delay(1000);
#ifdef LOG
  Serial.print("My IP address: ");
  for (byte thisByte = 0; thisByte < 4; thisByte++) {
    // print the value of each byte of the IP address:
    Serial.print(Ethernet.localIP()[thisByte], DEC);
    Serial.print(".");
  }
  Serial.println();
#endif
  Serial.println("connecting...");
#endif

   // Display init
#ifdef MATRIX_LED
   lc.shutdown(0, false);
#endif

#ifdef MATRIX_NEO
   FastLED.addLeds<NEOPIXEL, 2>(leds, 64);
#endif
}

//-----------------------------------------------------------------------  
// Main loop
//-----------------------------------------------------------------------  
void loop() {
  load();
}

//-----------------------------------------------------------------------
// Loading the movie from the network
//-----------------------------------------------------------------------
void load() {
  unsigned long start_time = millis(), count = 0;
  int row_pos = 0, col_pos;
  unsigned char row;

#ifdef LOG
  Serial.println(F("------------"));
#endif

#ifdef WIFI101
  client.setTimeout(5000);

  if (client.connect("zekube.toto.com", 80)) {
#endif

#ifdef CC3000
  uint32_t ip = 0;
  Adafruit_CC3000_Client client;
  if (cc3000.getHostByName("zekube.toto.com", &ip))
    client = cc3000.connectTCP(ip, 80);
#ifdef LOG
  else
    Serial.println(F("Couldn't resolve!"));
#endif

#ifdef ETHERNET
  if (client.connect("zekube.toto.com", 80)) {
#endif

  if (ip && client.connected()) {
#endif

#ifdef ETHERNET
  if (client.connect("zekube.toto.com", 80)) {
#endif

    client.print(F("GET /device/movie"));
    client.print(F("?serial="));
    client.print(device_serial);
    client.print(F("&orientation="));
    client.print(device_orientation);
    client.print(F("&move="));
    client.print(device_move);
    client.println(F(" HTTP/1.0"));
    client.println(F("Host: zekube.toto.com"));
    client.println(F("User-Agent: ZeKube/1"));
    client.print(F("SetCookie: ZEKUBE_SESSION="));
    client.println(device_session);
    client.println();        

    // Wait firt octet from the server
    while (client.connected() && !client.available())
      ;

    // Wait for the stream marker
    if (client.find(stream_marker) && 
    client.readBytes((char*)&stream_format, 1) && 
    client.readBytes((char*)&stream_speed, 2) && 
    client.readBytes((char*)&stream_audio_trunk_size, 2) && 
    client.readBytes((char*)&stream_video_trunk_size, 2)) {

#ifdef LOG
      Serial.print(F("Format "));
      Serial.print(stream_format);
      Serial.print(F(", "));
      Serial.print(stream_speed);
      Serial.print(F(" image(s)/s ,"));
//      Serial.print(stream_audio_trunk_size);
//      Serial.print(F(" audio B(s), "));
//      Serial.print(stream_video_trunk_size);
//      Serial.print(F(" video B(s)."));
    Serial.println(F(""));
#endif

      while (client.connected()) {
    while (client.available()) {
      row = client.read();

#ifdef LEDMATRIX
//          lc.setRow(0, row_pos, row);
      lc.setColumn(0, 7-row_pos, row);
#endif

#ifdef MATRIX_NEO
      for(int col = 0; col < 8; col++) 
         if ((7-row_pos) % 2)
            leds[((7-row_pos)*8)     + col] = (row & (1 << col)) ? 0x1f000c - (abs(count-col)/3)%0xffff + (count+row_pos)/5*0x0000ff : 0x000000; 
         else
            leds[((7-row_pos)*8 + 7) - col] = (row & (1 << col)) ? 0x1f000c - (abs(count-col)/3)%0xffff + (count+row_pos)/5*0x0000ff : 0x000000; 
#endif

#ifdef LOG
#ifdef DEBUG
      Serial.print(row_pos);
      Serial.print(F(" = "));
      Serial.println(row, BIN);
#endif          
#endif
      if (++row_pos >= 8) {
        row_pos = 0;
        count++;
#ifdef MATRIX_NEO                      
        FastLED.show();
#endif
        delay((unsigned long)(1000 / stream_speed));
#ifdef LOG
#ifdef DEBUG
        Serial.println(F("-"));
#endif            
#endif
      }
    }
      }
    }
#ifdef LOG
     else
       Serial.println(F("No data !"));
#endif
  }
#ifdef LOG
 else
   Serial.println(F("GET failed"));
#endif

  delay(1000);

#ifdef WIFI101
  client.stop();

  delay(1000); #

  asm volatile ("  jmp 0"); 
#endif

#ifdef CC3000
  client.close();
  delay(1000);
  cc3000.disconnect();
  delay(1000);

  asm volatile ("  jmp 0"); 
#endif

#ifdef ETHERNET
  client.stop();

  delay(1000);

  asm volatile ("  jmp 0"); 
#endif
}

Notes et limites de cette version :

  • le SSID et son mot de passe sont en dur dans le code (à revoir). Le top du top serait de recherche les SSID ouvert et éventuellement de passer par des requêtes DNS pour initialiser une connexion au serveur web.
  • le numéro de série est en dur dans le code (à revoir). Il faudrait pouvoir en générer un au premier démarrage du Kube, et l'afficher à la demande.
  • pas de gestion du son et de l'accéléromètre (testé dans une version précédente, à revoir).

Montage Ethernet avec Arduino UNO

Ici c'est un afficheur, fait maison, à base de LED RBG Neo Pixel qui sert de matrice. Les Neo Pixels sont des LED adressables en série qui permette un affichage sur 24 bits :

Composants afficheur géant Détail LED Neo Pixel Câblage afficheur Connecteur jack afficheur Afficheur géant terminé Câble Arduino vers afficheur Pieds pour afficheur Afficheur complet Injecteur POE Injecteur POE

Carte Arduino UNO + Shield Ethernet-POE

Cette version utilise le shield ethernet Arduino officiel sur lequel a été soudé l'option POE. Du coup l'ensemble carte Arduino et afficheur peuvent être alimenté directement par un switch ethernet POE. Dans mon cas, n'ayant pas switch POE sous la main, j'ai utilisé un injecteur POE.

Les NEO pixels s'utilisent de façon différente par rapport à la matrice LED à travers le MAX7219, car chaque LED contient un registre à décalage de 3*8 octets et toutes les LED sont sérialisées. D'autre par, ces LED permettent un affichage en 24 bits. Il faut donc modifier le code du croquis Arduino et le code de génération des notifications. J'ai développé une version du système pour répondre à ce besoin mais je ne l'ai pas fusionné avec le code courant. A revoir.

Serveur Web

Configuration Apache

La configuration du host virtuel Apache est simplement :

<VirtualHost *:80>
    ServerName zekube.toto.com
    ServerAdmin webmaster@toto.com

    DocumentRoot /home/public/www/zekube.toto.com/htdocs

    ErrorLog /var/log/apache2/zekube.toto.com-error.log
    CustomLog /var/log/apache2/zekube.toto.com-access.log vhost_combined

    IndexOptions FancyIndexing FoldersFirst IgnoreCase NameWidth=* DescriptionWidth=* VersionSort ScanHTMLTitles

    # Pour mysqli
    php_flag magic_quotes_gpc Off

    <Directory /home/public/www/zekube.toto.com/htdocs>
        AllowOverride All
        Require all granted
    </Directory>

    RewriteEngine on
    RewriteCond %{HTTP_HOST} zekube.toto.com
    RewriteRule device/movie /home/public/www/zekube.toto.com/htdocs/device.php [L]

    LogLevel warn
</VirtualHost>

Scripts PHP

Le schéma de la base de données est :

CREATE DATABASE /*!32312 IF NOT EXISTS*/ `zekube` /*!40100 DEFAULT CHARACTER SET latin1 */;

USE `zekube`;

--
-- Table structure for table `kubes`
--

DROP TABLE IF EXISTS `kubes`;
/*!40101 SET @saved_cs_client     = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `kubes` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user` int(11) DEFAULT NULL,
  `serial` varchar(10) DEFAULT NULL,
  `code` varchar(4) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `kubes` (`serial`,`code`),
  UNIQUE KEY `serial` (`serial`)
) ENGINE=InnoDB AUTO_INCREMENT=31 DEFAULT CHARSET=latin1;
/*!40101 SET character_set_client = @saved_cs_client */;

--
-- Table structure for table `notifications`
--

DROP TABLE IF EXISTS `notifications`;
/*!40101 SET @saved_cs_client     = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `notifications` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `serial` int(11) DEFAULT NULL,
  `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `class` varchar(30) DEFAULT NULL,
  `data` longtext,
  PRIMARY KEY (`id`),
  KEY `notifications` (`serial`,`time`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
/*!40101 SET character_set_client = @saved_cs_client */;

--
-- Table structure for table `users`
--

DROP TABLE IF EXISTS `users`;
/*!40101 SET @saved_cs_client     = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `users` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(50) DEFAULT NULL,
  `password` varchar(50) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `username` (`username`,`password`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=latin1;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;

La page d'accueil et la page Welcome :

root@server:/home/public/www/zekube.toto.com/htdocs/index.php

<?php session_start(); ?>

<h3>ZeKube official website</h3>

<p><a href=welcome.php>Introduction to ZeKube</a>
<p><a href=register.php>Registrer a new Kube</a>
<p><a href=manage.php>Manage your Kube</a>


root@server:/home/public/www/zekube.toto.com/htdocs/welcome.php 
<?php session_start(); ?>

<p><a href=index.php>Go home</a>
<h3>Welcome <?= empty($_SESSION) ? '' : $_SESSION['username'] ?></h3>

<p>ZeKube is the last notification web gadget.
<p>Part of the "Internet ot the thing" this small and nice notifier can alert you on many events happening on internet. From a personnel schedule to your source of information.
<p>Features are :
<ul>
  <li>Small/nice size</li>
  <li>Audio and video (8*8 digits) alerting</li>
  <li>Internet wifi access</li>
</ul>   

Gestion des utilisateurs

Il n'y a pas encore de gestion (ajout/suppression) des utilisateurs. La création d'un nouvel utilisateur doit donc se faire directement dans la base de données.

En revanche il y a bien un script de login des utilisateurs :

root@server:/home/public/www/zekube.toto.com/htdocs/login.php

<?php @session_start(); ?>
<?php 

$db = new mysqli('localhost', 'zekube', '******', 'zekube');

if (!empty($_REQUEST['command']) and $_REQUEST['command'] == 'login' && !empty($_REQUEST['username']) && !empty($_REQUEST['password'])) {
    $result = $db->query("select * from users where username='$_REQUEST[username]' and password='$_REQUEST[password]'");
    if ($result) {
        $record = $result->fetch_assoc();
        $_SESSION['username'] = $record['username']; 
        $_SESSION['id'] = $record['id']; 
    } else {
        print "Invalid user; try again !";
    }
} else if (!empty($_REQUEST['command']) and $_REQUEST['command'] == 'create' && !empty($_REQUEST['username']) && !empty($_REQUEST['password'])) {
    $result = $db->query("insert into users set username='$_REQUEST[username]', password='$_REQUEST[password]'");
    if ($result) {
        $_SESSION['username'] = $_REQUEST['username'];
        $_SESSION['id'] = $db->mysql_insert_id();
    } else {
        print "Error, can't create user : ";
        print mysql_error();
    } 
} else if (!empty($_REQUEST['command']) and $_REQUEST['command'] == 'disconnect') {
    session_unset();
    header("Location: /");
} 

if (empty($_SESSION['username'])) {
    echo <<<END
        <form action=$_SERVER[PHP_SELF]>
            <p>Login    : <input type=text     name=username />
            <p>Password : <input type=password name=password />
            <p><input type=submit name=command value=login />
            <p>or create a new account :
            <p><input type=submit name=command value=create />
        </form>
END;
} else {
    echo <<<END
    <p><a href=login.php?command=disconnect>Disconnect</a></p>
    <p>Hello $_SESSION[username]</p>
END;
}

?>

Ce script est automatiquement appelé depuis les autres scripts, et vérifie à chaque appel quel utilisateur est connecté. Si ce n'est pas le cas, la page de login est affiché pour saisie des identifiants.

Gestion des Kubes

Le page register.php permet d'attacher un nouveau Kube à l'utilisateur courant :

  • attacher un kube par son numéro de série puis saisie du code unique affiché sur le kube (nécessite d'être devant le périphérique)

Note: le kube ne doit pas déjà être attaché à un utilisateur (sinon il faut d'abord le détacher - voir script suivant)

root@server:/home/public/www/zekube.toto.com/htdocs/register.php

<?php session_start(); ?>

<p><a href=index.php>Go home</a>
<h3>Welcome <?= empty($_SESSION) ? '' : $_SESSION['username'] ?></h3>

<?php


if (empty($_SESSION['username'])) {
    echo "<p>To add a new Kube, you first need to login with your personnal account :";
    include "login.php";
} else {
    $db = new mysqli('localhost', 'zekube', '******', 'zekube');

    if (!empty($_REQUEST['command']) && $_REQUEST['command'] == 'attach' && !empty($_REQUEST['serial']) && !empty($_REQUEST['code'])) {
        $result = $db->query("update kubes set user='$_SESSION[id]' where serial='$_REQUEST[serial]' and code='$_REQUEST[code]' and user is null");
        if ($db->affected_rows > 0) {
            header("Location: manage.php");
        } else {
            print $result->error();
            print "Error : no free Kube match this serial and code; try again !";
        }
    } else if (!empty($_REQUEST['command']) && $_REQUEST['command'] == 'register' && !empty($_REQUEST['serial'])) {
        $code = rand(1111, 9999);       
        $result = $db->query("update kubes set code='$code' where serial='$_REQUEST[serial]' and user is null");
        if ($db->affected_rows == 0) {
            print $result->error();
            print "Error : unknown kube or this serial number is already attached to a user (you ?) !";
        } else {
            echo <<<END
            <p>To attach your new Kube to your account, you must now enter the code displayed on the Kube :
            <p>
                <form action=register.php>
                    <p><input type=text name=serial value='$_REQUEST[serial]' readonly />
                    <p><input type=text name=code />
                    <p><input type=submit name=command value=attach />
                </form>
END;
        }
    } else {
            echo <<<END
            <p>To add your new Kube, you must first switch it on your kube and enter it's serial number :
            <p>
                <form action=register.php>
                    <p><input type=text name=serial />
                    <p><input type=submit name=command value=register />
                </form>
END;
    }       
}

?>

La page manage.php permet d'afficher le tableau de bord de l'utilisateur courant, permettant de :

  • lister les kube rattachés à l'utilisateur
  • détacher un kube pour le libérer et le rendre enregistrable pour un autre utilisateur
  • envoyer un message de notification au kube (appel l'API)

root@server:/home/public/www/zekube.toto.com/htdocs/manage.php

<?php session_start(); ?>

<p><a href=index.php>Go home</a>
<h3>Welcome <?= empty($_SESSION) ? '' : $_SESSION['username'] ?></h3>

<?php 

$db = new mysqli('localhost', 'zekube', '******', 'zekube');

if (empty($_SESSION['username'])) {
    echo "<p>To manage your Kube(s), you first need to login with your personnal account :";
    include "login.php";
} else {
    if (!empty($_REQUEST['command']) && $_REQUEST['command'] == 'detach') {
        $db->query("delete from kubes where serial='$_REQUEST[serial]'");
    }

    print "<h3>Your Kube(s) is/are :</h3>";

    $result = $db->query("select * from kubes where user='$_SESSION[id]'");
    while ($record = $result->fetch_assoc()) {
        echo <<<END
        <p>$record[serial] (code=$record[code])
        <a href=manage.php?command=detach&serial=$record[serial]>detach</a>
        <form action=api.php><input type=text name=data><input type=hidden name=serial value=$record[serial]><input type=hidden name=class value=NotifyMessage></form>
        <a href=api.php?serial=$record[serial]&class=NotifyAudio&data=sirene.wav>Audio test</a>
END;
    }
}

?>

Les étapes de la procédure d'enregistrement en vidéo sont :

Affichage de l'URL du site web car le Kube n'est pas encore enregistré :

Affichage du code de rattachement du Kube :

Affichage par défaut en absence de notification (horloge) :

Modules de notification

Le Kube appele toujours la même URL http://zekube.toto.com/movie?... redirigée vers device.php par une RewriteRule dans la configuration Apache. Le code de ce script est :

root@server:/home/public/www/zekube.toto.com/htdocs/device.php

<?php @session_start(); ?>
<?php
include "tools.php";

set_time_limit(120);

$db = new mysqli('localhost', 'zekube', '******', 'zekube');

$serial = !empty($_REQUEST['serial']) ? $_REQUEST['serial'] : 0;

$result = $db->query("select * from kubes where serial='$serial'");
if ($result->num_rows > 0) {
    $record = $result->fetch_assoc();
    $code = $record['code'];
    $user = $record['user'];

    if (!empty($code)) {
        if (empty($user)) {
            send_text("  $code  ", 1, 1, false);
        } else {
            if (!NotificationPop($serial, $db)) {
                if ($_REQUEST['orientation'] == 'front')
                    NotifyAudio($serial, 'sirene.wav');
                else if ($_REQUEST['move'] == 'anticlockwise')
                    NotifyMessage($serial, $_REQUEST['orientation']);
                else if ($_REQUEST['move'] == 'shake')
                    NotifyMessage($serial, '...');
                else
                    NotifyClock($serial);
            }
        }
    } else {
        send_text("  HTTP : / / ZEKUBE.TOTO.COM  ", 1, 1, false);
    }
} else {
    $db->query("insert into kubes (serial) values('$_REQUEST[serial]')");
}

?>

Le script contenant le code effectif de codage 'vidéo' des notifications est :

root@server:/home/public/www/zekube.toto.com/htdocs/tools.php

<?php

//====================================================================================
// Notification management
//====================================================================================
function NotificationPush($serial, $class, $data, $db) {
    $db->query("insert into notifications (serial, class, data) values ('$serial', '$class', '$data')");
}

function NotificationPop($serial, $db) {
    $result = $db->query("select * from notifications where serial='$serial' order by time limit 1");
    if ($result->num_rows > 0) {
        $record = $result->fetch_assoc();
        $db->query("delete from notifications where id='$record[id]'");
        $record['class']($serial, $record['data']);
        return true;
    }
    else
        return false;
}

//====================================================================================
// Notification type functions 
//====================================================================================
function NotifyMessage($serial, $text) {
    send_text($text, 1, 1, false);
}

function NotifySpeech($serial, $text) {
    send_text($text, 1, 1, false, "jabber.avi");
    system("echo \"$text\" | espeak -v fr -s 100 --stdout | sox -t wavpcm - -q -b 8 -c 1 -e unsigned-integer -r 15000 -t raw jabber.wav");
    send_mux("jabber.avi", "jabber.wav", 10);   
}

function NotifyAudio($serial, $file) {
    header("Content-type: text/plain");
    print "ZK1" . chr(0) . chr(208) . chr(7) . chr(250) . chr(0) . chr(0) . chr(0);
    //passthru("wget -q -O - \"http://mp3.live.tv-radio.com/franceinfo/all/franceinfo-32k.mp3\" | sox -t mp3 - -q -b 8 -c 1 -e unsigned-integer -r 15000 -t wavpcm -");
    //shell_exec("cat /home/antoine/workspace/ZeKube_website/htdocs/source2.mp3 | sox -t mp3 - -q -b 8 -c 1 -e unsigned-integer -r 15000 -t wavpcm test.wav");
    //passthru("cat /home/antoine/workspace/ZeKube_website/htdocs/test.wav");
    //shell_exec("stream_audio.sh");
    print file_get_contents("media/$file");
}

function NotifyTime($serial, $format) {
    $format = empty($format) ? '   H:i   ' : $format; 
    send_text(date($format), 1, 1, false);
}

function NotifyClock($serial) {
    send_clock(time(), 1, 1, false);
}

function NotifyVibrate($serial, $delay) {
    send_vibrate($delay);
}

//====================================================================================
// Stream generation
//====================================================================================
function send_text($text, $colorate, $speed, $debug, $file = NULL) {
   $size = 7;
   $font = "./arial.ttf";
   $out = "";

   if (empty($file)) {
      if ($debug) 
     header('Content-Type: image/png');
      else
     header('Content-Type: text/plain');

      $out = fopen('php://output', 'w');    
      fwrite($out, "ZK1" . chr(0) . chr(10) . chr(0) . chr(0) . chr(0) . chr(8) . chr(0));
   } else 
      $out = fopen($file, 'w');    

   $position = imagettfbbox($size, 0, $font, $text);
   $max_step = $position[4] - $position[0];

   $im = @imagecreatetruecolor($position[4] - $position[0], 8) or die('Impossible de crée un flux d\'image GD');
   $text_color = imagecolorallocate($im, 254, 254, 254);
   imagefttext($im, $size, 0, 0, 7, -$text_color , $font, $text);

   if ($debug) {
      imagepng($im);
      } else {
      for ($step = 0; $step <= $max_step - 8; $step++) {
     for ($repeat = 0; $repeat < $speed; $repeat++) {
        for ($color = 1; $color < 2; $color++) {
           for ($row = 0; $row < 8; $row++) {
              $one_line = 0;
              for ($col = 0; $col < 8; $col++)
                 $one_line = ($one_line << 1) + (imagecolorat($im, $step + $col, $row) == 0 ? 0 : (($color & $colorate) != 0 ? 1 : 0));
              fwrite($out, chr($one_line));
           }
        }
     }
      }
   }

   fclose($out);

imagedestroy($im);
}


function send_mux($videofile, $audiofile, $interleave) {
   $video = fopen($videofile, 'r');
   $audio = fopen($audiofile, 'r');

   print "ZK1" . chr(0) . chr(120) . chr(0) . chr($interleave) . chr(0) . chr(1) . chr(0);

   while (feof($video) === FALSE || feof($audio) === FALSE) {
      $v = fread($video, 1);
      $a = fread($audio, $interleave);

      if ($v === FALSE) $v = chr(0);
      if ($a === FALSE) $a = chr(0);

      print  str_pad($a, $interleave, chr(0)) .  $v;
   }

   fclose($video);
   fclose($audio);
}

function send_clock($time, $colorate, $speed, $debug, $file = NULL) {
     $out = fopen('php://output', 'w');    

     header('Content-Type: image/png');

     fwrite($out, "ZK1" . chr(0) . chr(1) . chr(0) . chr(0) . chr(0) . chr(8) . chr(0));

     for ($second = 0; $second < 60; $second++) {
    $im = @imagecreatetruecolor(8, 8) or die('Impossible de créer un flux d\'image GD');
    $text_color = imagecolorallocate($im, 254, 254, 254);

    // Draw the clock
    $heure = localtime($time + $second, TRUE);
    $h = $heure[tm_hour];
    $m = $heure[tm_min];
    $s = $heure[tm_sec];
    imageline    ($im, 3.5, 3.5, 3.5 +   2*sin(deg2rad(($h * 360) / 12)), 3.5 -   2*cos(deg2rad(($h * 360) / 12)), $text_color);
    imageline    ($im, 3.5, 3.5, 3.5 + 4.5*sin(deg2rad(($m * 360) / 60)), 3.5 - 4.5*cos(deg2rad(($m * 360) / 60)), $text_color);
    imagesetpixel($im,           3.5 + 4.5*sin(deg2rad(($s * 360) / 60)), 3.5 - 4.5*cos(deg2rad(($s * 360) / 60)), $text_color);

    if ($debug) {
        imagepng($im);
    } else {
        for ($repeat = 0; $repeat < $speed; $repeat++) {
            for ($color = 1; $color < 2; $color++) {
                for ($row = 0; $row < 8; $row++) {
                    $one_line = 0;
                    for ($col = 0; $col < 8; $col++)
                        $one_line = ($one_line << 1) + (imagecolorat($im, $col, $row) == 0 ? 0 : (($color & $colorate) != 0 ? 1 : 0));
                    fwrite($out, chr($one_line));
                }
            }
        }
    }
    imagedestroy($im);
     }
     fclose($out);
}

function send_vibrate($delay = 10) {
    header('Content-Type: text/plain');
    print("ZK2" . chr($delay));
}

?>

Le code du script d'API est super simple :

root@server:/home/public/www/zekube.toto.com/htdocs/api.php

<?php

include "tools.php";

$serial = $_REQUEST['serial'];
$class = $_REQUEST['class'];
$data = empty($_REQUEST['data']) ? $_REQUEST['text'] : $_REQUEST['data'];

$db = new mysqli('localhost', 'zekube', '******', 'zekube');

NotificationPush($serial, $class, $data, $db);

?>

L'ajout de nouveaux types de notification est assez simple :

Notes :

  • il y a déjà des fonctions d'affichage de texte défilant, d'horloge numérique et analogique
  • la fonction d'affichage de texte défilant nécessite le fichier de police arial.ttf (à mettre dans le dossier du serveur web au même endroit que le script tools.php)
  • un début de multiplexage audio/vidéo existe dans le script tools.php mais le croquis Arduino ne contient plus le code permettant de le gérer (à revoir).
  • les scripts ne sont absolument pas sécurisés, donc à ne pas mettre en accès public sur internet.

Mise en boîte

Version TinyDuino

La 'mise en boîte' de la première version, la plus compacte, s'est faite en réalisant à la main un petit circuit imprimé. Il aurait été plus propre de graver un vrai circuit, mais faute de moyen je suis passé par une plaque pastillée, un vrai cauchemar vue la taille du circuit et le nombre de ponts à souder !

Pour le boîtier physique, rien de telle qu'une impression 3D.

Le circuit imprimé

Préparation du circuit imprimé (sans les ponts vers la matrice LED) sous Fritzing; histoire de ne pas se tromper du premier coup ;-) :

Préparation du CI

Et au boulot :

CI et brochage CI brut CI coupé Soudage composants 1 Soudage composants 2 Soudage composants 3 Soudage composants 4 Soudage composants 5 Soudage ponts Soudage connecteur TinyDuino 1 Soudage connecteur TinyDuino 2 Assemblage circuit Assemblage matrice LED Montage Montage + TinyDuino 1 Montage + TinyDuino 2 Montage + TinyDuino 3 Fonctionnement 1 Fonctionnement 2

Impression 3D du boîtier

Après quelques mesures et une rapide modélisation sous FreeCAD, puis impression 3D cela donne :

Mesures Modèle 1 Modèle 2 Modèle 3 Modèle 4 Modèle 5 Impression

Mesures Mesures

Version Ethernet-POE avec grand afficheur RGB

Pas de circuit imprimé à réaliser, car on reste avec la carte Arduino UNO et le shield Ethernet officiel. Il y a juste à souder le module POE sur la carte Ethernet.

Pour le boîtier, je réutilise la version officielle du projet Arduino, mais attention il n'y a pas assez de place pour le module POE et donc il ne ferme pas parfaitement. A revoir ou à modéliser en 3D.

Bien sûr il faut brancher le tout sur un switch POE ou un switch standard en y ajoutant un injecteur POE (le petit boitier noir présent ici, avec son alimentation externe).

Arduino UNO et shield Ethernet Arduino UNO et shield Ethernet dans son boîtier Injecteur POE Ensemble du système

Améliorations à prévoir

Coté matériel :

  • support du son : ajout d'un ampli (simple transistor ou shield avec ampli...), multiplexage du son dans le stream
  • gestion des matrice LED bi/tri-color : par registre à décalage et timing précis pour gestion des nuances
  • gestion des mouvements et interruptions
  • interface LiFi pour configurer l'accès WiFi (https://hackaday.io/project/153408-esp8266-screen-set-wifi-credentials)

Coté logiciel :

  • configuration WiFi : par WiFi en mode serveur, par LiFi, par le portail web
  • push : websocket, HTTP/2...
  • sécurisation des connexions : HTTPS, certificat client
  • tunnel par DNS
  • notifications scriptables en javascript : coté navigateur web pour test, coté serveur pour implémentation
  • notification effaçable/remplaçable
  • développer des notifications standards : IM-XMPP/IRC, Supervision-Shinken, Bureautique-emails/calendrier, Social-Twitter/FaceBook...
  • synchronisation des ZeKube : gestion des placements

Bonnes notifications

Antoine

Ecrire à l'auteur