Friday, February 24, 2012

Измерение температуры или освоение 1-wire протокола на примере DS18S20

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

Но описывать протокол, хоть и я и считаю его достаточно крутым, представляется мне весьма скучным занятием. Скажу лишь, что это протокол передачи данных в обе стороны между устройствами всего по одному проводу, и при этом на этом проводе могут висеть больше двух устройств. В общем - протокол как минимум интересный. Меня вообще вдохновило его изучение на многие идеи, которые, будем надеяться, мне удастся реализовать. Для освоения протокола я приведу несколько ссылок, по которым сам разбирался с ним - ребята молодцы и достаточно подробно описали все нюансы. Ссылки будут в конце поста.

Мы же перейдем сразу к более практической части и поподробнее пройдемся по коду.

DS18S20
За этой замысловатой маркировкой таиться датчик температуры от Dallas Semiconductor (на самом деле компания уже называется MAXIM, но DS в маркировке обозначаем именно Dallas Semiconductor -  вот все по привычке так и говорят).
Датчик привлекателен как минимум по одной причине - он достаточно дешевый (мой обошелся мне чуть больше 2$), цифровой и очень простой в использовании.

Соберем схему
Схема простая как грабли. Наверное проще только подключение обычного светодиода (хотя и это действие потребовало бы определенных знаний). В общем, прекращаю разговоры - вот схема:
Как видно, все очень и очень просто. Всего то сам датчик (кстати, он находится в точно таком же корпусе, как и транзисторы которые есть у меня), подано к нему питание в +5V, земля GND,  линия связи с контроллером и закреплен один подтягивающий резистор между линией связи и питанием +5V. Резистор должен быть номиналом 4,7кОм.
Эта "подтяжка" необходима для того, чтобы на линии связи автоматически подтягивалась логическая единица в случае, если контроллер не задал иного. Дело в том, что так работает протокол - фактически мы можем выставлять либо "ноль" на линии связи, либо хоть вообще её отключить - логическая единица подтянется через резистор.

Код
Прежде всего стоит сказать - для упрощения работы с протоколом 1-wire я буду использовать библиотеку OneWire. Она позволит нам абстрагироваться от "железной" реализации протокола и работать с достаточно понятным интерфейсом. Библиотеку скачать можно тут, там же находиться и короткая справка по методам класса шины OneWire.
Для примера я несколько модифицировал код идущий в комплекте с библиотекой. Переделка коснулась всех частей которые относятся к другим датчикам из этого семейства, я оставил только DS18S20, чтобы сделать более видной суть самого процесса.
#include <OneWire.h>

byte wirePin = 8;

OneWire  ds(wirePin);

void setup(void) {
  Serial.begin(9600);
}

void loop(void) {
  byte addr[8];
  if ( !ds.search(addr)) {
    Serial.println("No more addresses.");
    Serial.println();
    ds.reset_search();
    delay(250);
    return;
  }

  byte i;
  Serial.print("ROM =");
  for( i = 0; i < 8; i++) {
    Serial.write(' ');
    Serial.print(addr[i], HEX);
  }

  if (OneWire::crc8(addr, 7) != addr[7]) {
      Serial.println("CRC is not valid!");
      return;
  }
  Serial.println();
 
  if (addr[0]==0x10){
    Serial.println("  Chip = DS18S20");
  } else {
    Serial.println("Device is not a DS18S20.");
    return;
  }
  
  ds.reset();
  ds.select(addr);
  ds.write(0x44);        
  
  delay(750);

  ds.reset();
  ds.select(addr);    
  ds.write(0xBE);     
  
  byte data[9];
  Serial.print("  Data = ");
  for ( i = 0; i < 9; i++) {
    data[i] = ds.read();
    Serial.print(data[i], HEX);
    Serial.print(" ");
  }
  
  if (OneWire::crc8(data, 8) != data[8]) {
      Serial.println("Data CRC is not valid!");
      return;
  }
  
  int16_t raw = (((int16_t)data[1]) << 8) | data[0];
  
  float celsius, fahrenheit;
  celsius = (float)(raw >> 1) - 0.25 + 
            ((float)(data[7] - data[6]) / (float)data[7] );
  fahrenheit = celsius * 1.8 + 32.0;
  
  Serial.print("  Temperature = ");
  Serial.print(celsius);
  Serial.print(" Celsius, ");
  Serial.print(fahrenheit);
  Serial.println(" Fahrenheit");
}
Разобью ка я код на составные части и расскажу о каждой из них отдельно. Я буду опускать описание стандартных функций loop и setup Arduino, я про них уже где-то писал и не повторять же честное слово каждый раз одно и то же.
#include <OneWire.h>

byte wirePin = 8;

OneWire ds(wirePin);  
Подключаем библиотеку OneWire к нашему проекту. Напомню, для этого надо разархивировать  скачанный отсюда  архива сохранить в папку libraries нашей Arduino IDE, тогда библиотека будет доступна проекту.
После библиотеки создаем переменную которая будет хранить номер нашего порта, на котором будет висеть шина. 
И в последней строчке создаем объект ds класса OneWire, единственным параметром конструктора передаем номер того самого пина, на котором у нас находиться шина.
void setup(void) {
  Serial.begin(9600);
}
Открываем серийный порт и устанавливаем ему скорость в 9600 bps. Собственно этот порт будет использоваться, чтобы передавать информацию между Arduino и компьютером к которому она подключена.
  byte addr[8];
  if ( !ds.search(addr)) {
    Serial.println("No more addresses.");
    Serial.println();
    ds.reset_search();
    delay(250);
    return;
  }
addr - это массив байтов, в котором будет храниться адрес устройства, с которым мы на этом шаге собираемся работать. Такой адрес имеют все 1-wire устройства, он уникален и именно по нему мы можем "наладить" соединение с конкретным устройством в линии (как я уже говорил, на одной линии может быть большое количество устройств). Кстати, первый байт этого адреса указывает на тип устройства. Дальше еще будет использоваться как первый байт, так и весь адрес целиком.
Дальше тут вызывается метод нашего класса search(...) - этот метод ищет следующие устройство на шине (не буду вдаваться в подробности как он это делает), если такое имеется, то метод возвращает true, если же больше устройств на линии не осталось, то он вернет false. Помимо этого, если адрес все же нашелся - он будет сохранен в массиве addr.
Еще здесь обрабатывает случай, если адресов больше не осталось - тогда в серийный порт прокидывается соответствующее сообщение, а потом сбрасывается поиск.
  byte i;
  Serial.print("ROM =");
  for( i = 0; i < 8; i++) {
    Serial.write(' ');
    Serial.print(addr[i], HEX);
  }
Просто выводиться серийный номер устройства полученный нами на предыдущем шаге. Выводиться побайтно, каждый байт записывается в HEX (шестнадцатеричный формат) и  отделен он предыдущего пробелом.
  if (OneWire::crc8(addr, 7) != addr[7]) {
    Serial.println("CRC is not valid!");
    return;
  }
Вот таким вот простым способом проверяется CRC (Cyclic redundancy check). Дело в том, что в ходе передачи данные могли быть искажены. Да-да, такое бывает, данные исказились. Бывает из-за плохого контакта, длинных проводов, недостаточного тока от подтягивающего резистора и многого другого.
  if (addr[0]==0x10){
    Serial.println("  Chip = DS18S20");
  } else {
    Serial.println("Device is not a DS18S20.");
    return;
  }
Я уже говорил, в первом байте адреса находится код устройства. Все DS18S20 будут содержать одинаковый первый байт, тогда как остальные будут отличаться. Для DS18S20 - это число 16, ну или 0x10, если записывать в шестнадцатеричной форме. Если же устройство не является DS18S20, то мы просто выходим из функции loop, так как дальнейшие действия будут просто бессмысленны для какого-то другого устройства.
Почему так часто используется шестнадцатеричная форма записи числа?
Мне  кажется, для удобства. Часто приходиться работать с байтами, которые можно записать двумя символами в шестнадцатеричной форме, коротко и по делу. Помимо этого сразу становится понятно, где числа для математических вычислений, а где коды и команды внутри программы.
Любое действие по протоколу 1-wire  с устройством, чей адрес известен сводится к нескольким простым действиям:
  1. На линию отправляется сигнал reset. На железном уровне - на линию устанавливается логический ноль на определенный промежуток времени;
  2. Отправляется команда MATCH ROM (код 0x55), которая принимается всеми устройствами на линии. После этой команды, все устройства будут ожидать последовательности байт со адресом;
  3. Отправляется адрес конкретного устройства. После этого, все остальные устройства чей адрес не был "назван" на линии уходят в stand by до следующего сигнала reset.
  4. Отправляется код некоторой команды выбранному устройству;
  5. (необязательный) Получаем информацию от устройства. 
Более обобщенный алгоритм (не только для устройств с известным уже адресом) можно найти в статьях по ссылкам ниже. Главное помнить: сигнал reset, потом какая-то команда всем устройствам на линии, дальше уже по обстоятельствам.
  ds.reset();
  ds.select(addr);
  ds.write(0x44);
Вот собственно и реализация этого самого алгоритма. Начинается все с reset, после чего метод select() выполняет второй и третий пункты, дальше отправляем конкретный код устройству. Для того, чтобы понять, что выполняет этот код - заглянем в спецификацию нашего устройства. Она гласит:
CONVERT T [44h]
This command initiates a single temperature conversion. Following the conversion, the resulting thermal data is stored in the 2-byte temperature register in the scratchpad memory and the DS18S20 returns to its low-power idle state. If the device is being used in parasite power mode, within 10μs (max) after this command is issued the master must enable a strong pullup on the 1-Wire bus for the duration of the conversion (tCONV) as described in the Powering the DS18S20 section. If the DS18S20 is powered by an external supply, the master can issue read-time slots after the Convert T command and the DS18S20 will respond by transmitting 0 while the temperature conversion is in progress and 1 when the conversion is done. In parasite power mode this notification technique cannot be used since the bus is pulled high by the strong pullup during the conversion.
Если коротко, то эта команда инициализирует считывание данных о температуре с датчика и записи их в какой-то там участок памяти устройства. После этого, мы сможем их получать сколь угодно долго (одно и то же значение), до тех пор, пока не вызовем эту команду снова.
Раз уж упоминалось в спецификации: 1-wire устройства умеют питать не только напрямую от источника питания, а так же могут делать это напрямую от линии связи - называется это паразитивное (или фантомное) питание. Наша схема обеспечена питанием на прямую, так что больше про эту возможность рассказывать не буду.
  delay(750);
Просто ждем 750мс пока наш датчик успеет считать данные и записать их в память.
  ds.reset();
  ds.select(addr);    
  ds.write(0xBE);
Снова действуем по алгоритму. Отправляем reset на линию, выбираем устройство и отправляем ему команду, но только в этот раз уже 0xBE. Чтобы понять, что она делает, снова обратимся к спецификации:
READ SCRATCHPAD [BEh]
This command allows the master to read the contents of the scratchpad. The data transfer starts with the least significant bit of byte 0 and continues through the scratchpad until the 9th byte (byte 8 – CRC) is read. The master may issue a reset to terminate reading at any time if only part of the scratchpad data is needed.
Переводит датчик в состояние, в котором он будет отправлять значения из памяти. Для улучшения понимания - датчик не начнет неистово отправлять данные на шину, он будет ждать когда каждый конкретный байт у него "попросит" наш контроллер, т.е. все равно "руководить" процессом будет наша Arduino.
Далее процесс самого чтения, благодаря библиотеке OneWire он становится очень даже простым в понимании:
  byte data[9];
  for ( i = 0; i < 9; i++) {
    data[i] = ds.read();
    Serial.print(data[i], HEX);
    Serial.print(" ");
  }
В массиве data будут храниться все наши данные полученные от датчика температуры. Как было написано выше в выдержке из спецификации, таких байтов будет 9-ть. Помимо того, что мы запишем данные в наш массив data, мы еще и выведем их на серийный порт в шестнадцатеричном виде через пробел. Нужно это по большому счету только для того, чтобы иметь возможность увидеть, что же такое прочитал с датчика наш контроллер. Возможно пригодиться в дебаге.
  if (OneWire::crc8(data, 8) != data[8]) {
      Serial.println("Data CRC is not valid!");
      return;
  }
Снова проверяем CRC, так как мы снова получали данные от датчика. Немного распишем, что содержится в этом массиве data, для этого опять же можно обратиться к документации датчика.
Как видно, Byte 0 и Byte 1 содержит информацию о температуре, а конкретно старший и младший байты показаний датчика. С их помощью них можно получить информацию о температуре с точность 0,5 градуса. Побитное описание этих двух байтов снова таки нас любезно представляет спецификация DS18S20
Для того, чтобы получить более точные данные о температуре, необходимо будет еще получить и данные из Byte 6 и Byte 7. Снова обратимся к документации:
Resolutions greater than 9 bits can be calculated using the data from the temperature, COUNT REMAIN and COUNT PER °C registers in the scratchpad. Note that the COUNT PER °C register is hard-wired to 16 (10h). After reading the scratchpad, the TEMP_READ value is obtained by truncating the 0.5°C bit (bit 0) from the temperature data (see Figure 2). The extended resolution temperature can then be calculated using the following equation:
А теперь нам остается это реализовать. Вот тут лично у меня было много проблем, но в итоге я разобрался. Попытаюсь пояснить построчно.
  int16_t raw = (((int16_t)data[1]) << 8) | data[0];
В этой строчке мы склеиваем два байта в один 16 битовый int, причем int signed, а это значит, что первый бит в нем будет битом знака. Для этого мы приводим старший байт к такому же int, после чего смещаем его биты на 8 позиций влево, как бы освобождая правые 8 бит под наш младший байт. После чего мы добавляем младший бит в этот наш int. Фактически, после этих операций у нас уже будет данные о температуре, но с точностью 0,5. Для того, чтобы ей получить, можно просто разделить этот int на 2.
Но мы хотим получить большую точность, максимальную которую может выдать нам датчик. Для этого применим формулу из спецификации (см. выше).
  float celsius, fahrenheit;
  celsius = (float)(raw >> 1) - 0.25 + 
            ((float)(data[7] - data[6]) / (float)data[7] );
Для этого, необходимо "обрезать" последний бит в нашем raw. После этого, просто подставляем значения из памяти в формулу. Обратите внимание на несколько приведений - это особенности языка C, иначе мы потеряем много данных.
Дальше переведем все это дело еще в фаренгейты, просто для тренировки.
  fahrenheit = celsius * 1.8 + 32.0;
Ну и выводим все это на серийный порт.
  Serial.print("  Temperature = ");
  Serial.print(celsius);
  Serial.print(" Celsius, ");
  Serial.print(fahrenheit);
  Serial.println(" Fahrenheit");
Да вот собственно и все. Не так страшен этот датчик, как может показаться на первый взгляд. Надеюсь было интересно. И на последок - видео.

Ссылки:
  • Сам протокол коротко и по делу расписан тут. Мне это описание показалось наиболее лаконичным.
  • Более подробная информация может быть найдена на РадиоКоте. Тоже настоятельно рекомендую прочитать.