Hlídač napájení a teploty s Arduinem

31.10.2015

Intro

Arduino a celý ekosystém s ním spojený je v současné době velkým fenoménem. Jedni ho oslavují, druzí nenávidí. Já jsem názorově asi tak někde uprostřed, neboť pro mě je Arduino v podstatě jen AVRko a C++ překladač v takovém stavu, že ho stačí nainstalovat a vše funguje. Bonusem je řada knihoven, HW periferií a velká komunita lidí a tím i živá diskuzní fóra. Arduino je v zásadě hračka, ale docela fajn hračka, protože je možné si začít hrát hned a není třeba trávit spoustu času tím, že k rozblikání LEDky potřebuji ještě nějaký programátor, avrdude, avrgcc, makefile a prostudovat datasheet MCU. Souhlasím s názorem, že to vede k jisté otupělosti, kdy člověk dostane vše předžvýkáno až pod nos a vše funguje bez nějaké větší snahy - ale na druhou stranu - řídí snad auta jen automechanici? :-)

Takže jsem si pořídil Arduino Uno a Ethernet Shield. Jedná se o čínské klony a jelikož čínský soudruh je tvor pragmaticky spořivý, má UNO o několik součástek méně a okolo USB konektoru převodník CH340 místo FTDI, resp. Megy16.

O co jde?

V podstatě je to jednoduchá blbost :-) Situace je taková, že v serverovně (racku se switchi apod.) jsou dva okruhy napájení - dejme tomu dvě samostatně jištěné fáze. Na tyto okruhy jsou připojeny UPS, klimatizace apod. Cílem tohoto hlídače je dát vědět dohledovému systému v situaci, kdz dojde k výpadku nějakého napájecího okruhu. Důvodem je, že provoz serverovny bez klimatizace je zpravidla nemožný a samotné UPS vydrží většinou na šetrný shutdown. Dohled samozřejmě mohou dělat samotné UPSky, ale jsou situace, kdy to prostě nějde, neboť v racku není UPSka s ethernetovým rozhraním a zároveň není po ruce nějaké železo schopné si s UPS povídat přes USB nebo RS232.

Schéma sice řekne nejvíc, ale několik poznámek: Základem je Arduino Uno s ethernet shieldem. Tzn. komunikace s dohledovým systémem je po ethernetu tak, že dohledový systém se periodicky Arduina doptává, zda-li je všechno v pořádku. Detekce, zda-li je v jednotlivých okruzích napětí je řešeno tak, že do každého okruhu je zapojen malý adaptér 5V/1A (cca. 110,-Kč v GME). Toto řešení je zvoleno proto, že je tak zajištěno napájení i pokud je živý pouze jeden okruh. Zároveň se v zařízení nepracuje se silnoproudem - ten končí na certifikovaném adaptéru, z něhož vede pouze bezpečné napětí. Dále je k dispozici jeden externí vstup (např. pro nějaké čídlo zaplavení, otevření racku apod.) a vstup pro one-wire teploměr DS18B20. Propojení s Arduinem je 5-ti dráty do piny A0-A4 a napájení.


Arduino sketch

Ovládací "program" je složen z 5-ti souborů:

Komunikace je primitivní. Stačí se připojit telnetem, Arduino vrátí jeden řádek se stavy okruhů, stavu externího vstupu a teploty. Následně spojení ukončí.

telnet 10.210.100.1

Arduino-Square-Box:LineUPS-ON|LineA-ON|LineB-ON|ExtIn-OPEN|Thrm-1249#

Připojení k hostiteli bylo ztraceno.
Teplota je vrácena ve formě unsigned integeru, tedy vzorcem (t*10)+1000. V případě výše uvedeného odpovídá 1249 teplotě 24.9°C.

Sketch ctverec.ino

#include <SPI.h>
#include <Ethernet.h>
#include "inkey.h"
#include "OneWire.h"
#include <avr/wdt.h>
#include "utility/w5100.h"

#define READ_TEMPERATURE 0
#define EXEC_CONVERSION 1

byte mac[] = { 0xDE, 0xAD, 0xBE, 0xEF, 0xFE, 0xED };
IPAddress ip(10, 210, 100, 1);
IPAddress myDns(10, 210, 1, 10);
IPAddress gateway(10, 210, 1, 1);
IPAddress subnet(255, 255, 0, 0);

EthernetServer server(23);

inkey extInput(A0);
inkey lineUPS(A1);
inkey lineA(A2);
inkey lineB(A3);

OneWire  ds(A4);

unsigned int thrmBuffer[2];
unsigned int thrmCounter = 0;


// -- nastaveni --
void setup() {
  wdt_enable(WDTO_4S);
  Ethernet.begin(mac, ip, myDns, gateway, subnet);
  server.begin();
  Serial.begin(115200);
  Serial.print("IP address: ");
  Serial.println(Ethernet.localIP());
  readThrm(EXEC_CONVERSION);
  delay(1000);
  thrmBuffer[0] = readThrm(READ_TEMPERATURE);
  thrmBuffer[1] = thrmBuffer[0];
}


// -- pracovni smycka --
void loop() {
  unsigned char i = 0;
  byte remoteip[4];

  // -- obsluha sitoveho pripojeni --
  server.available();
  while (i < 4) {  
    EthernetClient client(i);
    if (client.connected()) {
      Serial.print("New connection from ");
      W5100.readSnDIPR(i, remoteip);
      Serial.println(IPAddress(remoteip));
      client.print("Arduino-Square-Box:");
      if (lineUPS._state) client.print("LineUPS-OFF|"); else client.print("LineUPS-ON|");
      if (lineA._state) client.print("LineA-OFF|"); else client.print("LineA-ON|");
      if (lineB._state) client.print("LineB-OFF|"); else client.print("LineB-ON|");
      if (extInput._state) client.print("ExtIn-OPEN|"); else client.print("ExtIn-CLOSE|");
      client.print("Thrm-");
      client.print((thrmBuffer[0] + thrmBuffer[1]) / 2, DEC);
      client.print("#");
      client.println();
      client.stop();
    }
    i++;
  }

  // -- obsluha smycek --
  if (extInput.beat()) {
    if (extInput._state) Serial.println("ExtIn OPEN"); else Serial.println("ExtIn CLOSE");
  }
  if (lineUPS.beat()) {
    if (lineUPS._state) Serial.println("LineUPS OFF"); else Serial.println("LineUPS ON");
  }
  if (lineA.beat()) {
    if (lineA._state) Serial.println("LineA OFF"); else Serial.println("LineA ON");
  }
  if (lineB.beat()) {
    if (lineB._state) Serial.println("LineB OFF"); else Serial.println("LineB ON");
  }

  // -- obsluha teplomeru --
  thrmCounter++;
  if (thrmCounter == 1180) readThrm(EXEC_CONVERSION);
  if (thrmCounter >= 1200) {
    thrmBuffer[1] = thrmBuffer[0];
    thrmBuffer[0] = readThrm(READ_TEMPERATURE);
    thrmCounter = 0;
    Serial.print("Read new temperature: ");
    Serial.println(thrmBuffer[0]);
  }

  delay(50);
  wdt_reset();
}


// -- spusti prevod nebo nacteni cidla --
unsigned int readThrm(unsigned char action) {
  unsigned char data_lo, data_hi;
  signed int t;
  unsigned int tout = 0;
  switch (action) {
    case EXEC_CONVERSION:
      ds.reset();
      ds.write(0xCC);
      ds.write(0x44);
      break;
    case READ_TEMPERATURE:
      ds.reset();
      ds.write(0xCC);
      ds.write(0xBE);
      data_lo = ds.read();
      data_hi = ds.read();
      t = ((data_hi << 8) | data_lo) * 0.625;
      tout = t + 1000;
      if ((tout < 500) || (tout > 1500)) tout = 0;
  }
  return (tout);
}

Třída inkey

inkey.h

#ifndef inkey_h
#define inkey_h

#include <Arduino.h>

class inkey {

  public:
    inkey(unsigned char pin);
    unsigned char beat();
    unsigned char _state;

  private:
    unsigned char _pin;
    unsigned char _counter = 0;

};

#endif

inkey.cpp

#include <Arduino.h>
#include "inkey.h"


inkey::inkey(unsigned char pin) : _pin(pin) {
  pinMode(_pin, INPUT_PULLUP);
}

unsigned char inkey::beat() {
  unsigned char somethingChanged = 0;
  if (digitalRead(_pin)) {
    if (_counter < 20) _counter++;
    if (_counter == 15) {
      somethingChanged = 1;
      _state = 1;
    }
  } else {
    if (_counter) _counter--;
    if (_counter == 5) {
      somethingChanged = 1;
      _state = 0;
    }
  }
  return(somethingChanged);
} 

 

Ethernet knihovna

Toto je obvyklé použití Ethernet Library, které je možné najít na webu Arduina jako example. Je to sice jednoduché, funguje to, ale podlě mě dosti nešťastně.

#include <SPI.h>
#include <Ethernet.h>

byte mac[] = { 0xDE, 0xAD, 0xBE, 0xEF, 0xFE, 0xED };
IPAddress ip(10, 210, 100, 1);
IPAddress myDns(10, 210, 1, 10);
IPAddress gateway(10, 210, 1, 1);
IPAddress subnet(255, 255, 0, 0);

EthernetServer server(23);

// -- nastaveni --
void setup() {
  Ethernet.begin(mac, ip, myDns, gateway, subnet);
  server.begin();
}

// -- pracovni smycka --
void loop() {
  EthernetClient client = server.available();
  if (client) {
    if (client.available() > 0) {
      // zde je cteni dat pomoci client.read
      // nebo odpoved pomoci client.print apod.
    }
  }
}

Vadí mi především toto:

Knihovna pro ethernet je zde c:\Program Files\Arduino\libraries\Ethernet\src\ a bude se jednat o úpravy v souborech EthernetClient.cpp a EthernetClient.h. Upravovat samotné knihovny mě přijde dost zhovadilé, ale i přes veškerou snahu se mi nepodařil najít způsob, jak v C++ přidat do existující třídy nějakou další metodu bez zásahu do hlavičkového souboru a nebo jak se ve třídě, kterou z existující podědím dostat k privátním atributům a metodám třídy rodičovské.

Jak zjistit ID klienta (socketu)

Ve třídě EthernetClient je číslo aktuálně používaného socketu v privátní proměnné _sock. Řešením je udělat z privátní proměnné proměnnou veřejnou nebo napsat veřejnou metodu na vrácení její hodnoty. Rozhodl jsem se pro druhou variantu, protože mi příjde přehlednější.

Do EthernetClient.h přidat do sekce public prototyp metody getSockID()

  ....
  public:
   ....
   uint8_t getSockID();
   ....

Do EthernetClient.cpp přidat implementaci metody getSockID()

uint8_t EthernetClient::getSockID() {
  return(_sock);
}

Použití:

....
// -- pracovni smycka --
void loop() {
  // promena pro ulozeni id klienta
  unsigned char idClient;
  // ziskani instance klienta
  EthernetClient client = server.available();
  if (client) {
    // zjisteni hodnoty _sock, tedy cislo socketu
    idClient = client.getSockID();
    // nyni vim, ktery vzdaleny klient poslal data
    // idClient bude cislo 0-3
    if (client.available() > 0) {
      // zde je cteni dat pomoci client.read
      // nebo odpoved pomoci client.print apod.
    }
  }
}

Jak killnout spojení

Důvodem pro záměrné přerušení spojení může být fakt, že vzdálený klient neposlal data v nějakém rozumeném časovém intervalu. Nemá smysl čekat věčnost a přitom si blokovat drahocenný socket a tím i možnost připojení dalších klientů. Pokud tedy na základě předchozí úpravy vím, se kterým socketem si povídám, tak si mohu měřit timeout a podle něj pak spojení odstřelovat. Praxi se mi odsvědčil přístup takový, že jsem si vytvořil čtyři časovače, které se nulovaly vždy po příchozu nějakého znaku z daného socketu a pokud znak dlouho nepřišel, tak kill.

Do EthernetClient.h přidat do sekce public prototyp metody kill(uint8_t sock)

  ....
  public:
   ....
   void kill(uint8_t sock);
   ....

Do EthernetClient.cpp přidat implementaci metody kill(uint8_t sock)

void EthernetClient::kill(uint8_t sock) {
  // attempt to close the connection gracefully (send a FIN to other side)
  disconnect(sock);
  unsigned long start = millis();

  // wait up to a second for the connection to close
  uint8_t s;
  do {
    s = socketStatus(sock);
    if (s == SnSR::CLOSED)
      break; // exit the loop
    delay(1);
  } while (millis() - start < 1000);

  // if it hasn't closed, close it forcefully
  if (s != SnSR::CLOSED)
    close(sock);

  EthernetClass::_server_port[sock] = 0;
}

Použití:

....
// 4 citace v poli
unsigned char timer[4] = {0,0,0,0};
....
// -- pracovni smycka --
void loop() {
  unsigned char i;
  EthernetClient client = server.available();
  if (client) {
    if (client.available() > 0) {
      // nastavovani citace na max hodnotu 60*50ms, tj. 3 sekundy
      timer[client.getSockID()] = 60;
      // zde je cteni dat pomoci client.read
      // nebo odpoved pomoci client.print apod.
    }
  }

  // vyhodnoceni timeoutu  
  i=4;
  while(i--) {
    if(timer[i]>0) timer[i]--;       // odecitani citacu smerem k nule
    if(timer[i]==1) client.kill(i);  // pokud je citac na hodnote 1, tak kill
  }
  
  //50ms krok
  delay(50); 
}

Welcome message

Řešením může být to, že se vybodneme na metodu server.available() a budeme si instance třídy Ethernet Client vytvářet sami. Argumentem konstruktoru této třídy je totiž číslo socketu 0-3. Metoda connected() nám řekne, zda-li je k socketu někdo připojen.

....
// 4 citace v poli
unsigned char timer[4] = {0,0,0,0};
// 4 flagy aktivniho spojeni, aby se welcome message poslala jen jednou
unsigned char connected[4] = {0,0,0,0};
....
// -- pracovni smycka --
void loop() {
  unsigned char i = 0;
  server.available();
  
  // krokovani po socketech
  while (i < 4) {  
    // vytvoreni instance klienta
    EthernetClient client(i);
    // dotaz, zda-li je ve W5100 aktualni socket pripojeny ke vzd. klientovi
    if (client.connected()) {
      // uvitaci zprava a nastaveni flagu o pripojeni
      if(connected[i]==0) {
        // welcome message
        client.print("Arduino-Square-Box:");
        // nastaveni flagu o aktivnim spojeni
        connected[i]=1;
        // spusteni timeoutu, aby se killnulo i spojeni, kde vzd. klient neposlal zadny znak
        timer[i] = 60;
      }      
      // cteni dat
      if (client.available() > 0) {
        // nastaveni timeoutu
        timer[i] = 60;
        // zde je cteni dat pomoci client.read
        // nebo odpoved pomoci client.print apod.
      }
    } else {
      // pokud neni spojeni aktivni, tak nastavime flag pripojeni na 0
      connected[i]=0;
    }
    i++;  
  }

  // vyhodnoceni timeoutu  
  i=4;
  while(i--) {
    if(timer[i]>0) timer[i]--;    // odecitani citacu smerem k nule
    if(timer[i]==1) {             // pokud je citac na hodnote 1, tak kill
      client.kill(i); 
      connected[i]=0;
    }
  }

  delay(50);
}

Zjištění adresy vzdáleného klienta

Toto lze udělat dvěma způsoby. První je bez úpravy Ethernet knihovny a to vložením utility/w5100.h do include a zavoláním W5100.readSnDIPR(i, remoteip), kde i je číslo socketu a remoteip je pointer na pole 4 bytes. Výstupem této metody je pole remoteip naplněné IP adresou vzdáleného klienta. Pro snadné zformátování pro tisk (nejen) na sériový port je použita třída IPAddress.

  // vlozeni tridy pro ovladani W5100
  #include "utility/w5100.h"
  ...
  // -- pracovni smycka --
  void loop() {
    unsigned char i = 0;
    // pole 4 byte pro ulozeni IP adresy
    byte remoteip[4];
    // -- obsluha sitoveho pripojeni --
    server.available();
    while (i < 4) {  
      EthernetClient client(i);
      if (client.connected()) {
        Serial.print("New connection from ");
        // zjisteni IP vzdaleneho klienta na socketu i do remoteip
        W5100.readSnDIPR(i, remoteip);
        // tisk IP na seriovy port
        Serial.println(IPAddress(remoteip));
  ...    

Když už se však zasahuje do Ethernet knihovny, je lepší variantou přidat metodu na zjištění vzdádlené adresy přímo do ní a to takto:

Do EthernetClient.h přidat do sekce public prototyp metody getRemoteIP()

  ....
  public:
   ....
   IPAddress getRemoteIP();
   ....

Do EthernetClient.cpp přidat implementaci metody getRemoteIP()

IPAddress EthernetClient::getRemoteIP() {
  byte rip[4];
  W5100.readSnDIPR(_sock, rip);
  return rip;
}

Použití je pak jednodušší:

  // -- pracovni smycka --
  void loop() {
    unsigned char i = 0;
    // -- obsluha sitoveho pripojeni --
    server.available();
    while (i < 4) {  
      EthernetClient client(i);
      if (client.connected()) {
        Serial.print("New connection from ");
        // zjisteni a tisk IP vzd. klienta na seriovy port
        Serial.println(client.getRemoteIP());
  ...    

Ke stažení

Zdrojáky a jiné soubory