Cet article a pour but de présenter une solution .Net écrite en C# permettant de communiquer avec un contrôleur Z-Wave.
Cet article fait directement suite à celui présentant le protocole Z-Wave.
Le code de l’application est disponible en dernière partie de l’article.
Je l’ai délibérément simplifié au maximum pour comprendre le fonctionnement des échanges. La réalisation d’une application réelle nécessiterait une meilleure architecture (découpage en couche notamment) et une gestion des exceptions.
Rappels sur le fonctionnement du protocole Z-Wave
La communication avec les modules Z-Wave se fait via l’échange de trame au format hexadécimal. Pour cela, il est nécessaire de passer pour un module se branchant sur le PC qui sera vu comme un port série par Windows.
Le format d’une trame est le suivant :
Le matériel
J’ai bien sûr un PC (Windows 10) avec Visual Studio 2015 d’installé.
Pour le Z-Wave, j’ai acquis un stick USB Z-Wave de la marque Vision Security pour de chez Vision Security pour 35 €.
C’est moins connu que les grosses marques, mais c’est 2 fois moins cher et il marche très bien.
L’installation est assez simple puisqu’il suffit de brancher le stick sur un port UBS pour voir apparaître un nouveau port COM dans le gestionnaire de périphériques.
Besoin
Nous allons écrire un simple client C# pour communiquer avec notre stick USB.
Les exigences seront les suivantes :
- Pourvoir émettre des données au format hexadécimal vers le contrôleur via une IHM
- Pouvoir lire les données envoyées par le contrôleur au format hexadécimal
API de communication avec le port série
Le Framework .Net fournit depuis ses débuts une API pour communiquer avec le port série. A noter qu’aucune API n’est disponible dans le Framework .Net Core pour communiquer avec le port COM. Il faut obligatoirement utiliser le Frameowk Full, je serais en version 4.6.
La documentation de la classe « System.IO.Ports.SerialPort » nous donne les informations nécessaires.
- Constructeur
- SerialPort(string name) : instancier un objet pour un port série nommé (« COM1 », « COM2 » …)
- Méthodes
- static string[] GetPortNames() : obtenir la liste des noms des ports séries disponibles
- void Open() : ouvre la connexion avec le port série
- void Write(byte[] message, int start, int end) : écrit une plage d’octet d’un tableau
- void Close() : fermer la connexion avec le port série
- Evènement
- DataReceived : lancé lorsque des données sont dans le buffer de lecture. Dans une solution utilisable réellement, je préfère appeler la méthode « void Read(byte[] buffer, int start, int end) de manière récursive. En effet, j’ai remarqué de l’événement n’est pas forcément levé ou met du temps. Ce n’est pas très performant.
- Propriétés
- bool IsOpen : déterminer si le port est ouvert ou non
- int BytesToRead : obtenir le nombre d’octets qui peuvent être lus
Réalisation
Nous allons créer une application Windows en XAML / C# pour communiquer simplement avec le contrôleur Z-Wave.
Partie IHM
Dans Visual Studio, nous pouvons créer une nouvelle application WPF nommée « ZWaveGUI ».
La fenêtre principale contient les éléments minimums pour répondre à notre besoin.
Elle comporte :
- Une liste qui contiendra les ports séries disponibles
- Un champ texte permettant d’écrire notre message avec un bouton envoyer
- Un champ texte en lecture seule détaillant les échanges
Je ne décris pas plus la création de la fenêtre qui est disponible dans le code source, ce n’est pas l’objet de cet article.
Partie code
Liste des ports séries disponibles
Nous commençons par afficher la liste des ports séries disponibles dans la liste en complétant le constructeur de la classe « MainWindow ».
J’appelle simplement la méthode statique « GetPortNames » qui renvoie la liste des noms des ports séries disponibles. Puis, je sélectionne le premier élément de cette liste.
public MainWindow()
{
InitializeComponent();
// Display serial port list in "cbComPorts"
cbComPorts.ItemsSource = SerialPort.GetPortNames().ToList();
if (cbComPorts.Items.Count > 0) cbComPorts.SelectedIndex = 0;
}
Connexion et déconnexion au port série
Nous pouvons ensuite établir la connexion avec le port série qui sera sélectionné dans la liste des ports séries disponibles.
La connexion au port série se fait en liant l’évènement « SelectionChanged » de la liste avec une méthode de connexion.
- Elle ferme la connexion au port série si elle est ouverte
- Elle récupère le nom du port sélectionné dans la liste générant l’évènement
- Elle connecte le port série
- Elle attache l’événement de réception des octets qui est décrit plus bas dans l’article
En plus, nous ajoutons une autre méthode liée à l’événement de fermeture de la fenêtre.
- Elle ferme la connexion au port série si elle est ouverte
private SerialPort Port { get; set; }
private void cbComPorts_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
// Close serial port connexion if necessary
if (Port != null && Port.IsOpen)
{
Port.Close();
}
var list = (ComboBox) sender;
if (list.SelectedValue != null)
{
// Get port name
var portName = (string)list.SelectedValue;
if (!string.IsNullOrEmpty(portName))
{
// Create port and open connexion
Port = new SerialPort(portName);
Port.DataReceived += Port_DataReceived;
Port.Open();
}
}
}
private void Window_Closing(object sender, CancelEventArgs e)
{
// Close serial port connexion if necessary
if (Port != null && Port.IsOpen)
{
Port.Close();
}
}
Envoi des messages
L’envoi des messages se fait via un champ texte limité aux caractères hexadécimaux.
Cette limite est crée en ajoutant une méthode pour l’événement « PreviewTextInput » de notre champ texte.
- Elle valide que le texte écrit à un format hexadécimal
private void tbMessage_PreviewTextInput(object sender, TextCompositionEventArgs e)
{
int hexNumber;
e.Handled = !int.TryParse(e.Text, NumberStyles.HexNumber, CultureInfo.CurrentCulture, out hexNumber);
}
Il nous reste à lier l’événement « Click » du bouton à notre méthode d’envoi.
- Elle vérifie que le port est bien ouvert
- Elle transforme les données saisies en tableau d’octet (les données sont séparées par des espaces)
- Elle ajoute l’octet du checksum (XOR entre les octets 2 à N et appliquer un NOT sur le résultat)
- Elle écrit le tableau d’octet dans le port série
- Elle trace l’envoi dans le champ texte des échanges
private static byte GenerateChecksum(List<byte> data)
{
var result = data[1];
for (var i = 2; i < data.Count; i++)
{
result ^= data[i];
}
result = (byte)(~result);
return result;
}
private void btnSend_Click(object sender, RoutedEventArgs e)
{
// Check that serial port is ready
if (Port != null && Port.IsOpen)
{
// Convert to text to byte array
var values = tbMessage.Text.Split(' ');
var message = new List<byte>();
foreach (var hexStr in values)
{
int hexInt;
if (int.TryParse(hexStr, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out hexInt))
{
message.Add((byte) hexInt);
}
}
// Add checksum end byte for data frames
if (message.Count > 0 && message[0] == 0x01) message.Add(GenerateChecksum(message));
// Write array and log
Port.Write(message.ToArray(), 0, message.Count);
tbExchanges.Text += "Message envoyé : ";
message.ForEach(x => tbExchanges.Text += x.ToString("X") + " ");
tbExchanges.Text += "\r\n";
}
}
Réception des messages
La lecture des données reçues se fait via la méthode « Read » du port série. Le mieux est de l’appeler de manière régulière dès qu’un message est envoyé.
Pour notre exemple, je vais utiliser l’événement « DataReceived » du port série qui est levé quand des octets sont en attente de lecture. Cet événement a été attaché à la connexion du port série.
Plusieurs méthodes sont nécessaires à la réception du message. En effet, les octets lus dans le port série ne représente pas un message Z-Wave complet mais seulement une fraction. Il est donc nécessaire de compléter le message au fur et à mesure des réception.
Pour cela, nous avons 2 informations importantes :
- Le premier octet du message est le type de message (0x01 : frame de données, 0x06 : acquittement positif et 0x015 : acquittement négatif)
- Le second octet est la taille totale de la frame – 2
Tout d’abord, nous devons déclarer 2 propriétés.
- Une représentant le buffer des octets lus, j’utilise un type « Queue » qui est de type First In First Out (FIFO) pour gérer l’ordre des octets
- Une représentant le message en cours
private Queue<byte> ReadBuffer { get; } = new Queue<byte>();
private List<byte> CurrentMessage { get; set; } = new List<byte>();
Ensuite, nous pouvons écrire une méthode qui lit les octets dans le port série.
private byte[] ReadBytes(SerialPort port)
{
byte[] result;
if (port.IsOpen)
{
// create the buffer with the number of bytes to read
result = new byte[port.BytesToRead];
if (result.Length > 0)
{
port.Read(result, 0, result.Length);
}
}
else
{
result = new byte[0];
}
return result;
}
Puis, nous pouvons écrire la méthode qui va servir à créer un message Z-Wave complet.
Cet méthode fonctionne comme suit :
- Si le message est nouveau alors il est vide
- Nous récupérons le type de la frame
- Si le message est une frame de données (0x01), alors
- Si le message comporte 1 octet, nous récupérons la taille du message
- Ensuite, nous récupérons les octets depuis le buffer tant que la taille du message n’a pas été atteint ou que le buffer soit vidé
private List<byte> BuildMessage(Queue<byte> readBufer, List<byte> currentMessage)
{
// if current message is empty : add frame type
if (currentMessage.Count == 0)
{
currentMessage.Add(readBufer.Dequeue());
}
// If the frame is a data frame
if (currentMessage.First() == 0x01)
{
// Get the frame size
if (currentMessage.Count == 1 && readBufer.Count >= 1)
{
currentMessage.Add(readBufer.Dequeue());
}
// Compute bytes while frame size is not good
for (var i = 0; i < readBufer.Count && currentMessage.Count != currentMessage[1] + 2; i++)
{
currentMessage.Add(readBufer.Dequeue());
}
}
return currentMessage;
}
Enfin, pour terminer, il nous reste à écrire les méthodes gérant l’événement émis par le port série.
- Elle lit les octets depuis le port série
- Elle construit le message Z-Wave
- Si le message est complet (frame data avec taille vérifiée ou acquittement), alors il est affiché dans le champ des échanges.
- La méthode gérant l’événement de réception des octets boucle tant que le buffer de lecture n’est pas vide
private void ProcessReceivedBytes()
{
// Build the current message
if (ReadBuffer.Count > 0) CurrentMessage = BuildMessage(ReadBuffer, CurrentMessage);
// Message is complete if size + 2 equals message size
byte[] result;
if (CurrentMessage.Count > 1 && CurrentMessage.Count == CurrentMessage[1] + 2)
{
result = CurrentMessage.ToArray();
}
// If message size is 1 and byte is 0x06 or 0x15, then message is acknowlegment.
else if (CurrentMessage.Count == 1 && (CurrentMessage[0] == 0x06 || CurrentMessage[0] == 0x15))
{
result = CurrentMessage.ToArray();
}
else
{
result = new byte[0];
}
// if message is valid, display it
if (result.Length != 0)
{
CurrentMessage = new List<byte>(); // reset current message
Action action = delegate
{
if (result[0] == 0x15) tbExchanges.Text += "Acquittement négatif reçu : ";
else if (result[0] == 0x15) tbExchanges.Text += "Actquitement posifitf reçu : ";
else tbExchanges.Text += "Trame reçue : ";
result.ToList().ForEach(x => tbExchanges.Text += x.ToString("X") + " ");
tbExchanges.Text += "\r\n";
};
Application.Current.Dispatcher.Invoke(action);
}
}
private void Port_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
// Read bytes from serial port
ReadBytes((SerialPort)sender).ToList().ForEach(x => ReadBuffer.Enqueue(x));
// process while bytes are in buffer
while (ReadBuffer.Count != 0)
{
ProcessReceivedBytes();
}
}
Notre code est maintenant fini.
Si vous avez des problèmes à le faire fonctionner, vous pouvez télécharger le code source que j’ai utilisé.
Tests de l’application
Nous allons pouvoir envoyer notre première trame Z-Wave !
Saisissez la trame : 1 3 0 20
- Le header 1 signifie qu’il s’agit d’une trame de données
- La taille de la trame est 3 +2 = 5 (il n’y a que 4 octets dans la trame que je vous ai donné, mais le checksum est calculé par l’application et rajouté au message)
- Le type de requête est 0 pour un « get »
- La commande 20 demande l’identifiant unique (appelé « home id ») du contrôleur
- Le checksum qui sera calculé par l’application est DC
Un premier résultat 6 est reçu. Cela signifie que le contrôleur a validé notre message.
Le second résultat est notre retour de demande de home id, pour mon contrôleur j’ai : 1 8 1 20 DC 8C 2B 32 1 9E
- Le header 1 signifie qu’il s’agit d’une trame de données
- La taille de la frame de données est de 8 octets
- Le type de requête 1 est le résultat du « get »
- La commande est bien 20
- La valeur de l’identifiant de mon contrôleur est « DC 8C 2B 32 » car il est sur 4 octets (ce n’est pas indiqué dans la trame, il faut le savoir)
- L’avant dernière valeur 1 est l’identifiant du module dans le réseau Z-Wave
- La dernière valeur est le checksum
Pour terminer, vous pouvez envoyer une trame « 6 » pour indiquer au contrôleur que vous avez bien reçu une trame correcte.
Conclusion
Vous savez maintenant comment communiquer avec un contrôleur.
Dans les prochains articles, nous verrons les différentes commandes intéressantes du contrôleur, obtenir la liste des modules du réseau notamment. Puis, nous pourrons voir comment interagir avec ces modules.