Dans le domaine du développement Android, la communication série est un mécanisme essentiel pour interagir avec des périphériques externes tels que des capteurs, des microcontrôleurs ou d'autres équipements inudstriels. Pour que cette interaction soit efficace et fiable, il est nécessaire d'établir un protocole qui définisse la structure des données et les règles d'échange.
Protocoles de Communication Série Courants
Plusieurs types de protocoles sont couramment employés pour l'échange de données via des liaisons série :
- Protocole ASCII : Les données sont représentées par des caractères ASCII, ce qui les rend lisibles par un humain et faciles à déboguer. Chaque commande ou information est transmise sous forme de texte, souvent terminée par des caractères de contrôle comme le retour chariot ou le saut de ligne.
- Protocole Binaire : Contrairement à l'ASCII, ce protocole transmet des octets bruts. Il est généralement plus compact et plus rapide, car il ne nécessite pas de conversion de caractères. Cependant, il est plus complexe à inspecter et à débugger.
- Protocole Modbus : Largement utilisé dans l'automatisation industrielle, Modbus est un protocole maître/esclave pour la communication entre périphériques. Il se décline en variantes RTU (transmission binaire compacte), ASCII et TCP/IP (pour les réseaux).
- Protocoles Spécifiques/Personnalisés : Lorsque les protocoles existants ne répondent pas aux besoins spécifiques d'une application (contraintes de performance, de sécurité, ou de format de données), il est courant de concevoir son propre protocole.
Étapes pour la Conception d'un Protocole Personnalisé
La création d'un protocole de communication série sur mesure implique une approche structurée pour garantir la robustesse et la flexibilité. Voici les étapes clés :
- Définition du Format de Trame : Il s'agit de structurer les paquets de données échangés. Cela inclut généralement :
- Un octet de début de trame : un caractère ou une séquence d'octets fixe indiquant le début d'un message.
- Un identifiant de commande ou de type de message : un octet pour distinguer les différents types de requêtes ou de réponses.
- Un champ de longueur : un octet ou plus indiquant la taille des données utiles (charge utile).
- La charge utile (data) : les données brutes que le message transporte.
- Un champ de vérification d'intégrité (checksum ou CRC) : pour détecter les erreurs de transmission.
- Un octet de fin de trame : marquant la fin du message.
- Établisseemnt des Règles de Transmission : Définir comment les messages sont envoyés et reçus. Cela peut inclure des notions comme le contrôle de flux, les délais d'attente, ou la gestion des accusés de réception (ACK/NACK).
- Implémentation des Mécanismes d'Encodage et de Décodage : Développer les fonctions qui convertissent les données applicatives en trames protocolaires (encodage) côté émetteur, et inversement (décodage) côté récepteur.
- Intégration de la Vérification d'Erreurs : Au-delà du simple checksum, il est important de concevoir des mécanismes pour la gestion des trames corrompues ou incomplètes, et de définir la réponse du système à ces erreurs.
- Tests et Validation Rigoureux : Effectuer des tests approfondis dans diverses conditions (forte charge, erreurs simulées) pour s'assurer de la stabilité, de la fiabilité et de la conformité du protocole.
- Documentation Complète : Rédiger une spécification claire et détaillée du protocole. Cette documentation est essentielle pour la maintenance, l'intégration avec d'autres systèmes et le travail d'équipe.
Exemple d'Implémentation d'un Protocole Série Simplifié
Voici un exemple Java illustrant la construction et l'analyse de trames pour un protocole série simple. Ce protocole utilise un octet de début, un type de commande, la longueur des données, la charge utile, un checksum XOR et un octet de fin.
Classe pour l'Assemblage et l'Analyse des Trames (FrameAssembler)
import java.nio.ByteBuffer;
import java.util.Arrays;
public class FrameAssembler {
// Constantes du protocole
private static final byte START_DELIMITER = (byte) 0xAA; // Début de trame
private static final byte END_DELIMITER = (byte) 0x55; // Fin de trame
private static final int MAX_PAYLOAD_LENGTH = 250; // Longueur maximale des données utiles
/**
* Calcule un checksum XOR simple pour un tableau d'octets.
* @param data Les octets sur lesquels calculer le checksum.
* @return Le checksum calculé.
*/
private static byte calculateXORChecksum(byte[] data) {
byte checksum = 0;
for (byte b : data) {
checksum ^= b;
}
return checksum;
}
/**
* Construit une trame protocolaire à partir d'un type de commande et d'une charge utile.
* Format: [START_DELIMITER] [COMMAND_TYPE] [LENGTH] [PAYLOAD] [CHECKSUM] [END_DELIMITER]
* @param commandType Le type de commande (1 octet).
* @param payload Les données utiles (charge utile).
* @return Le tableau d'octets représentant la trame complète, ou null si la charge utile est trop longue.
*/
public static byte[] buildFrame(byte commandType, byte[] payload) {
if (payload.length > MAX_PAYLOAD_LENGTH) {
System.err.println("Erreur: Charge utile trop longue (" + payload.length + " > " + MAX_PAYLOAD_LENGTH + " octets)");
return null;
}
// Le checksum est calculé sur le COMMAND_TYPE, LENGTH et PAYLOAD
byte[] dataForChecksum = ByteBuffer.allocate(1 + 1 + payload.length)
.put(commandType)
.put((byte) payload.length)
.put(payload)
.array();
byte checksum = calculateXORChecksum(dataForChecksum);
// Allouer le buffer pour la trame complète
// 1 (START) + 1 (COMMAND_TYPE) + 1 (LENGTH) + payload.length + 1 (CHECKSUM) + 1 (END)
ByteBuffer frameBuffer = ByteBuffer.allocate(5 + payload.length);
frameBuffer.put(START_DELIMITER)
.put(commandType)
.put((byte) payload.length)
.put(payload)
.put(checksum)
.put(END_DELIMITER);
return frameBuffer.array();
}
/**
* Représente une trame décodée.
*/
public static class DecodedFrame {
public final byte commandType;
public final byte[] payload;
public final boolean isValid;
public DecodedFrame(byte commandType, byte[] payload, boolean isValid) {
this.commandType = commandType;
this.payload = payload;
this.isValid = isValid;
}
@Override
public String toString() {
return "DecodedFrame{" +
"commandType=0x" + String.format("%02X", commandType) +
", payload=" + Arrays.toString(payload) +
", isValid=" + isValid +
'}';
}
}
/**
* Analyse une trame d'octets complète et en extrait les informations.
* @param frameBytes La trame d'octets complète à analyser.
* @return Un objet DecodedFrame contenant le type de commande, la charge utile et l'état de validation.
*/
public static DecodedFrame parseFrame(byte[] frameBytes) {
if (frameBytes == null || frameBytes.length < 5) { // Taille minimale: START + COMMAND + LENGTH + CHECKSUM + END
return new DecodedFrame((byte) 0, new byte[0], false); // Trame trop courte
}
// Vérifier les délimiteurs de début et de fin
if (frameBytes[0] != START_DELIMITER || frameBytes[frameBytes.length - 1] != END_DELIMITER) {
System.err.println("Erreur de délimiteurs de trame.");
return new DecodedFrame((byte) 0, new byte[0], false);
}
byte commandType = frameBytes[1];
int payloadLength = Byte.toUnsignedInt(frameBytes[2]); // La longueur est un octet non signé
if (frameBytes.length != (5 + payloadLength)) {
System.err.println("Erreur: Longueur de trame incorrecte. Attendue: " + (5 + payloadLength) + ", Reçue: " + frameBytes.length);
return new DecodedFrame(commandType, new byte[0], false);
}
// Extraire la charge utile
byte[] payload = new byte[payloadLength];
System.arraycopy(frameBytes, 3, payload, 0, payloadLength);
byte receivedChecksum = frameBytes[3 + payloadLength];
// Recalculer le checksum
byte[] dataForChecksum = ByteBuffer.allocate(1 + 1 + payloadLength)
.put(commandType)
.put((byte) payloadLength)
.put(payload)
.array();
byte calculatedChecksum = calculateXORChecksum(dataForChecksum);
if (receivedChecksum != calculatedChecksum) {
System.err.println("Erreur de checksum. Reçu: 0x" + String.format("%02X", receivedChecksum) +
", Calculé: 0x" + String.format("%02X", calculatedChecksum));
return new DecodedFrame(commandType, payload, false);
}
return new DecodedFrame(commandType, payload, true);
}
}
Classe pour la Gestion de la Communication Série (SerialPortManager)
Cette classe gère l'ouverture, la fermeture et l'échange de trames via le port série. Elle utilise FrameAssembler pour la logique protocolaier.
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Arrays;
import java.io.ByteArrayOutputStream; // Nécessaire pour l'implémentation de MockSerialPort
// Interface simplifiée pour le port série, à remplacer par votre bibliothèque spécifique (ex: jssc, purejavacomm, ou une implémentation Android)
interface ISerialPort {
InputStream getInputStream();
OutputStream getOutputStream();
void openPort() throws IOException;
void closePort() throws IOException;
void setParams(int baudRate, int dataBits, int stopBits, int parity) throws IOException;
}
// Classe de simulation pour l'exemple, NE PAS UTILISER EN PRODUCTION
class MockSerialPort implements ISerialPort {
protected byte[] mockInputBuffer;
protected int readPointer = 0;
protected ByteArrayOutputStream outputBuffer = new ByteArrayOutputStream();
public MockSerialPort(byte[] simulatedInput) {
this.mockInputBuffer = simulatedInput;
}
@Override
public InputStream getInputStream() {
return new InputStream() {
@Override
public int read() throws IOException {
if (readPointer < mockInputBuffer.length) {
return Byte.toUnsignedInt(mockInputBuffer[readPointer++]);
}
// Simuler un blocage ou -1 si plus de données
try {
Thread.sleep(100); // Simuler un délai de lecture
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return -1; // End of stream or no data
}
@Override
public int read(byte[] b, int off, int len) throws IOException {
if (readPointer >= mockInputBuffer.length) {
return -1;
}
int bytesAvailable = mockInputBuffer.length - readPointer;
int bytesToRead = Math.min(len, bytesAvailable);
System.arraycopy(mockInputBuffer, readPointer, b, off, bytesToRead);
readPointer += bytesToRead;
return bytesToRead;
}
@Override
public int available() throws IOException {
return mockInputBuffer.length - readPointer;
}
};
}
@Override
public OutputStream getOutputStream() {
return outputBuffer;
}
@Override
public void openPort() throws IOException {
System.out.println("MockSerialPort: Port ouvert.");
}
@Override
public void closePort() throws IOException {
System.out.println("MockSerialPort: Port fermé. Données envoyées: " + Arrays.toString(outputBuffer.toByteArray()));
}
@Override
public void setParams(int baudRate, int dataBits, int stopBits, int parity) throws IOException {
System.out.println("MockSerialPort: Paramètres définis: " + baudRate + " bauds, " + dataBits + " bits de données, " + stopBits + " bit d'arrêt, parité " + (parity == 0 ? "aucune" : "spécifiée"));
}
}
public class SerialPortManager {
private ISerialPort serialPort; // Utilisez votre véritable implémentation ici
private InputStream inputStream;
private OutputStream outputStream;
private final String portPath;
private final int baudRate;
// Définir les types de commandes pour l'application
public static final byte COMMAND_SEND_DATA = 0x01;
public static final byte COMMAND_REQUEST_STATUS = 0x02;
public static final byte COMMAND_ACK = 0x06; // Accusé de réception
public SerialPortManager(String portPath, int baudRate) {
this.portPath = portPath;
this.baudRate = baudRate;
}
public void openConnection() throws IOException {
// En production: instanciez votre SerialPort réel ici. Ex: this.serialPort = new VotreSerialPortClass(portPath);
// Pour la démo, nous utilisons MockSerialPort pour simuler
this.serialPort = new MockSerialPort(new byte[0]); // Initialiser avec un buffer vide pour l'entrée
serialPort.openPort();
serialPort.setParams(baudRate, 8, 1, 0); // 8 bits, 1 stop bit, pas de parité
inputStream = serialPort.getInputStream();
outputStream = serialPort.getOutputStream();
System.out.println("Connexion au port série " + portPath + " établie.");
}
public void closeConnection() {
if (serialPort != null) {
try {
if (inputStream != null) inputStream.close();
if (outputStream != null) outputStream.close();
serialPort.closePort();
System.out.println("Connexion au port série fermée.");
} catch (IOException e) {
System.err.println("Erreur lors de la fermeture du port série: " + e.getMessage());
}
}
}
/**
* Envoie un message via le port série en utilisant le protocole personnalisé.
* @param commandType Le type de commande à envoyer.
* @param data La charge utile (payload) à inclure.
* @throws IOException Si une erreur d'E/S se produit.
*/
public void sendMessage(byte commandType, byte[] data) throws IOException {
byte[] frame = FrameAssembler.buildFrame(commandType, data);
if (frame != null) {
outputStream.write(frame);
outputStream.flush(); // S'assurer que les données sont écrites immédiatement
System.out.println("Message envoyé: " + Arrays.toString(frame));
} else {
System.err.println("Impossible de construire la trame pour l'envoi.");
}
}
/**
* Lit et décode la prochaine trame complète du port série.
* Cette implémentation est simplifiée : elle recherche un délimiteur de début,
* puis collecte tous les octets suivants jusqu'à ce qu'un délimiteur de fin soit trouvé ou un timeout.
* Une implémentation réelle devrait utiliser un thread dédié pour la lecture asynchrone,
* un buffer circulaire pour assembler les octets et une machine à états pour une robustesse accrue.
* @return Une DecodedFrame si une trame valide est reçue, ou null en cas d'erreur/timeout ou si aucun octet n'est lu.
* @throws IOException Si une erreur d'E/S se produit.
*/
public FrameAssembler.DecodedFrame readNextFrame() throws IOException {
ByteArrayOutputStream frameBuffer = new ByteArrayOutputStream();
int b;
long startTime = System.currentTimeMillis();
final long READ_BYTE_TIMEOUT_MS = 500; // Timeout pour la lecture d'un seul octet
final long FRAME_COMPLETE_TIMEOUT_MS = 2000; // Timeout pour assembler une trame complète après le début
// Attendre le délimiteur de début
while (System.currentTimeMillis() - startTime < FRAME_COMPLETE_TIMEOUT_MS) {
if (inputStream.available() > 0) {
b = inputStream.read();
if (b == -1) continue; // Pas de données réelles pour l'instant
if ((byte) b == FrameAssembler.START_DELIMITER) {
frameBuffer.write(b);
break; // Début de trame trouvé
}
} else {
try {
Thread.sleep(10); // Attendre que des données soient disponibles
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
}
if (frameBuffer.size() == 0) { // Pas de délimiteur de début trouvé dans le délai imparti
System.out.println("Timeout: Aucun délimiteur de début trouvé.");
return null;
}
startTime = System.currentTimeMillis(); // Réinitialiser le timeout pour le reste de la trame
// Lire le reste de la trame jusqu'au délimiteur de fin ou timeout
while (System.currentTimeMillis() - startTime < FRAME_COMPLETE_TIMEOUT_MS) {
if (inputStream.available() > 0) {
b = inputStream.read();
if (b == -1) continue;
frameBuffer.write(b);
if ((byte) b == FrameAssembler.END_DELIMITER) {
// Fin potentielle de trame, essayer de la parser
FrameAssembler.DecodedFrame decoded = FrameAssembler.parseFrame(frameBuffer.toByteArray());
if (decoded.isValid) {
System.out.println("Trame reçue: " + Arrays.toString(frameBuffer.toByteArray()));
return decoded;
} else {
// Trame invalide (ex: checksum incorrect, longueur ne correspond pas).
// Dans un scénario réel, il faudrait une gestion plus sophistiquée des erreurs
// (ex: rejeter la trame et continuer à chercher un nouveau START_DELIMITER).
// Pour cet exemple, nous réinitialisons le buffer et continuons à chercher la prochaine trame.
System.err.println("Trame reçue invalide, réinitialisation du buffer et recherche d'une nouvelle trame.");
frameBuffer.reset();
// Recommence la recherche depuis le début.
return readNextFrame(); // Récursive, mais attention au StackOverflow en cas de flux bruité continu.
}
}
} else {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
}
System.err.println("Timeout: Trame incomplète ou corrompue.");
return null;
}
// --- Méthode main pour l'exemple d'utilisation ---
public static void main(String[] args) {
String portToUse = "/dev/ttyUSB0"; // Chemin du port série (peut être un chemin virtuel pour Android)
int baud = 9600;
SerialPortManager manager = new SerialPortManager(portToUse, baud);
try {
manager.openConnection();
// 1. Exemple d'envoi de données
byte[] dataToSend = {0x10, 0x20, 0x30, 0x40};
manager.sendMessage(COMMAND_SEND_DATA, dataToSend);
// 2. Simuler une réponse du périphérique pour tester la réception
// En production, cette réponse proviendrait du périphérique réel.
byte[] responsePayload = {0x01, 0x02, 0x03, 0x04, 0x05};
byte[] simulatedResponseFrame = FrameAssembler.buildFrame(COMMAND_ACK, responsePayload);
// "Injecter" la trame simulée dans le MockSerialPort pour la lecture
if (manager.serialPort instanceof MockSerialPort) {
((MockSerialPort) manager.serialPort).mockInputBuffer = simulatedResponseFrame;
((MockSerialPort) manager.serialPort).readPointer = 0; // Réinitialiser le pointeur de lecture
}
System.out.println("\nTentative de lecture de la réponse...");
FrameAssembler.DecodedFrame receivedFrame = manager.readNextFrame();
if (receivedFrame != null) {
System.out.println("Trame reçue et décodée: " + receivedFrame);
if (receivedFrame.commandType == COMMAND_ACK) {
System.out.println("Accusé de réception (ACK) reçu avec payload: " + Arrays.toString(receivedFrame.payload));
}
} else {
System.out.println("Aucune trame valide reçue.");
}
} catch (IOException e) {
System.err.println("Erreur de communication série: " + e.getMessage());
e.printStackTrace();
} finally {
manager.closeConnection();
}
}
}