Le Minitel (Médium interactif par numérisation d’information téléphonique) est un type de terminal informatique destiné à la connexion au service français de Vidéotex baptisé Télétel, commercialement exploité en France entre 1980 et 2012.
L’ensemble Minitel+Télétel était en quelque sorte l’ancêtre du Web actuel !
Le réseau Télétel du Minitel était accessible par différents numéros courts, dont le plus populaire était le 3615.
Il permettait l’accès, payant (60 francs – 9,15 € – par heure environ, payés par l’usager, dont 40 F – 6,10 € – pour le service et 20 F – 3,05 € – pour France Télécom), à de très nombreux services (jusqu’à 25 000 en 1996 !) :
L’objectif de ce mini projet est de redonner vie à cet objet mythique de l’histoire de l’informatique et des communications !
Utilisation du Minitel avec un Arduino
Le Minitel permet la transmission de données par voie série au moyen d’un connecteur DIN situé à l’arrière, avec des débits pouvant atteindre 4 800 bits/s, et même 9 600 bits/s avec le Minitel 2.
Rappel : l’unité bits/s se confond ici avec le baud.
2 : masse (GND)
3 : émission de données (Tx)
1 : réception de données (Rx)
A l’aide d’un microcontrôleur Arduino, on peut communiquer avec le minitel via le protocole série RS-232.
Coté écran, on peut bien sûr afficher du texte (encodage spécial dérivé de l’ASCII), mais il existe également un mode « semi-graphique » où chaque caractère peut être décomposé en 6 pseudo-pixels.
Par exemple, le caractère suivant, qui possède 4 pseudo-pixels blancs et 2 noirs, sera codé par le nombre (écrit en binaire) : 0b100111
Coté Arduino, nous utiliserons la bibliothèque Minitel1B_Hard.
#include <Minitel1B_Hard.h>
Une fois le Minitel relié aux ports de communication série Rx et Tx de l’Arduino, il faut créer l’objet représentant le Minitel :
Minitel minitel(Serial);
puis configurer la vitesse de communication :
minitel.changeSpeed(minitel.searchSpeed());
Ensuite, cette bibliothèque permet, entre autres fonctionnalités très intéressantes, de :
effacer l’écran : minitel.newScreen()
passer en mode graphique : minitel.graphicMode()
écrire un caractère de 6 pseudo-pixels à l’écran :
à la position du curseur : minitel.graphic(0b110110)
à la position souhaitée (ici x=30, y=15) : minitel.graphic(0b110110, 30, 15)
…
Code source complet (version carte SD)
/*
Programme d'affichage d'une image en noir&blanc sur un Minitel
Lecture d'un fichier sur la carte SD
Affichage via port série
Voir : https://info.blaisepascal.fr/3615-tuveuxmaphoto
*/
#include <SPI.h>
#include <SD.h>
File imgFile;
#include <Minitel1B_Hard.h>
// Minitel minitel(Serial); // Le premier port série matériel de l'ATMega 1284P (RXD0 TXD0),
// ou celui de l'Arduino UNO
Minitel minitel(Serial1); // Le deuxième port série matériel de l'ATMega 1284P (RXD1 TXD1).
void setup() {
Serial.begin(9600);
while (!Serial) {
;
}
// fonction bloquante ... ???
//minitel.changeSpeed(minitel.searchSpeed());
minitel.newScreen();
minitel.graphicMode();
Serial.println("init ...");
if (!SD.begin(4)) {
Serial.println("init failed!");
while (1);
}
Serial.println("init done.");
// Affichage de l'image sur le minitel
//afficher_image("spock_nb");
afficher_image_couleur("spock");
}
void loop() {
}
/*********************************************************
Fonctions d'affichage sur le minitel
**********************************************************/
void afficher_image(String imgFileName){
imgFile = SD.open(imgFileName);
if (imgFile) {
Serial.println(imgFileName);
// Lecture du fichier image
byte pixel[1];
while (imgFile.available()) {
imgFile.read(pixel,1);
minitel.graphic(pixel[0]);
}
// Fermeture du fichier
imgFile.close();
} else {
Serial.print("Erreur ouverture ");
Serial.println(imgFileName);
}
}
void afficher_image_couleur(String imgFileName){
imgFile = SD.open(imgFileName);
if (imgFile) {
Serial.println(imgFileName);
// Lecture du fichier image
byte pixel[3];
while (imgFile.available()) {
imgFile.read(pixel,3);
minitel.attributs(pixel[0]);
minitel.attributs(pixel[1]);
minitel.graphic(pixel[2]);
}
// Fermeture du fichier
imgFile.close();
} else {
Serial.print("Erreur ouverture ");
Serial.println(imgFileName);
}
}
/* Programme d'affichage d'une image en noir&blanc sur un Minitel
Image passée en paramètre d'une requête HTTP POST
via un formulaire
Affichage via port série
Voir : https://info.blaisepascal.fr/3615-tuveuxmaphoto
*/
#include <SPI.h>
#include <Ethernet.h>
#include <Minitel1B_Hard.h>
#include "arduino_base64.hpp"
//Minitel minitel(Serial); // Le premier port série matériel de l'ATMega 1284P (RXD0 TXD0),
// ou celui de l'Arduino UNO
Minitel minitel(Serial1); // Le deuxième port série matériel de l'ATMega 1284P (RXD1 TXD1).
byte mac[] = {0xDE, 0xAD, 0xBE, 0xEF, 0xFE, 0xED};
EthernetServer server = EthernetServer(80);
byte buf[3842]; // données base64
int decodedLen = 0;
int b64Len = 0;
String readString = "";
void setup() {
Serial.begin(9600);
while (!Serial) {
;
}
Serial.println("Minitel Image WebServer");
// fonction bloquante ... ???
//minitel.changeSpeed(minitel.searchSpeed());
minitel.newScreen();
minitel.graphicMode();
// start the Ethernet connection:
Serial.println("Initialize Ethernet with DHCP:");
if (Ethernet.begin(mac) == 0) {
Serial.println("Failed to configure Ethernet using DHCP");
if (Ethernet.hardwareStatus() == EthernetNoHardware) {
Serial.println("Ethernet shield was not found");
} else if (Ethernet.linkStatus() == LinkOFF) {
Serial.println("Ethernet cable is not connected.");
}
}
// start the server
server.begin();
Serial.print("Server is at ");
Serial.println(Ethernet.localIP());
}
void loop() {
// listen for incoming clients
EthernetClient client = server.available();
if (client) {
//Serial.println("new client");
// an http request ends with a blank line
boolean currentLineIsBlank = true;
readString = "";
while (client.connected()) {
if (client.available()) {
char c = client.read();
Serial.write(c);
readString += c;
if (c == '\n' && currentLineIsBlank) {
client.println("HTTP/1.1 200 OK");
client.println("Content-Type: text/html");
client.println("Connection: close");
client.println();
client.println("<!DOCTYPE HTML>");
client.println("<html>");
client.println("<head><meta charset='UTF-8'/></head>");
client.println("<form method='POST' id='img' enctype='text/plain'>");
client.println("<textarea name='img' form='img' rows='45' cols='100'></textarea>");
client.println("<input type='submit' value='Envoyer'>");
client.println("</form>");
if (readString.startsWith("POST")) {
int lenPos = readString.indexOf("Length:")+8;
int endPos = readString.indexOf("Origin:")-1;
String l = readString.substring(lenPos, endPos);
b64Len = l.toInt();
//readString = "";
for (int i = 0 ; i<b64Len ; i++){
char cc = client.read();
if (i>3) {
buf[i-4] = cc;
}
}
b64Len = b64Len-4;
decodedLen = base64::decodeLength(buf);
client.println("Image :</br>");
client.print("<ul><li>Base64 : ");
client.println(String(b64Len));
client.print("</li><li>Décodée : ");
client.println(String(decodedLen-1));
client.println("</li></ul>");
}
client.println("</html>");
break;
}
if (c == '\n') {
currentLineIsBlank = true;
} else if (c != '\r') {
currentLineIsBlank = false;
}
}
}
// give the web browser time to receive the data
delay(1);
// close the connection:
client.stop();
readString = "";
Serial.println("client disconnected");
if (decodedLen > 0 && decodedLen < 3842) {
afficher_image_couleur();
}
decodedLen = 0;
b64Len = 0;
}
}
void afficher_image_couleur(){
Serial.println("Affichage");
uint8_t decoded[decodedLen];
base64::decode(buf, decoded);
minitel.newScreen();
minitel.graphicMode();
if (decodedLen > 1500) { // Couleur
//Serial.println("Couleur");
for (int i=0 ; i<decodedLen ; i=i+3) {
minitel.attributs(decoded[i]);
minitel.attributs(decoded[i+1]);
minitel.graphic(decoded[i+2]);
}
} else { // N&B
//Serial.println("N&B");
for (int i=0 ; i<decodedLen ; i++) {
minitel.graphic(decoded[i]);
}
}
}
Version GET (ancien)
/* Programme d'affichage d'une image en noir&blanc sur un Minitel
Image passée en paramètre d'une requête HTTP GET encodée en Base64
<IP>/?img=s48eh8s3z8fd1vf...
Affichage via port série
Voir : https://info.blaisepascal.fr/3615-tuveuxmaphoto
*/
#include <SPI.h>
#include <Ethernet.h>
#include <Minitel1B_Hard.h>
#include <gBase64.h>
// Minitel minitel(Serial); // Le premier port série matériel de l'ATMega 1284P (RXD0 TXD0),
// ou celui de l'Arduino UNO
Minitel minitel(Serial1); // Le deuxième port série matériel de l'ATMega 1284P (RXD1 TXD1).
byte mac[] = {0xDE, 0xAD, 0xBE, 0xEF, 0xFE, 0xED};
IPAddress ip(192, 168, 0, 177);
EthernetServer server(80);
String readString = "";
String img = "";
void setup() {
Serial.begin(9600);
while (!Serial) {
;
}
Serial.println("Minitel Image WebServer");
// fonction bloquante ... ???
//minitel.changeSpeed(minitel.searchSpeed());
minitel.newScreen();
minitel.graphicMode();
Ethernet.begin(mac, ip);
// Check for Ethernet hardware present
if (Ethernet.hardwareStatus() == EthernetNoHardware) {
Serial.println("Ethernet shield was not found. Sorry, can't run without hardware. :(");
while (true) {
delay(1); // do nothing, no point running without Ethernet hardware
}
}
if (Ethernet.linkStatus() == LinkOFF) {
Serial.println("Ethernet cable is not connected.");
}
// start the server
server.begin();
Serial.print("server is at ");
Serial.println(Ethernet.localIP());
}
void loop() {
// listen for incoming clients
EthernetClient client = server.available();
if (client) {
Serial.println("new client");
// an http request ends with a blank line
boolean currentLineIsBlank = true;
while (client.connected()) {
if (client.available()) {
char c = client.read();
Serial.write(c);
readString += c;
if (c == '\n' && currentLineIsBlank) {
img = get_img(readString);
// send a standard http response header
client.println("HTTP/1.1 200 OK");
client.println("Content-Type: text/html");
client.println("Connection: close");
client.println();
client.println("<!DOCTYPE HTML>");
client.println("<html>");
client.println("Image Base64 :");
client.println(img);
client.println("</html>");
break;
}
if (c == '\n') {
currentLineIsBlank = true;
} else if (c != '\r') {
currentLineIsBlank = false;
}
}
}
// give the web browser time to receive the data
delay(1);
// close the connection:
client.stop();
Serial.println("client disconnected");
afficher_image_couleur(img);
readString="";
img = "";
}
}
String get_img(String readString) {
int imgPos = readString.indexOf("img=")+4;
int HTTPpos = readString.indexOf("HTTP")-1;
String img = "";
if(imgPos >0) {
img = readString.substring(imgPos, HTTPpos);
Serial.println(img);
}
return img;
}
void afficher_image_couleur(String imgString){
byte buf[imgString.length()];
imgString.getBytes(buf, imgString.length());
int decodedLen = base64_dec_len(buf, imgString.length());
char decoded[decodedLen];
base64_decode(decoded, buf, imgString.length());
//Serial.println(imgString.length());
for (int i=0 ; i<decodedLen ; i=i+3) {
minitel.attributs(decoded[i]);
minitel.attributs(decoded[i+1]);
minitel.graphic(decoded[i+2]);
//Serial.println(decoded[i]);
//Serial.println(decoded[i+1]);
//Serial.println(decoded[i+2]);
}
}
Configuration du Minitel
Sortie du répertoire: Fnct + Sommaire
Passage en mode périphérique: Fnct + T puis A
Désactivation de l’écho du terminal: Fnct + T puis E
Connexion
9600 bauds : Fnct + P puis
4800 bauds : Fnct+P puis 4
1200 bauds : Fnct + P puis 1
L’objectif est de réaliser un programme Python qui permette de convertir n’importe quelle image numérique dans un format compatible avec le protocole d’affichage du Minitel.
Pour la phase de développement, nous utiliserons l’image suivante (cliquer pour télécharger) :
Image en noir et blanc
Pour manipuler les images, nous utiliserons la bibliothèque PIL, et plus particulièrement l’objet Image.
Écrire une fonction ouvrir_image(chemin_fichier) qui renvoie un objet PIL.Image à partir d’un chemin de fichier.
Cette fonction devra gérer les éventuels problèmes qui pourraient se poser :
Fichier introuvable : on testera l’existence du fichier à l’aide de la fonction os.path.isfile() ;
Fichier incompatible : on encadrera la fonction d’ouverture de fichier image par un try: ... except: ... .
Si l’image n’a pas pu être ouverte correctement, la fonction renvoie None.
Redimensionnement
Une fois l’objet Image créé, il faut en modifier les dimensions, car la résolution de l’écran du Minitel (en pseudo-pixels) est de 25×3 lignes pour 40×2 colonnes. (25 étant le nombre de lignes de caractères et 40 le nombres de caractères par ligne).
On définira les variables globales qui définissent les dimensions de l’écran (en caractères) :
# Dimensions écran Minitel
W, H = 40, 24
On se propose de coder 3 modes différents de redimensionnement :
mode 0 : l’image est simplement redimensionnée aux nouvelles dimensions, il peut donc y avoir une déformation ;
mode 1 : l’image est tronquée pour conserver le rapport largeur/hauteur d’origine ;
mode 2 : pour respecter le rapport largeur/hauteur sans tronquer l’image, du noir est ajouté autour.
Écrire une fonction redimensionner(img, mode = 0) qui renvoie un nouvel objet Image aux dimensions adaptées à l’écran du Minitel, en utilisant le mode donné en argument à la fonction.
Conversion en Noir et Blanc
La méthode .convert(mode) de l’objet Image renvoie un nouvel objet Image, convertit l’image selon différents modes, qui définissent la manière dont sont codés les pixels (profondeur, …). Voici un extrait de la documentation de PIL donnant quelques uns des modes disponibles :
"1" (1-bit pixels, black and white, stored with one pixel per byte)
"L" (8-bit pixels, black and white)
"RGB"(3×8-bit pixels, true color)
"RGBA" (4×8-bit pixels, true color with transparency mask)
…
Il existe donc un mode permettant de convertir directement en noir et blanc (2 niveaux !) mais cela ne permet pas de régler le seuil de luminosité délimitant les pixels noirs des pixels blanc.
Nous souhaitons pouvoir spécifier ce seuil afin d’optimiser l’apparence de l’image :
63
95
127
159
191
Écrire une fonction convertirNB(img, seuil = 127) qui renvoie une image convertie en 2 niveaux (noir et blanc) selon le seuil de luminosité spécifié (nombre entre 0 et 255).
Conversion au format Minitel
La trame d’octets envoyés au Minitel constituera l’ensemble des caractères (contenant chacun 6 pseudo-pixels) les uns à la suite des autres, en partant de la position x = 0 et y = 0 (en haut à gauche de l’écran).
Il va donc falloir construire chacun de ces caractères à partir des pixels de l’image.
Pour obtenir la valeur d’un pixel d’une image, on peut utiliser la méthode .getpixel((x, y)).
ATTENTION, en mode "1" (1 bit par pixel) .getpixel() renvoie soit 0, soit 255.
Écrire la fonction convertir_pixels_octet(img, x, y) qui renvoie l’octet construit à partir des valeur des 6 bits représentant les 6 pixels à proximité du pixel de coordonnées (x, y).
Écrire la fonction convertirMinitel(img) qui renvoie la liste d’octets constituant la trame à envoyer.
Envoi au minitel
Quel que soit le moyen choisi pour envoyer les données au Minitel, il faut commencer par convertir la liste d’entiers en tableau d’octet, un type à part entière appelé bytearray.
b = bytearray(m)
Version fichier
try:
with open(fichier_trame, 'wb') as f:
f.write(bytearray(m))
except:
print("Impossible d'écrire dans le fichier %s !" %fichier_trame)
sys.exit(1)
Version serveur Web
Pour la version Web, on commence par afficher la trame au format base64 (un format qui permet de convertir un tableau d’octets en une chaîne de caractères « imprimables »).
print(base64.b64encode(bytearray(m)))
Ensuite, à l’aide d’un « simple » copier/coller, on peut passer cette chaîne de caractères comme paramètres dans une URL (méthode GET) :
http://172.16.16.177/?img=KAAAAAAAABQA ... AAAAA
ou bien la coller dans la zone de texte du formulaire (méthode POST).
Résultat attendu
Image en niveaux de gris
Les caractères peuvent également être affichés en 8 couleurs différentes. Mais sur un écran en noir et blanc, cela rend des niveaux de gris.
La bibliothèque Arduino Minitel1B_Hard permet de spécifier les 2 couleurs pour chaque caractère : le caractère lui-même et son fond.
Pour cela, avant d’afficher un caractère, on peut modifier les attributs d’affichage à l’aide de la fonction minitel.attribut()
Voici les codes des couleurs, tirés du code source de la bibliothèque :
Par exemple, pour modifier la couleur du caractère, on exécutera :
minitel.attribut(CARACTERE_JAUNE);
Écrire une fonction convertir8(img) qui renvoie une image convertie en 8 niveaux de gris.
Chaque caractère ne pouvant contenir que 2 couleurs, il faut trouver une méthode pour convertir chaque groupe de 6 pixels à 8 niveaux de gris en 6 pixels à 2 niveaux de gris, de sorte que les motifs soient visuellement les plus proches possible :
Écrire une fonction convertirMinitel8(img) qui renvoie la liste d’octets constituant la trame à envoyer.
Chaque caractère sera codé par 3 octets consécutifs : la couleur de fond, la couleur du caractère, le code du caractère semi-graphique.
Attention : l’ordre des couleurs ne correspond pas avec celui des niveaux de gris ! Si on ne modifie pas cet ordre on risque d’obtenir ceci :
Voici ce qu’on voit lorsque l’on affiche les couleurs dans l’ordre de leur numéro de code :