Monitorowanie wejść i wyjść centrali Satel Integra w Node.js

Pisałem ostatnio o połączeniu Raspberry Pi z Integrą produkcji Satela z użyciem interfejsu szeregowego. Przyszła pora, żeby napisać coś więcej o tym przedsięwzięciu od strony programowania - zarówno samej centrali alarmowej, jak i Raspberry Pi - w Node.js, bo jego używa obecnie główna część mojego “inteligentnego domu”. Node.js świetnie nadaje się do obsługi systemu opartego na zdarzeniach, ale oczywiście każdy może przełożyć zamieszczony tu kod na swój ulubiony język.

Ekspander wejść Satel CA64E

Tytuł wpisu wyraźnie wskazuje na to, że będziemy monitorować także wejścia, tymczasem opis protokołu opublikowany przez Satela wskazuje, że łatwo możemy dostać się wyłącznie do stanu wyjść (przez komendę 0x17). Oczywiście to żaden problem, ponieważ centrala pozwala definiować wirtualne wyjścia, których stan zależy od stanu wejść (czyli np. czujek ruchu). Możemy więc na bieżąco mieć podgląd wszystkich czujek (w końcu system automatyki domowej ma swoje “oczy”), ale też sprawdzać wiele więcej - stan zasilania, akumulatora, uzbrojenia i wywołania alarmu, sabotażu, przyciśnięci przycisków w pilotach ABAX, rozpoczęcie odliczania czasu na wyjście, alarmy wstępne, itp.

Definiowanie wyjść Satel Integra

Dla sprawdzania stanu wybranej czujki, definiujemy typ wyjścia jako 14 - naruszenie wejścia i wybieramy o którą czujkę (lub grupę, gdy nie zależy nam na takiej dokładności) nam chodzi. Czas działania (okres, przez który wyjście jest aktywne po wykryciu ruchu) określamy na wystarczająco długi, żeby nie przegapić zmiany i dość krótki, żeby móc obserwować kolejne naruszenia. Na początek testowo wybrałem 2 sekundy przy zapytaniach do centrali co sekundę. Potem być może wydłużę te czasy.

Mamy więc zdefiniowane wyjścia, które zmieniają się zależnie od stanu czujek. Celem na dzisiaj jest załączanie listew led w kuchni po wykryciu ruchu (na czas zależny od miejsca wykrycia ruchu) oraz wysłanie e-maila z informacją gdy zniknie (i zostanie przywrócone) zasilanie w sieci 230V. Pierwsze zadnie realizujemy wysyłając do sterownika listew LED (oparty na ESP8266, może kiedyś opiszę) odpowiedni pakiet UDP. Drugie zadanie sprowadza się do uruchomienia odpowiedniego skryptu wysyłającego pocztę - resztą zajmuje się system SMTP na Raspberry Pi. Jak można wysłać e-maila bez prądu? Centrala alarmowa ma swój akumulator, a “malina” i infrastruktura dostępu do sieci podłączona jest do zasilaczy buforowych, co gwarantuje długie działanie bez prądu.

Patrząc na protokół dostarczony przez firmę Satel dochodzi się do wniosku, że w pierwszej kolejności trzeba  napisać jakąś klasę do obsługi ramek wysyłanych do centrali i sprawdzania tych, które z niej wróciły. Nie jest to przesadnie trudne zadanie, bo wszystko jest świetnie opisane w dokumentacji, a protokół jest prosty. Na pewno istotne jest wyliczanie kodu CRC według podanego na końcu algorytmu.

// CLASS -------------------------

function SatFrame()
{
 this.param=new Array();
 this.frame=null;
}

var p = SatFrame.prototype;

// METHODS -----------------------

// dodaje kolejny bajt do ramki (bajty danych, nie kontrolne)
p.addParam = function(ch)
{
 this.param.push(ch);
}

// zwraca ramkę jako tablicę
p.getFrame = function()
{
 this.makeFrame();
 return this.frame;
}

// zwraca ramkę jako bufor
p.getFrameAsBuffer = function()
{
 this.makeFrame();
 var buf=new Buffer(this.frame, 'binary');
 return(buf);
}

// wpisuje całą ramkę jako tablicę (do użycia w celu sprawdzenia crc)
p.setFrame = function(fr)
{
 this.frame=fr.slice(0);
}

// sprawdza czy zgadza się CRC pełnej, gotowej ramki
p.checkCrc = function()
{
 var crcbegin=this.frame.length-4;
 if( this.frame[crcbegin]==0xfe ) crcbegin--;
 if( this.frame[crcbegin-1]==0xfe ) crcbegin--;
 var params=this.frame.slice(2, crcbegin);
 var crc=this.frame.slice(crcbegin);
 var calculatedcrc=crc16(params);

 if( (calculatedcrc>>8&0xff)!=crc[0] ) return(false);
 if( crc[0]!=0xfe && (calculatedcrc&0xff)!=crc[1] ) return(false);
 else if( crc[0]==0xfe && (calculatedcrc&0xff)!=crc[2] ) return(false);
 return(true);
}

function rl(x)
{
 return ((x<<1) | (x>>15)) & 0xFFFF;
}

// kalkuluje crc przy założeniu, że 0xfef0 ma być traktowane jako 0xfe
function crc16(param)
{
 var crc=0x147a;
 for(w=0; w<param.length; w++)
 {
 crc=rl(crc);
 crc=crc ^ 0xffff & 0xffff;
 crc=crc+(crc>>8 & 0xff)+param[w];
 if( param[w]==0xfe && param[w+1]==0xf0 ) w++;
 }
 return(crc);
}

// tworzy ramkę łącznie z crc i zamianą 0xfe na 0xfef0
p.makeFrame = function()
{
 this.frame=new Array();
 this.frame.push(0xfe);
 this.frame.push(0xfe);
 for( w=0; w<this.param.length; w++ ) 
 { 
 this.frame.push(this.param[w]); 
 if( this.param[w]==0xfe ) this.frame.push( 0xf0 );
 }
 var crc=crc16(this.param);
 this.frame.push((crc>>8) & 0xff);
 if( ((crc>>8) & 0xff)==0xfe ) this.frame.push( 0xf0 );
 this.frame.push(crc & 0xff);
 if( (crc & 0xff)==0xfe ) this.frame.push( 0xf0 );
 this.frame.push(0xfe);
 this.frame.push(0x0d);

}

// EXPORTS -----------------------

exports.SatFrame = SatFrame;

Mając gotową klasę można pomyśleć o komunikacji. Wpierw jednak trzeba wiedzieć jak użyć klasy i stworzyć pierwszą ramkę do wysłania. Niech to będzie zapytanie o stan wyjść. Użyjemy klasy, stworzymy nowy obiekt klasy, dodamy komendę 0x17 oraz zbudujemy ramkę i wyślemy ją do centrali.

var SatFrameClass = require("./satframe");
var sf=new SatFrameClass.SatFrame();
sf.addParam(0x17);
var fr=sf.getFrameAsBuffer();
serialPort.write(fr);

Powyższe pokazuje, że użycie klasy jest bardzo proste, jednak trzeba też umieć obsłużyć transmisję przez port szeregowy. Nie będziemy wyważać otwarty drzwi i użyemy gotowej biblioteki . Zbudujmy więc kompletny program realizujący zamierzone funkcje.

var SerialPort = require("serialport");
var SatFrameClass = require("./satframe");
var dgram = require('dgram');
var http = require('http');
var child_process = require('child_process');

var bufsize=1024;
var buf=new Buffer(bufsize);
var bufpos=0;

var outputs=new Array;
var outputschange=new Array;

// tworzymy zapytanie o stan wyjść
var sf=new SatFrameClass.SatFrame();
sf.addParam(0x17);

//inicjujemy tablice stanów i zmian wyjść
//numery wejść w cenrali są numerowane od 1 do 128 (w przypadku Integry 128)
for( w=1; w<=128; w++ )
{
 outputs[w]=0;
 outputschange[w]=0;
}

// uruchamiamy nasłuch portu szeregowego
serialListener();

// cykliczne zapytania o stan wyjść
setInterval( function() 
{ 
 var fr=sf.getFrameAsBuffer();
 serialPort.write(fr);
}, 1000 );

function serialListener()
{
 var receivedData = "";
 serialPort = new SerialPort.SerialPort("/dev/ttyAMA0", {
 baudrate: 19200,
 dataBits: 8, 
 parity: 'none', 
 stopBits: 1, 
 flowControl: false, 
 });
 
 serialPort.on("open", function () {
 console.log('open serial communication');

 serialPort.on('data', function(data) { 
 if(bufpos+data.length>bufsize) bufpos=0;
 for(w=0; w<data.length; w++ )
 {
 buf[bufpos]=data[w];
 bufpos++;
 }
 oldbufpos=bufpos;
 while( (bufpos=parseSatel(buf, bufpos))!=oldbufpos ) oldbufpos=bufpos;

 }); 

 });
}

// szuka pełnej ramki i dba, żeby bufor się nie przepełnił
function parseSatel(b, size)
{
 var begin=-1;
 var end=-1;
 var frame=new Array;

 for( w=0; w<size && end==-1; w++ )
 {
 if( b[w]==0xfe && b[w+1]==0xfe ) begin=w;
 if( b[w]==0xfe && b[w+1]==0x0d ) end=w+1;
 }

 if( end!=-1 ) // jest pelna ramka
 {

 //przepisujemy gotowa ramke
 q=0;
 for(w=begin; w<=end; w++)
 {
 frame[q]=b[w];
 q++;
 } 

 //przepisujemy reszte na poczatek bufora
 q=0;
 for(w=end+1; w<size; w++)
 {
 b[q]=b[w];
 q++;
 } 

 runFrame(frame);
 return(q);
 }

 return(size);

}

// uruchamia akcje zwiazana z ramka, gdy to potrzebne
function runFrame(f)
{

 var tm = new Date();

 // sprawdzimy czy crc ramki się zgadza, jak nie to ignorujemy
 var test=new SatFrameClass.SatFrame();
 test.setFrame(f);
 if( !test.checkCrc() )
 {
 console.log("Niepoprawny CRC dla ramki: "+f); 
 return;
 }

 if( f[2]==0x17 ) //wyjscie
 {
 for( w=0; w<128; w++ )
 {
 out=f[3+Math.floor(w/8)]&(Math.pow(2,(w%8)));
 if( out>0 ) out=1;
 outputschange[w+1]=0;
 if( out!=outputs[w+1] ) { 
 outputschange[w+1]=1; 
 console.log(tm+" zmiana "+(w+1)+"="+out); 
 }
 outputs[w+1]=out;
 }

 if( outputs[99]==1 && outputschange[99]==1 ) { sendLed("1"); }
 if( outputs[100]==1 && outputschange[100]==1 ) { sendLed("2"); }
 if( outputs[91]==1 && outputschange[91]==1 ) { console.log("A tu moglibyśmy zareagować na wciśnięcie przycisku pilota."); }
 if( outputs[84]==1 && outputschange[84]==1 ) { child_process.exec('/sciezka/mailsend "Brak zasilania centrali"'); }
 if( outputs[84]==0 && outputschange[84]==1 ) { child_process.exec('/sciezka/mailsend "Zasilanie centrali przywrocone'); }
 }
}

// wysłanie polecenia do sterownika LED
function sendLed(msg)
{
 var message = new Buffer(msg);
 var client = dgram.createSocket("udp4");
 client.send(message, 0, message.length, 80, "192.168.1.11", function(err) {
 client.close();
 });
}

Program robi to co było zaplanowane - otwiera port, uruchamia cykliczne zapytania o stan wyjść. Przy nadejściu danych dopisuje je do bufora i sprawdza czy są w nim gotowe ramki. Gdy są gotowe ramki wybiera pierwszą a resztę przepisuje na początek bufora. Dla gotowej ramki sprawdza czy jej suma kontrolna jest poprawna. Jeżeli tak, to dla odpowiedzi 0x17 sprawdza stan, których wyjść się zmienił, loguje na konsolę wszystkie zmiany (czas, numer wyjścia i nowy stan), a w przypadku gdy zmieniło się któreś z istotnych wyjść - reaguje. W przypadku dwóch czujek ruchu w kuchni wysyła polecenie do sterownika LED, a w przypadku zaniku zasilania, wysyła e-mail przez gotowy skrypt.

Skuteczne i stabilne, acz wymagające jeszcze nieco dopracowania. Nie ma np. sprawdzania czy połączenie szeregowe zostało zamknięte. W przypadku problemów z jego odtworzeniem także powinna zostać wysłana informacja o problemach. Warto byłoby też logować zmiany według nazw lub funkcji wyjść a nie ich numerów. Pewne drobiazgi pominąłem, bo i tak będą potrzebne jeszcze pewne zmiany przy włączaniu modułu do działającego już serwera automatyki domowej (także działającego w Node.js), a chciałem, żeby kod był możliwie przejrzysty.

Dalsza rozbudowa współpracy centrali alarmowej z systemem inteligentnego domu, w moim przypadku, będzie sprowadzała się głównie do korzystania z informacji pochodzących z czujników centrali. Można sobie wyobrazić wiele nowych funkcji uzyskanych dzięki tym informacjom - zamykanie rolet po uzbrojeniu alarmu, wyłączanie świateł w domu po załączeniu alarmu, mruganie wszystkimi światłami w domu gdy alarm zostanie wywołany, blokowanie możliwości otwarcia bram po uzbrojeniu alarmu, załączanie symulacji obecności domowników, powiadomienie o powrocie dziecka ze szkoły i wiele więcej. Możliwości są duże, wszystko zależy od potrzeb i chęci ich realizacji.