07.05.2013

Web-термометр

"Погодная станция своими руками" - вот как можно было бы назвать статью, но я решил пока просто рассказать о том, как соорудить web-термометр, подключить его к домашней LAN  и наблюдать показания через браузер ;)

Для этого нам потребуется Arduino-совместимая плата, поддержка Ethernet и несколько температурных датчиков.


По традиции, я использовал то, что оказалось под руками:
  1. Arduino-совместимая плата: Angelino с ATmega328P. Конечно, начать отлаживаться можно на любой Arduino-совместимой плате, с USB. Но в конечном итоге устройство будет жить где-то в кладовке, где ему уже не потребуется USB. Тоже самое относится и к схеме автоматического выбора питания, и к красивым перемигивающимся светодиодам на пинах RX/TX (их тоже никто не увидит). Памяти процессора ATmega328P как раз хватит и для управления Ethernet, и для считывания информации с датчиков;
  2. В качестве Ethernet-контроллераFreeduino EtherCard R1. Если нет необходимости в SD и скорости в 100Мб, вполне подойдет. Стоит он дешевле классического Ethernet+SD шилда, а поддержку SD-карт при необходимости можно добавить с помощью microSD shield;
  3. Датчики температуры - цифровые DS18B20. Работать с ними удобно, все датчики имеют уникальный идентификатор. Могут висеть на одном проводке, поэтому сколько их там будет - один, два или десять, можно практически не задумываться. 
Сразу же хочу еще раз повторить - конкретные наборы деталей я выбирал из того, что оказалось под руками. И мало того, что существует масса Arduino-совместимых плат и Ethernet-шилдов от других производителей, но особо упорные все детали могут приобрести по-отдельности и самостоятельно собрать в единое устройство - в основном, благодаря тому, что контроллер Ethernet ENC28J60 выпускается в DIP-корпусе.

Как выглядит процесс прототипизации на базе Arduino? Примерно так:

Сначала набросаем схему.


Пока всё просто - Arduino соединен с Ethernet-шилдом, обмен идет по шине SPI. Выбор устройства (он же - сигнал SS) для EtherCard находится на пине D10. Для коммуникации с сетью датчиков DS18B20 используется свободный пин D9, который в соответствии с требованиями однопроводной шины подтянут к VCC через резистор 4K7. Для питания датчиков отказываемся он паразитного режима, чтобы не усложнять себе отладку.

Настало время набросать первый скетч, в основном для проверки работы нашей схемы. Берем библиотеки EtherCard, OneWire - распаковываем их в sketchook/libraries. Если у нас изначально Angelino, на время экспериментов потребуется USB-TTL переходник (для заливки скетчей и получения отладочной информации через Serial Monitor).

Для начала определимся с сетевыми адресами нашего будущего устройства - нам нужно придумать шестибайтный MAC-адрес, он должен быть уникален в пределах сети до ближайшего роутера и его можно выбрать практически "наугад". Если все-таки есть сомнения, попробуйте понаблюдать за адресами с помощью Wireshark - отличный инструмент, чтобы покопаться в сетевых пакетах, бегающих в вашей локалке.

Далее, нам потребуется IP-адрес: на случай, если в сети не окажется сервера DHCP, прописываем статический адрес прямо в скетче. Следите, чтобы он  тоже был уникальным и желательно в одной подсети с целевым компьютером (например, если у компьютера, с которого вы будете подключаться к Arduino, IP = 192.168.0.2 с маской 255.255.255.0, можно выбрать для Arduino 192.168.0.3 или 192.168.0.222). Если же Arduino получит динамический IP-адрес, потребуется каким-то образом его узнать - по логам DHCP-сервера или, что гораздо проще, через Serial Monitor.

Второй момент - это опрос температурного датчика и вывод результатов. По счастью, Dallas Semiconductors (ныне является подразделением Maxim Integrated Products) снабжает все датчики уникальным ROM-кодом, нам остается только последовательно получить все их адреса на шине, а затем вывести полученные значения в тексте HTML-странички, которую будет генерировать на лету, при каждом обращении к web-серверу Arduino. 

Тестовый скетч выглядит так:

#include <OneWire.h>
#include <EtherCard.h>

// настройки Ethernet

#define BUF_SIZE 512

byte mac[] = { 0x00, 0x04, 0xA3, 0x21, 0xCA, 0x38 }; // MAC-адрес

byte fixed = false; // =false: пробовать получить адрес по DHCP, 
                    //         в случае неудачи использовать статический; 
                    // =true:  сразу использовать статический

uint8_t ip[] = { 169, 254, 8, 200 };     // Статический IP-адрес
uint8_t subnet[] = { 255, 255, 0, 0 };   // Маска подсети
uint8_t gateway[] = { 192, 168, 1, 20 }; // Адрес шлюза 
uint8_t dns[] = { 192, 168, 1, 20 };     // Адрес DNS-сервера (необязателен)

byte Ethernet::buffer[BUF_SIZE];
static BufferFiller bfill; 

// настройки OneWire

#define DS18B20PIN  9
OneWire ds(DS18B20PIN);

void setup(void)
{
    Serial.begin(57600);
    delay(2000);

    // Проверяем, что контроллер Ethernet доступен для работы
    Serial.println("Initialising the Ethernet controller");
    if (ether.begin(sizeof Ethernet::buffer, mac, 10) == 0) {
        Serial.println( "Ethernet controller NOT initialised");
        while (true);
    }

    // Пытаемся получить адрес динамически 
    Serial.println("Attempting to get an IP address using DHCP");
    if (ether.dhcpSetup()) {
        ether.printIp("Got an IP address using DHCP: ", ether.myip);
    } else {
        // Если DHCP не доступен, используем статический ip-адрес
        ether.staticSetup(ip, gateway, dns);
        ether.printIp("DHCP FAILED, using fixed address: ", ether.myip);
        fixed = true;
    }
}

char okHeader[] PROGMEM =
  "HTTP/1.0 200 OK\r\n"
  "Content-Type: text/html\r\n"
  "Pragma: no-cache\r\n"
;

char authorLink[] PROGMEM =
  "</pre><hr>Read about me <a href=\"http://mk90.blogspot.com\" target=\"_blank\">here</a>"
;

static void homePage (BufferFiller& buf) {
  
  buf.emit_p(PSTR("$F\r\n"
    "<title>Arduino web-thermometr</title>"
    "<h2>DS18B20 Network:</h2>"
    "<pre>"), okHeader);
  
  byte counter = 0; 
  byte addr[8]; 
  ds.reset_search();
  delay(250);
  while (ds.search(addr)) {
    buf.emit_p(PSTR("$D: "),++counter);
    for (byte i=0; i<8; i++) {  // считываем и выводим 9-байтный код
      buf.emit_p(PSTR("$H "), addr[i]);
    }
    if ( OneWire::crc8(addr, 7) != addr[7]) {
      buf.emit_p(PSTR("- CRC is not valid!"));
    } else if (addr[0] != 0x28) {
      buf.emit_p(PSTR("- is not a DS18B20 family device!"));
    }
    buf.emit_p(PSTR("\n"));
  }
  buf.emit_p(PSTR("\nTotal: $D devices."), counter);
  buf.emit_p(authorLink);
}


void loop(void)
{
  word len = ether.packetReceive();
  word pos = ether.packetLoop(len);
  if (pos) { 
    bfill = ether.tcpOffset();
    char* data = (char *) Ethernet::buffer + pos;
    Serial.println(data); // распечатываем запрос для отладки
    
    if (strncmp("GET / ", data, 6) == 0) 
      homePage(bfill);
    else
      bfill.emit_p(PSTR(
        "HTTP/1.0 401 Unauthorized\r\n"
        "Content-Type: text/html\r\n"
        "\r\n"
        "<h1>401 Unauthorized</h1>"));
        
    // отправить ответ клиенту
    ether.httpServerReply(bfill.position());
  }
}

Скачать скетч ecard_webthermo_1

После направления браузера на http://ip-адрес-arduino, мы должны увидеть ROM-номера всех найденных датчиков, например:


Возможны следующие варианты ошибок:
  1. Браузер "долго думает" и в итоге ничего не показывает. Загляните в вывод Serial Monitor-а, еще раз уточнив правильность набираемого в строке браузера адреса. Проверьте правильность подключения Ethernet Card R1 к сети. Из командной строки это можно  сделать командой ping <адрес Arduino>, визуально - по наличию зеленого и помигиванию желтого светодиода на разъеме Ethernet Card R1;
  2. В окне браузера мы видим, что датчиков не обнаружено. Увы, "наука о контактах" весьма часто сопровождает инженеров-практиков ;) Проверьте еще раз, что датчик правильно питается (верный признак ошибки - если вы обжигаетесь, прикасаясь к корпусу датчика), его DATA-пин подключен именно к тому пину, который указан в скетче (ну и ничего другого, разумеется, не подключено) и он не соединен по чистой случайности с GND или VCC;
  3. В окне браузера напротив идентификатора датчика отображается ошибка CRC. Это разновидность предыдущей ошибки подключения датчика - только ошибка может быть не только в неустойчивом контакте, но и в наводках на кабель. Еще раз проверьте контакты, в крайнем случае замените кабель небольшим кусочком провода - после завершения отладки можете экспериментировать с его длиной сколько угодно, а лучше загляните в эту статью.
Разобравшись со всеми ошибками, можно продолжить совершенствовать скетч.

Первым делом, надо добавить считывание и вывод значений температуры, путем нехитрых манипуляций, благо они подробно расписаны в документации на DS18B20.

Перед поиском датчиков даем команду  "Conversion"- преобразование показаний температуры в цифровой код. Чем точнее выполняется преобразование, тем больше требуется времени, зато команду можно дать сразу всем устройствам на шине. В цикле поиска датчиков заменяем распечатывание их адресов на команду считывания содержимого "Scratchpad" и полученный результат преобразуем в символьную строку для отображения.

Для удобства добавим функции setConversionTime и startConversionAll, а затем перепишем homePage - вместо вывода адреса, пустим его "в дело" - то есть в вычисление температуры.

// команды DS18B20

void setConversionTime(byte conf) {
  ds.reset();
  ds.skip(); // skip ROM
  ds.write(0x4E); // write scratchpad
  ds.write(0); // Th
  ds.write(0); // Tl
  ds.write(conf); // configuration
}

void startConversionAll() {
  ds.reset();
  ds.skip(); // skip ROM
  ds.write(0x44,0); // start conversion
  delay(10);
}

static void homePage (BufferFiller& buf) {
  
  buf.emit_p(PSTR("$F\r\n"
    "<title>Arduino web-thermometr</title>"
    "<h2>DS18B20 Network:</h2>"
    "<pre>"), okHeader);
  
  byte counter = 0; 
  byte addr[8]; 
  byte data[12];
  
  setConversionTime(0x7F); // установить 9-битное разрешение 
  startConversionAll(); // запустить конвертацию температуры
  delay(100); // на конвертацию 9-битного значения требуется 93,75 мс
  
  // ищем устройства и выводим результаты
  ds.reset_search();
  delay(250);
  while (ds.search(addr)) {
    buf.emit_p(PSTR("$D: "),++counter);
    if ( OneWire::crc8(addr, 7) != addr[7]) {
      buf.emit_p(PSTR("- address CRC is not valid!\n"));
      continue;
    } else if (addr[0] != 0x28) {
      buf.emit_p(PSTR("- is not a DS18B20 family device!\n"));
      continue;
    }
    ds.reset();
    ds.select(addr);
    ds.write(0xBE); // читать scratchpad
    for (byte k=0; k<9; k++) {  // нам потребуется 9 байт
      data[k] = ds.read();
    }     
    if ( OneWire::crc8( data, 8) != data[8]) {
      buf.emit_p(PSTR("- value CRC is not valid!\n"));
      continue;
    }
    buf.emit_p(PSTR(" $D.$D °C\n"), *(int *)data/16, (int) (abs(*(int *)data % 16) * 0.625));
  }
  buf.emit_p(PSTR("\nTotal: $D devices."), counter);
  buf.emit_p(authorLink);
}

Скачать скетч ecard_webthermo_2

Ну вот, теперь наш скетч стал вполне рабочим:

(полные тексты всех скетчей можно скачать по ссылке в конце статьи)

Отлично, но что делать, если хочется вместо порядкового номера видеть осмысленные местоположения проведения измерений? Допустим, "гостиная" или "балкон"?..

Тут, наверное, будет уместным заметить, что из алгоритма поиска на однопроводной шине однозначно вытекает гарантия, что для одного и того же набора датчиков последовательность их нахождения будет всегда одной и той же. Иными словами - если уж вы развесили конкретные датчики по конкретным помещениям, то - при условии, что они все работают - в процессе поиска первым всегда будет найдено одно помещение, вторым - другое, и т.д. Остается жестко прописать названия в скетче, и потом выводить их вместо номера. Например, через объявляемый в памяти программ массив locations:

// локации

prog_char string_0[] PROGMEM = "Комната";   
prog_char string_1[] PROGMEM = "Кухня";
prog_char string_2[] PROGMEM = "Балкон";

PROGMEM const char *locations[] = 
{   
  string_0,
  string_1,
  string_2
};

Директива PROGMEM предписывает компилятору размещать строковые константы в памяти программ, что одновременно обязывает использовать для обращения к ним специальные процедуры. Вот как это выглядит в замененном в homePage фрагменте, где выводится строка с названием локации:

  while (ds.search(addr)) {
    //buf.emit_p(PSTR("$D: "),++counter);
    buf.emit_p( (char*)pgm_read_word(&(locations[counter++])) );
    buf.emit_p(PSTR(": "));


Результат в окне браузера:


Кстати - русский язык существует в ArduinoIDE в виде UTF-8, поэтому для корректного отображения на платформах, для которых эта кодировка не является системной (например, Windows XP), требуется уточнить ее в  HTTP-ответе OK, изменив строки в массиве okHeader следующим образом:

char okHeader[] PROGMEM =
  "HTTP/1.0 200 OK\r\n"
  "Content-Type: text/html; charset=utf-8\r\n"
  "Pragma: no-cache\r\n"
;

Скачать скетч ecard_webthermo_3

Сокрушительно слабое место такого подхода - изъятие, замена или добавление датчика в сеть. Тут же изменяется порядок поиска и надо заново выяснять, как последовательность поиска сопоставлена с названиями локаций. Для этого, а также для изменения имени локации  надо перекомпилировать скетч и залить его в Arduino, что не всегда возможно/удобно.

Следовательно, наиболее надежно - запоминать соответствия индентификаторов датчиков и локаций. Хотя можно возложить это на специальную программу и пользоваться ей вместо браузера, но выглядит это как-то неуклюже. Гораздо проще все поручить Arduino, задействовав EEPROM - энергонезависимую память нашего MCU, которую можно перезаписывать прямо из скетча. Снабдив нашу HTML-страничку ссылками, можно по клику предлагать форму ввода нового имени, которое будет запоминаться в EEPROM. Быть может,  на первый взгляд это кажется сложным, но не забывайте - у нас в распоряжении остаются еще свыше 10К свободной памяти программ!

Для начала определим формат хранения в EEPROM - в нулевой ячейке будем хранить размер списка, а дальше - записи, состоящие из адреса (8 байт) и собственно названия (17 байт). Для удобства доступа определим макросы через #define:

// константы для EEPROM

#define EEFIRSTENTRY  1
#define EEADDRLEN  8
#define EENAMELEN  17

// макросы для доступа в EEPROM

#define LOCADDR(n) (EEFIRSTENTRY+((n)*(EEADDRLEN+EENAMELEN)))
#define LOCNAME(n) (EEFIRSTENTRY+EEADDRLEN+((n)*(EEADDRLEN+EENAMELEN)))


В самом начале EEPROM вообще ничего не хранит, и скорее всего из нулевого байта мы прочтем первый раз 0xff. Это как минимум наводит на мысль, что в setup нелишне было бы проверить, что этот байт хранит какое-то реальное значение, не "убегающее" за пределы доступного EEPROM, размер которого у ATmega328P равняется 1024 байтам:

    // считываем и анализируем EEPROM
    if ( ((EEPROM.read(0)+1)*(EEADDRLEN+EENAMELEN)+EEFIRSTENTRY) > E2END) {
      EEPROM.write(0,0); // инициализируем число записей
    }

Теперь надо добавить новую функцию searchLocationByAddress, которая будет 1) по ROM-идентификатору датчика возвращать порядковый номер найденной записи и 2) если записи не найдено, создавать такую запись и присваивать локации стандартное имя "?".

byte searchLocationByAddr(byte *addr) {
  byte total = EEPROM.read(0);
  for (byte i=0;i<total;i++) {
    byte equial = 0;
    for (byte k=0;k<EEADDRLEN;k++) {
      if (addr[k] == EEPROM.read(LOCADDR(i)+k)) equial++;
    }
    if (equial == EEADDRLEN) return i;
  }
  
  // записи не найдено, создаем еще одну - в конце
  for (byte k=0;k<EEADDRLEN;k++) {
    EEPROM.write(LOCADDR(total)+k,addr[k]);
  }
  
  EEPROM.write(LOCNAME(total),'?');
  EEPROM.write(LOCNAME(total)+1,0);
  
  EEPROM.write(0,total+1);
  return total;
}


Теперь настало время подумать о дополнительном CGI-скрипте для редактирования названия локации. Пусть он будет называться "e" - для краткости. Добавим в loop лишний оператор if, проверяющий URL в запросе:

    if (strncmp("GET / ", data, 6) == 0) 
      homePage(bfill);
    else if (strncmp("GET /e", data, 6) == 0)
      editPage(data, bfill);
    else
      bfill.emit_p(PSTR(
        "HTTP/1.0 401 Unauthorized\r\n"
        "Content-Type: text/html\r\n"
        "\r\n"
        "<h1>401 Unauthorized</h1>"));

Теперь добавим ссылку в вывод на основной странице - в функции homePage:

    counter++;
    byte idx = searchLocationByAddr(addr); // возвращает номер локации в eeprom
    buf.emit_p(PSTR("<a href=\"/e?n=$D\">$E</a>: "), 
      idx, LOCNAME(idx));

Тут надо немного пояснить аргументы buf.emit_p: подобно функциям семейства printf, она формирует строку по шаблону (первый параметр), подставляя в него все последующие (второй параметр и далее). В шаблоне можно указывать следующие типы данных:

  • $D - числовая константа;
  • $S - строковая константа;
  • $F - строковая константа, расположенная в PROGMEM (т.е в памяти программ);
  • $E - строковая константа, расположенная в EEPROM (что мы и использовали).

Наконец, сама функция редактирования. Манера ее написания покажется до боли знакома web-программистам: генерация формы или запоминание значения, переданного через GET:

static void editPage (const char* data, BufferFiller& buf) {
  if (data[6] == '?') {
    char buf2[96];
    if (EtherCard::findKeyVal(data+6, buf2, sizeof(buf2)-1, "n")) {
      byte n = atoi(buf2);
      if (!EtherCard::findKeyVal(data+6, buf2, sizeof(buf2)-1, "s")) {
        // генерировать форму
        buf.emit_p(PSTR("$F\r\n"
        "<h3>Edit location name</h3>"
        "<form>"
         "<p>"
         "Change name: <input type=text name=b value='$E' size=8>"
         "<input type=hidden name=n value=$D>"         
         "<input type=submit name=s value=set>"
         "</p>"
        "</form>"), okHeader, LOCNAME(n), n); 
        return;
      } else {
        // cохранить новое название локации
        byte len = EtherCard::findKeyVal(data+6, buf2, sizeof(buf2)-1, "b");
        if (len) { 
          EtherCard::urlDecode(buf2);
          for (byte i=0;i<(strlen(buf2)+1);i++) 
            EEPROM.write(LOCNAME(n)+i,buf2[i]);
        }
      }
    }
  }  
  buf.emit_p(PSTR(
        "HTTP/1.0 302 found\r\n"
        "Location: /\r\n"
        "\r\n"));  
}

Другая вспомогательная функция  EtherCard::findKeyVal служит для поиска параметра в строке GET. Она анализирует цепочку символов запроса в поисках названия параметра, а затем копирует в буфер значение после знака равенства. Функция EtherCard::urlDecode приводит кодированные для передачи через URL символы типа %20 к нормальному виду.


Скачать скетч ecard_webthermo_4

Скачать код примеров всех четырех скетчей одним архивом, вместе с библиотеками можно здесь. Разумеется, это всего лишь учебный пример, который перед использованием в реальной жизни (а уж тем более, на для извлечения коммерческой выгоды), должен быть доработан. Надо не только исправить ошибки (которые всегда есть), но и обратить внимание на ряд моментов:

  • Внимательнее манипулировать EEPROM - например, добавить проверки при исчерпании свободного места и подумать о возможности удаления из таблицы названий локаций неиспользуемых записей;
  • Возможно, стоит отказаться от хранения числа записей в нулевой ячейке EEPROM - производитель предупреждает, что в случае сбоев она пострадает первой; 
  • Для любителей русских названий может вызвать, например, удивление тот факт, что при максимальной длине в 17 символов, ввести можно только 8. Повторно обращаю внимание на использование UTF-8 - каждая русская буква в этом наборе занимает 2 байта, поэтому либо пишите по-английски, либо рассмотрите переход на однобайтовое кодирование - например, с помощью KOI8-R или CP1251;
  • Ограничением библиотеки EtherCard является необходимость умещать страничку в одном пакете tcp. Если размер html-кода будет расти, это может вызвать проблемы. Поэтому, старайтесь соблюдать минимализм в записи HTML - даже если это не соответствует стандартам. Разумеется, можно доработать код библиотеки ;)
Что можно было бы еще сделать? Можно добавить новые типы датчиков. Можно добавить внешнюю flash-память (например, подключаемую по i2c) и читать оттуда предварительно записанные favicon или css-стили, чтобы украсить страничку. Можно добавить базовую авторизацию, чтобы кто попало не имел доступ к информации или не мог редактировать названия локаций.

Будем считать это домашним заданием для тех, кто хочет довести свою конструкцию до совершенства - которое, конечно же, недостижимо ;)

Кстати, уже почти после написания статьи наткнулся на замечательный блог lucadentella.it, который ведет Luca Dentella, разбирая разные приемы работы с EtherCard под тегом ENC28J60.

9 комментариев:

  1. Тепло у вас там, черт возьми =)

    А мысли собрать не на ардуине не было?

    ОтветитьУдалить
  2. Спасибо за труды, то что нужно, правда попробую еще немного допилить ваш скетч под свои нужды.

    ОтветитьУдалить
    Ответы
    1. ОК, если будет чем поделиться с общественностью - милости прошу...

      Удалить
  3. Если сюда еще добавить реле шилд, то можно сделать девайс который сможет удаленно нажать на компьютере кнопку reset или удерживать несколько секунд кнопку power, а заодно и температуру мерить в серверной.

    ОтветитьУдалить
    Ответы
    1. Да, такой вариант возможен! Но для профессионального применения я бы посоветовал iLo...

      Удалить
  4. Здравствуйте, такая проблема возникла - не отображается отрицательная температура, вместо этого отображается 65534 к примеру.
    Как решить проблему?

    ОтветитьУдалить
    Ответы
    1. Надо исправить ошибку в отображении. Массив data[] можете огласить (все девять значений), который дает такой результат?

      Удалить
    2. Добрый день!
      У меня такая же ситуация
      229 255 0 0 127 255 11 16 125 65535.6 °C

      Удалить
  5. Добрый вечер...
    В чём может быть проблема? при -5 показывает 65530.5 °C

    ОтветитьУдалить