imageКазалось бы, каждый, кто осваивает ардуино, первым делом конструирует или повторяет прибор для измерения температуры и(или) прочих параметров окружающей среды. Только большинство подобных конструкций, к сожалению, мало применимы в домашнем хозяйстве — в качестве тренировки сгодится, а пользы нет. Попробуем исправить эту недоработку. В статье расскажу о создании комплекса для измерения и хранения любых данных на примере сбора показаний датчиков температуры, влажности воздуха и атмосферного давления. Начну с требований к прибору и описания протокола обмена, закончу web-службой для получения данных из БД. Подробных выкладок и пошаговых руководств не будет, но будет немного теории и много кода.


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

После обдумывания проблемы получилось следующее ТЗ:

  1. Датчики должны быть цифровыми, с приемлемой точностью. Температурные — DS1820, влажности — DHT22, давления воздуха — BMP085. Выбор датчиков был обусловлен их наличием “в закромах”. Кстати, функция измерения температуры есть во всех этих трех типах, но использовать будем именно DS1820, поскольку, их можно включать параллельно.
  2. Датчики эти должны подключаться «на лету», т.е. не требовать при этом вмешательства оператора.
  3. Контроллер, к которому должны подключаться датчики, должен быть доступным. Мой выбор пал на Arduino, ибо стоит недорого и имеет минимальный уровень вхождения.
  4. Контроллер должен подключаться к компьютеру посредством последовательного порта. Будем использовать USB2Serial адаптер, как распространенное и недорогое решение.
  5. Так как контроллер может располагаться на некотором удалении от компьютера, и контроллеров на одном порту может быть несколько, в протоколе обмена должны быть предусмотрены защита от искажения данных и возможность адресации приборов.
  6. Комплекс должен хранить в базе данных историю всех измерений. Мой выбор — SQLite.
  7. Все программы для работы с контроллером должны быть переносимыми, т.е. одинаково работать на разных платформах без серьезных доработок. Мой выбор — Python 2.7.

Описание протокола обмена между компьютером и контроллером


Так как обмен данными с контроллером будет асинхронным, с пакетами заранее неизвестной длины, за основу канального протокола был взят SLIP. Это протокол, в котором передача данных осуществляется при помощи SLIP-кадров. Границами SLIP-кадра является флаг END (0xC0). Если внутри кадра встречается байт 0xC0, он заменяется ESC-последовательностью 0xDB, 0xDC, а если встречается байт ESC (0xDB), он заменяется последовательностью (0xDB, 0xDD). Обратное преобразование симметричное.

В SLIP-кадры будем оборачивать сообщения с заранее рассчитанной контрольной суммой.

Для расчета контрольной суммы был применен алгоритм CRC16 с полиномом 0xA001 (modbus):

  1. В 16-битовый регистр(CRC) загружается 0xFFFF.
  2. Первый байт сообщения складывается по ИСКЛЮЧАЮЩЕМУ ИЛИ с содержимым регистра CRC. Результат помещается в регистр CRC.
  3. Регистр CRC сдвигается вправо на 1 бит, старший бит заполняется 0.
  4. (Если младший бит 1): Содержимое CRC складывается по ИСКЛЮЧАЮЩЕМУ ИЛИ с полиномиальным числом 0xA001.
  5. Шаги 3 и 4 повторяются восемь раз.
  6. Шаги 2 — 5 повторяются для всех последующих байтов посылки.
  7. Финальное содержание регистра CRC и есть контрольная сумма.

CRC добавляется в конец сообщения в формате сначала младший байт, потом старший байт.

Прикладной протокол обмена


Формат запроса к прибору


адрес_прибора(1 байт) класс(1 байт) [метод(1 байт)] [данные(N байт)]

Формат ответа прибора:

адрес_прибора(1 байт) данные(N байт)

класс 0 (PING)


возвращает 0x55 0xAA 0x55 0xAA

класс 1 (INFO)


методы


0 — запрос количества датчиков температуры
возвращает: (unsigned char)количество
1 — запрос показаний и серийных номеров с датчиков темературы
возвращает: ((float)температура (8 bytes)sernum)*количество датчиков
2 — запрос показания с датчика давления
возвращает: (int32_t)давление (char)sernum
3 — запрос показания с датчика влажности
возвращает: (float)влажность (byte)sernum
Классов всего два, но читатель, если пожелает, сможет по образу и подобию расширить протокол требуемыми параметрами.

Пример


Запрос


Адрес прибора — 00
Класс — 00 (PING)
Контрольная сумма — 01 B0
Итоговая посылка — C0 00 00 B0 01 C0

Ответ


Ответ прибора — C0 00 55 AA 55 AA C3 AA C0
Адрес прибора — 00
Контрольная сумма — AA C3
Сообщение — 55 AA 55 AA (ответ на PING)

Схема прибора




Фото макетки




Тут фото промежуточного варианта с поддержкой экрана от Нокии (код есть в репозитории по ссылке в конце статьи).

Фото готового изделия






Исходный код:


Скетч для ардуино
#include <DallasTemperature.h>
#include <Adafruit_BMP085.h>
#include <OneWire.h>
#include <DHT.h>

#define ONE_WIRE_BUS 10
#define TEMPERATURE_PRECISION 9

#define DHTPIN 2
#define DHTTYPE DHT22

OneWire oneWire(ONE_WIRE_BUS);
DallasTemperature sensors(&oneWire);
Adafruit_BMP085 bmp;
const unsigned char MAXNUMBERS = 10;
DeviceAddress addresses[MAXNUMBERS];
unsigned char numbers;
DHT dht(DHTPIN, DHTTYPE);
char readbuf[50];
char writebuf[130];
char tmpbuf[50];
int msglen = 0;

const int bufLength = 8;
const char SLIP_END = '\xC0';
const char SLIP_ESC = '\xDB';
const char SLIP_ESC_END = '\xDC';
const char SLIP_ESC_ESC = '\xDD';
const char CS_PING = '\x00';
const char CS_INFO = '\x01';
const char LOC_ADR = '\x00';

int transferData(char *buf, unsigned char cnt)
{
  Serial.print(SLIP_END);
  for (int i = 0; i < cnt; i++)
  {
    switch (buf[i])
    {
      case SLIP_END:
        Serial.print(SLIP_ESC);
        Serial.print(SLIP_ESC_END);
        break;
      case SLIP_ESC:
        Serial.print(SLIP_ESC);
        Serial.print(SLIP_ESC_ESC);
        break;
      default:
        Serial.print(buf[i]);
        break;
    }
  }
  Serial.print(SLIP_END);
}

unsigned short getCRC(char *buf, unsigned char cnt)
{
  unsigned short temp, temp2, flag;
  temp = 0xFFFF;
  for (int i = 0; i < cnt; i++)
  {
    temp ^= (unsigned char) buf[i];
    for (int j = 1; j <= 8; j++)
    { 
      flag = temp & 0x0001;
      temp >>= 1;
      if (flag)
        temp ^= 0xA001;
    }
  }
  temp2 = temp >> 8;
  temp = (temp << 8) | temp2;
  temp &= 0xFFFF;
  return temp;
}

int addCRC(char *buf, unsigned char cnt)
{
  unsigned short crc = getCRC(buf, cnt);
  memcpy(&buf[cnt], &crc, 2);
  return cnt + 2;
}

void setup()
{
  Serial.begin(9600);
  bmp.begin();
  sensors.begin();
  dht.begin();
}

void loop()
{
  float humidity = dht.readHumidity();
  int32_t pressure = (int32_t)(bmp.readPressure() / 133.3224);
  numbers = 0;
  for (int i = 0; i < MAXNUMBERS; i++)
  {
      if (!sensors.getAddress(addresses[i], i))
      break;
      numbers++;
  }
  for (unsigned char i = 0; i < numbers; i++)
  {
      sensors.setResolution(addresses[i], TEMPERATURE_PRECISION);
  }
  sensors.requestTemperatures();
  
  if (msglen)
  {
    unsigned short msgcrc;
    memcpy(&msgcrc, &readbuf[msglen-2], 2);
    unsigned short crc = getCRC(readbuf, msglen-2);
    if (crc == msgcrc)
    {
      char adr = readbuf[0];
      char cs = readbuf[1];
      char mtd = readbuf[2];
      int len;
      unsigned char n;
      float temp;
      if (adr == LOC_ADR)
      {
        switch (cs)
        {
          case CS_PING:
            writebuf[0] = LOC_ADR;
            writebuf[1] = '\x55';
            writebuf[2] = '\xAA';
            writebuf[3] = '\x55';
            writebuf[4] = '\xAA';
            len = addCRC(writebuf, 5);
            delay(100);
            transferData(writebuf, len);
            break;
          case CS_INFO:
            switch (mtd)
            {
               case 0:
                  writebuf[0] = LOC_ADR;
                  writebuf[1] = numbers;
                  len = addCRC(writebuf, 2);
                  delay(100);
                  transferData(writebuf, len);
                  break;
                case 1:
                  writebuf[0] = LOC_ADR;
                  writebuf[1] = numbers;
                  for (int i=0; i < numbers; i++)
                  {
                    temp = sensors.getTempC(addresses[i]);
                    memcpy(&writebuf[i*12+2], &temp, 4);
                    memcpy(&writebuf[i*12+6], &addresses[i], 8);
                  }
                  len = addCRC(writebuf, numbers*12+2);
                  delay(100);
                  transferData(writebuf, len);
                  break;
                case 2:
                  writebuf[0] = LOC_ADR;
                  memcpy(&writebuf[1], &pressure, 4);
                  writebuf[5] = 0;
                  len = addCRC(writebuf, 6);
                  delay(100);
                  transferData(writebuf, len);
                  break;
                case 3:
                  writebuf[0] = LOC_ADR;
                  memcpy(&writebuf[1], &humidity, 4);
                  writebuf[5] = 0;
                  len = addCRC(writebuf, 6);
                  delay(100);
                  transferData(writebuf, len);
                  break;
            }
            break;
        }
      }
    }
    msglen = 0;
  }
}

void serialEvent() 
{
    msglen = readCommand(readbuf);
}

int readCommand(char *buf)
{
  int i = 0;
  bool escaped = false;
  char c = (char) Serial.read();
  if (c == SLIP_END)
  {
    bool beginflag = true;
    while (beginflag)
    {
      char c1 = (char) Serial.read();
      switch (c1)
      {
        case SLIP_END:
          return i;
          break;
        case SLIP_ESC:
          escaped = true;
          break;
        case SLIP_ESC_END:
          if (escaped)
          {
            buf[i] = SLIP_END;
            escaped = false;
          }
          else
            buf[i] = c1;
          i++;
          break;
        case SLIP_ESC_ESC:
          if (escaped)
          {
            buf[i] = SLIP_ESC;
            escaped = false;
          }
          else
            buf[i] = c1;
          i++;
          break;
        default:
          if (escaped)
          {
            return 0;
          }
          else
          buf[i] = c1;
          i++;
          break;
      }
    }
  }
  return i;
}


Класс slip.py
class SlipConv:

    def __init__(self):
        self.started = False
        self.escaped = False
        self.packet = ''
        self.SLIP_END = '\xc0'
        self.SLIP_ESC = '\xdb'
        self.SLIP_ESC_END = '\xdc'
        self.SLIP_ESC_ESC = '\xdd'
        self.serialComm = None

    def __getcrc(self, buf):
        temp = 0xffff
        for c in buf:
            i = ord(c)
            temp ^= i
            j = 1
            while j <= 8:
                flag = temp & 0x0001
                temp >>= 1
                if flag > 0:
                    temp ^= 0xa001
                j += 1
        temp2 = temp >> 8
        temp = (temp << 8) | temp2
        temp &= 0xffff
        return temp

    def addcrc(self, packet):
        crc = self.__getcrc(packet)
        return packet + chr(crc & 0xff) + chr(crc >> 8)

    def checkcrc(self, packet):
        tmpcrc = self.__getcrc(self.getmsgpart(packet))
        msgcrc = self.getcrcpart(packet)
        return (chr(tmpcrc & 0xff) + chr(tmpcrc >> 8)) == msgcrc

    def getcrcpart(self, packet):
        return packet[len(packet)-2:len(packet)]

    def getmsgpart(self, packet):
        return packet[0:len(packet)-2]

    def unslip(self, stream):
        packetlist = ''
        for char in stream:
            if char == self.SLIP_END:
                if self.started:
                    packetlist += self.packet
                else:
                    self.started = True
                self.packet = ''
            elif char == self.SLIP_ESC:
                self.escaped = True
            elif char == self.SLIP_ESC_END:
                if self.escaped:
                    self.packet += self.SLIP_END
                    self.escaped = False
                else:
                    self.packet += char
            elif char == self.SLIP_ESC_ESC:
                if self.escaped:
                    self.packet += self.SLIP_ESC
                    self.escaped = False
                else:
                    self.packet += char
            else:
                if self.escaped:
                    self.packet = ''
                    self.escaped = False
                    return ''
                else:
                    self.packet += char
                    self.started = True
        self.started = False
        return packetlist

    def slip(self, packet):
        encoded = self.SLIP_END
        for char in packet:
            if char == self.SLIP_END:
                encoded += self.SLIP_ESC + self.SLIP_ESC_END
            elif char == self.SLIP_ESC:
                encoded += self.SLIP_ESC + self.SLIP_ESC_ESC
            else:
                encoded += char
        encoded += self.SLIP_END
        return encoded


Класс protocol.py
# - *- coding: utf- 8 - *-

import sys
import serial
import time
import math
from slip import SlipConv
import struct

class Protocol:

    def __init__(self, port, baudrate, logon):
        self.log = logon
        self.slipC = SlipConv()
        self.SLIP_END = '\xc0'
        self.ser = serial.Serial()
        self.ser.port = port
        self.ser.baudrate = baudrate
        self.ser.timeout = 5
        try:
            self.ser.open()

        except serial.SerialException as e:
            print ('Oops! IO Error. Check ' + port + ' at ' + str(baudrate) + '.')
            sys.exit(1)
        if self.log:
            print ('Opened ' + port + ' at ' + str(baudrate) + '.')
        time.sleep(2)

    def printPacket(self, packet):
        print ' '.join("%X" % ord(c) if ord(c) > 0x0f else '0' + "%X" % ord(c) for c in packet)

    def sendCommand(self, packet):
        crcPack = self.slipC.addcrc(packet)
        out = self.slipC.slip(crcPack)
        self.ser.write(out)
        if self.log:
            print ('Sent ' + str(len(out)) + ' bytes: '),
            self.printPacket(out)

    def receiveAnswer(self):
        packet = ''
        char = ''
        firsttime = time.time()
        while (time.time() - firsttime) < self.ser.timeout:
            char = self.ser.read(1)
            if char == self.SLIP_END:
                break
        if char != self.SLIP_END:
            print 'Timeout error!!! Check the connections'
            sys.exit(1)
        packet += char
        beginflag = True
        while beginflag:
            c = self.ser.read(1)
            packet += c
            if c == self.SLIP_END:
                beginflag = False
        if self.log:
            print ('Received ' + str(len(packet)) + ' bytes: '),
            self.printPacket(packet)
        unsliped = self.slipC.unslip(packet)
        if self.slipC.checkcrc(unsliped):
            if self.log:
                print ('CRC - OK')
            return self.slipC.getmsgpart(unsliped)
        else:
            if self.log:
                print ('BAD CRC,'),
                print 'received ',
                self.printPacket(packet)
            return ''

    def ping(self, adr):
        if self.log:
            print ('Ping adr=' + str(adr))
        self.sendCommand(chr(adr) + chr(0))
        if self.receiveAnswer() == ((chr(0) + chr(0x55) + chr(0xAA) + chr(0x55) + chr(0xAA))):
            if self.log:
                print ('Ping to adr=' + str(adr) + ' - OK')
            return True
        else:
            return False

    def getTemp(self, adr):
        if self.log:
            print ('Get a temperature from sensors.')
        self.sendCommand(chr(adr) + chr(1) + chr(1))
        res = self.receiveAnswer()
        num = ord(res[1])
        values = []
        for i in range(0, num):
            temp, = struct.unpack('<f', res[i*12+2:i*12+6])
            sernum = res[i*12+6:i*12+14]
            values.append((temp, sernum))
            if self.log:
                print 'It has ' + str(num) + ' temperature sensors:'
                print ("%.1f" % temp + 'C on the sensor with the serial number'),
                self.printPacket(sernum)
        return values

    def getPressure(self, adr):
        if self.log:
            print ('Get the atmospheric pressure.')
        self.sendCommand(chr(adr) + chr(1) + chr(2))
        res = self.receiveAnswer()
        pressure, = struct.unpack('<i', res[1:5])
        sernum = res[5]
        if self.log:
            if 10 < pressure < 1000:
                print (str(pressure) + ' mmHg on the sensor with the serial number'),
                self.printPacket(sernum)
            else:
                print 'The pressure sensor doesn\'t exist'
        return pressure, sernum

    def getHumidity(self, adr):
        if self.log:
            print ('Get a humidity.')
        self.sendCommand(chr(adr) + chr(1) + chr(3))
        res = self.receiveAnswer()
        humidity, = struct.unpack('<f', res[1:5])
        sernum = res[5]
        if self.log:
            if math.isnan(humidity):
                print 'The humidity sensor doesn\'t exist'
            else:
                print (str(humidity) + '% on the sensor with the serial number'),
                self.printPacket(sernum)
        return humidity, sernum

    def close(self):
        self.ser.close()


Немного о хост-компьютере

В качестве хоста может использоваться абсолютно любой компьютер с установленными Python 2.7 и SQLite. Для работы потребуется установить библиотеку pyserial.
Выбор упал на достаточно пожилой уже роутер Asus WL-500gp.
Установил на него OpenWrt, смонтировал USB-flash, установил Python, SQLite и библиотеки.

Для проверки работоспособности прибора можно использовать тестовый скрипт


Скрипт tst.py
#!/usr/bin/python
import math
from protocol import Protocol

deviceAddress = 0
serialPort = '/dev/ttyUSB0'
baudRate = 9600
logEnabled = True

device = Protocol(serialPort, baudRate, logEnabled)
if device.ping(deviceAddress):
   pressure, sernumP = device.getPressure(deviceAddress)
   if 10 < pressure < 1000:
       print ('Pressure - ' + str(pressure) + ' mmHg')
   humidity, sernumH = device.getHumidity(deviceAddress)
   if not math.isnan(humidity):
       print ('Humidity - ' + str(humidity) + '%')
   values = device.getTemp(deviceAddress)
   i = 1
   for (temperature, sn) in values:
       print ('T' + str(i) + ' - ' + "%.1f" % temperature + ' C, sensor'),
       device.printPacket(sn)
       i += 1
device.close()


Если все работает нормально, вывод будет примерно таким:
Opened /dev/ttyUSB0 at 9600.
Ping adr=0
Sent 6 bytes:  C0 00 00 B0 01 C0
Received 9 bytes:  C0 00 55 AA 55 AA C3 AA C0
CRC - OK
Ping to adr=0 - OK
Get the atmospheric pressure.
Sent 7 bytes:  C0 00 01 02 91 F1 C0
Received 10 bytes:  C0 00 EB 02 00 00 00 B4 25 C0
CRC - OK
747 mmHg on the sensor with the serial number 00
Pressure - 747 mmHg
Get a humidity.
Sent 7 bytes:  C0 00 01 03 51 30 C0
Received 10 bytes:  C0 00 9A 99 33 42 00 34 B6 C0
CRC - OK
44.9000015259% on the sensor with the serial number 00
Humidity - 44.9000015259%
Get a temperature from sensors.
Sent 7 bytes:  C0 00 01 01 90 B1 C0
Received 19 bytes:  C0 00 01 00 80 BD 41 10 60 3B 4F 00 08 00 DB DC 21 1B C0
CRC - OK
It has 1 temperature sensors:
23.7C on the sensor with the serial number 10 60 3B 4F 00 08 00 C0
T1 - 23.7 C, sensor 10 60 3B 4F 00 08 00 C0

Теперь требуется сохранить результаты измерений в БД.

Создаем следующую структуру:

База содержит три основных таблицы — sensortypes, sensors и metering (типы, датчики, измерения) и одно представление allrecords (плоская таблица измерений).
Таблицы hourlyrecords и dailyrecords содержат почасовые и посуточные усредненные данные.
В таблице dbversion  - версия БД.
Сразу оговорюсь — я не использовал никаких встроенных возможностей SQLite, выходящих за пределы SQL-92, появившихся в последних версиях, поскольку SQLite у меня был 3.8.2 и обновлять его резона не было. Но есть и плюс, этот код можно использовать с любой БД с минимальными изменениями.

Для работы с базой данных был написан небольшой класс:
Класс dbhelper.py
# - *- coding: utf- 8 - *-

import sqlite3
import time
from datetime import datetime

class DBHelper:

    dbconnect = None
    cursor = None
    version = 1

    def __init__(self, fileName):
        self.dbconnect = sqlite3.connect(fileName)
        self.dbconnect.text_factory = str
        self.cursor = self.dbconnect.cursor()
        self.cursor.execute('CREATE TABLE IF NOT EXISTS dbversion' +
                            '(_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,' +
                            'time INTEGER NOT NULL,' +
                            'version INTEGER NOT NULL)')
        self.cursor.execute('SELECT version FROM dbversion')
        if len(self.cursor.fetchall()) == 0:
            self.cursor.execute('INSERT INTO dbversion (time, version) VALUES (?,?)', (int(time.time()), self.version))
        self.cursor.execute('CREATE TABLE IF NOT EXISTS sensortypes' +
                            '(_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,' +
                            'type TEXT,' +
                            'valuename TEXT)')
        self.cursor.execute('SELECT type FROM sensortypes')
        if len(self.cursor.fetchall()) == 0:
            self.cursor.execute('INSERT INTO sensortypes (type, valuename) VALUES (?,?)', ('Температура', 'град. С'))
            self.cursor.execute('INSERT INTO sensortypes (type, valuename) VALUES (?,?)', ('Давление', 'мм рт. ст.'))
            self.cursor.execute('INSERT INTO sensortypes (type, valuename) VALUES (?,?)', ('Влажность', '%'))
        self.cursor.execute('CREATE TABLE IF NOT EXISTS sensors' +
                            '(_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,' +
                            'type INTEGER NOT NULL,' +
                            'sernum TEXT,' +
                            'description TEXT NOT NULL,' +
                            'place TEXT NOT NULL,' +
                            'FOREIGN KEY (type) REFERENCES sensortypes(_id))')
        self.cursor.execute('CREATE TABLE IF NOT EXISTS metering' +
                            '(_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,' +
                            'time INTEGER NOT NULL,' +
                            'value REAL NOT NULL,' +
                            'sensorid INTEGER NOT NULL,' +
                            'FOREIGN KEY (sensorid) REFERENCES sensors(_id))')
        self.cursor.execute('CREATE TABLE IF NOT EXISTS hourlyrecords' +
                            '(time INTEGER PRIMARY KEY NOT NULL)')
        self.cursor.execute('CREATE TABLE IF NOT EXISTS dailyrecords' +
                            '(time INTEGER PRIMARY KEY NOT NULL)')
        self.cursor.execute('CREATE UNIQUE INDEX IF NOT EXISTS "avgday" on dailyrecords (time ASC)')
        self.cursor.execute('CREATE UNIQUE INDEX IF NOT EXISTS "avghour" on hourlyrecords (time ASC)')
        self.cursor.execute('CREATE UNIQUE INDEX IF NOT EXISTS "mid" on metering (_id ASC)')
        self.cursor.execute('CREATE INDEX IF NOT EXISTS "time" on metering (time ASC)')
        self.cursor.execute('CREATE UNIQUE INDEX IF NOT EXISTS "sid" on sensors (_id ASC)')
        self.cursor.execute('CREATE UNIQUE INDEX IF NOT EXISTS "stid" on sensortypes (_id ASC)')

    def updateAvgTables(self):
        self.cursor.execute('SELECT MAX(_id) FROM sensors')
        number = self.cursor.fetchone()[0]
        self.cursor.execute('SELECT * FROM hourlyrecords ORDER BY ROWID ASC LIMIT 1')
        columnnamelist = [tuple[0] for tuple in self.cursor.description]
        if number > (len(columnnamelist)-1):
            for i in range(len(columnnamelist), number+1):
                self.cursor.execute('ALTER TABLE hourlyrecords ADD COLUMN v%s REAL' % str(i))
                self.cursor.execute('ALTER TABLE dailyrecords ADD COLUMN v%s REAL' % str(i))
        self.cursor.execute('SELECT MIN(time) FROM metering')
        minrealtime = self.cursor.fetchone()[0]
        if minrealtime is not None:
            self.cursor.execute('SELECT MAX(time) FROM metering')
            maxrealtime = self.cursor.fetchone()[0]
            self.cursor.execute('SELECT MAX(time) FROM hourlyrecords')
            maxhourlyavgtime = self.cursor.fetchone()[0]
            self.cursor.execute('SELECT MAX(time) FROM dailyrecords')
            maxdailyavgtime = self.cursor.fetchone()[0]
            firsthourtime = 3600
            firstdaytime = 86400
            if maxhourlyavgtime is None:
                maxhourlyavgtime = minrealtime
                firsthourtime = 0
            if maxdailyavgtime is None:
                maxdailyavgtime = minrealtime
                firstdaytime = 0
            begintimestamp = datetime.fromtimestamp(float(maxhourlyavgtime))
            endtimestamp = datetime.fromtimestamp(float(maxrealtime))
            firstedge = datetime(begintimestamp.year, begintimestamp.month, begintimestamp.day, begintimestamp.hour)
            secondedge = datetime(endtimestamp.year, endtimestamp.month, endtimestamp.day, endtimestamp.hour)
            begin = int(time.mktime(firstedge.timetuple())) + firsthourtime
            end = int(time.mktime(secondedge.timetuple()))-1
            for i in range(begin, end, 3600):
                self.cursor.execute('SELECT AVG(time) FROM metering WHERE time >= %s AND time <= %s' % (str(i), str(i+3599)))
                if self.cursor.fetchone()[0] is None:
                    continue
                insert = 'INSERT INTO hourlyrecords (time'
                select = 'SELECT CAST(AVG(time) AS INTEGER)'
                for v in range(1, number+1):
                    insert += ', v%s' % str(v)
                    select += ', AVG(CASE WHEN sensorid=%s THEN value ELSE NULL END)' % str(v)
                insert += ') '
                select += ' FROM metering WHERE time >= %s AND time <= %s' % (str(i), str(i+3599))
                self.cursor.execute(insert + select)
            begintimestamp = datetime.fromtimestamp(float(maxdailyavgtime))
            endtimestamp = datetime.fromtimestamp(float(maxrealtime))
            firstedge = datetime(begintimestamp.year, begintimestamp.month, begintimestamp.day)
            secondedge = datetime(endtimestamp.year, endtimestamp.month, endtimestamp.day)
            begin = int(time.mktime(firstedge.timetuple())) + firstdaytime
            end = int(time.mktime(secondedge.timetuple()))-1
            for i in range(begin, end, 86400):
                self.cursor.execute('SELECT AVG(time) FROM metering WHERE time >= %s AND time <= %s' % (str(i), str(i+85399)))
                if self.cursor.fetchone()[0] is None:
                    continue
                insert = 'INSERT INTO dailyrecords (time'
                select = 'SELECT CAST(AVG(time) AS INTEGER)'
                for v in range(1, number+1):
                    insert += ', v%s' % str(v)
                    select += ', AVG(CASE WHEN sensorid=%s THEN value ELSE NULL END)' % str(v)
                insert += ') '
                select += ' FROM metering WHERE time >= %s AND time <= %s' % (str(i), str(i+85399))
                query = insert + select
                self.cursor.execute(query)

    def __makeDict(self, raw):
        res = {'time': raw[0]}
        for i in range(2, len(raw)+1):
            res[str(i-1)] = raw[i - 1]
        return res

    def getSensorId(self, sensorType, sernum):
        self.cursor.execute('SELECT _id FROM sensors WHERE sernum=? AND type=?', (sernum, sensorType))
        selres = self.cursor.fetchall()
        if len(selres) > 0:
            sensorId = selres[0][0]
        else:
            self.cursor.execute('INSERT INTO sensors (type, sernum, description, place) VALUES (?,?,?,?)', (sensorType, sernum, '', ''))
            self.cursor.execute('SELECT _id FROM sensors WHERE sernum=? AND type=?', (sernum, sensorType))
            sensorId = self.cursor.fetchone()[0]
        return sensorId

    def storeValue(self, time, value, sensorId):
        self.cursor.execute('INSERT INTO metering (time, value, sensorid) VALUES (?,?,?)', (int(time), value, sensorId))

    def getLast(self):
        self.cursor.execute('SELECT MAX(_id) FROM sensors')
        number = self.cursor.fetchone()[0]
        query = 'SELECT time'
        for i in range(1, number+1):
            query += ', (SELECT value FROM metering WHERE sensorid=%s AND time=m.time)' % str(i)
        query += ' FROM metering m WHERE time=(SELECT MAX(time) FROM metering) GROUP BY time'
        self.cursor.execute(query)
        return [self.__makeDict(self.cursor.fetchone()), ]

    def getInterval(self, minTime = None, maxTime = None):
        self.cursor.execute('SELECT MAX(_id) FROM sensors')
        number = self.cursor.fetchone()[0]
        query = 'SELECT time'
        for i in range(1, number+1):
            query += ', (SELECT value FROM metering WHERE sensorid=%s AND time=m.time)' % str(i)
        if minTime is not None and maxTime is not None:
            query += ' FROM metering m WHERE (time >= ? AND time <= ?) GROUP BY time'
            self.cursor.execute(query, (minTime, maxTime))
        else:
            query += ' FROM metering m GROUP BY time ORDER BY time'
            self.cursor.execute(query)
        return [self.__makeDict(raw) for raw in self.cursor.fetchall()]

    def updateAllRecordsView(self):
        self.cursor.execute('SELECT MAX(_id) FROM sensors')
        number = self.cursor.fetchone()[0]
        self.cursor.execute('DROP VIEW IF EXISTS allrecords')
        query = 'CREATE VIEW allrecords AS SELECT time time'
        for i in range(1, number+1):
            query += ', max(CASE WHEN sensorid=%s THEN value ELSE NULL END) v%s' % (str(i), str(i))
        query += ' FROM metering GROUP BY time ORDER BY time'
        self.cursor.execute(query)
        return

    def getAll(self):
        return self.getInterval()

    def getSensors(self):
        self.cursor.execute('SELECT s._id, st.type, s.sernum, s.description, s.place, st.valuename FROM sensors s, sensortypes st WHERE s.type=st._id ORDER BY s._id')
        res = []
        for raw in self.cursor.fetchall():
            res.append({'id': raw[0],
                        'type': raw[1],
                        'sernum': ' '.join("%X" % ord(c) if ord(c) > 0x0f else '0' + "%X" % ord(c) for c in raw[2]),
                        'description': raw[3],
                        'place': raw[4],
                        'valuename': raw[5]})
        return res

    def updateSensor(self, sensorid, description, place):
        self.cursor.execute('UPDATE sensors SET description = ?, place = ? WHERE _id = ?', (description, place, sensorid))

    def getDBVersion(self):
        self.cursor.execute('SELECT version FROM dbversion WHERE _id=(SELECT MAX(_id) FROM dbversion)')
        return self.cursor.fetchone()[0]

    def close(self):
        self.dbconnect.commit()


Следующим шагом объединим опрос датчиков с сохранением в БД


Скрипт опроса getweather.py
#!/usr/bin/python
# - *- coding: utf- 8 - *-
import math
from protocol import Protocol
import sys
import time
import os
from dbhelper import DBHelper

deviceAddress = 0
serialPort = '/dev/ttyUSB0'
baudRate = 9600
logEnabled = True

dbFileName = 'weatherstation.db'
# modulePath = os.path.abspath('/home/weather') + '/'
# dbFileName = modulePath + 'weatherstation.db'

termSensorType = 1
pressureSensorType = 2
humiditySensorType = 3

if len(sys.argv) == 3:
    serialPort = sys.argv[1]
    baudRate = sys.argv[2]
    deviceAddress = sys.argv[3]
    logEnabled = sys.argv[4]
elif len(sys.argv) == 1:
    print ('Command line: getweather.py serial_port serial_speed')
    print ('Trying with serial_port = ' + serialPort + ' and serial_speed = ' + str(baudRate))
else:
    print ('Command line: getweather.py serial_port serial_speed')
    sys.exit(1)

currenttime = time.time()
db = DBHelper(dbFileName)

device = Protocol(serialPort, baudRate, logEnabled)
if device.ping(deviceAddress):
    pressure, sernumP = device.getPressure(deviceAddress)
    if 10 < pressure < 1000:
        print ('Pressure - ' + str(pressure) + ' mmHg')
        pressureSensorId = db.getSensorId(pressureSensorType, sernumP)
        db.storeValue(currenttime, pressure, pressureSensorId)
    humidity, sernumH = device.getHumidity(deviceAddress)
    if not math.isnan(humidity):
        print ('Humidity - ' + str(humidity) + '%')
        humiditySensorID = db.getSensorId(humiditySensorType, sernumH)
        db.storeValue(currenttime, humidity, humiditySensorID)
    values = device.getTemp(deviceAddress)
    i = 1
    for (temperature, sn) in values:
        print ('T' + str(i) + ' - ' + "%.1f" % temperature + ' C, sensor'),
        device.printPacket(sn)
        i += 1
        termSensorId = db.getSensorId(termSensorType, sn)
        db.storeValue(currenttime, temperature, termSensorId)
device.close()
db.updateAvgTables()
db.updateAllRecordsView()
db.close()


Далее, скопируем полученные файлы на наш хост-компьютер, добавим задачу в планировщик на запуск getweater.py каждые 5 минут, и оставим наш прибор собирать статистику.

Теперь эти данные нужно как-то получить. Разработаем API:

/ws.py будет возвращать html-страницу с последней записью из БД.
/ws.py?mtd=last — последняя запись в БД в формате json-строки.
/ws.py?mtd=intervalmin=XXmax=YY — диапазон записей между датами min и max в формате json-строки.
/ws.py?mtd=all — все записи в формате json-строки.
/ws.py?mtd=version — версия БД в формате json-строки.
/sensors.py — html-страница с перечнем датчиков.

Для этого напишем простую web-службу и редактор датчиков.

Скрипт ws.py
#!/usr/bin/python
# - *- coding: utf- 8 - *-
import sys
import os
import json
import cgi
import time

modulePath = os.path.dirname(__file__) + '/../../'
# modulePath = os.path.abspath('/home/weather') + '/'
sys.path.append(modulePath)
from dbhelper import DBHelper

method = 'mtd'
version = 'version'
minThr = 'min'
maxThr = 'max'

dbFileName = modulePath + 'weatherstation.db'
# dbFileName = modulePath + 'genweather.db'
db = DBHelper(dbFileName)

def makeJSON(records):
    return json.JSONEncoder().encode({'sensors': db.getSensors(), 'records': records})

args = cgi.FieldStorage()
if len(args) == 0:
    sensors = db.getSensors()
    records = db.getLast()
    print 'Content-Type: text/html; charset=utf-8'
    print
    defaulthtml = """
    <title>Метеостанция</title>
    <h1>Погода</h1>
    <hr>"""
    defaulthtml += '<P>' + time.strftime("%d.%m.%Y %H:%M", time.localtime(records[0]['time'])) + '</P>'
    defaulthtml += '<table border=0>'
    for i in range(1, len(sensors) + 1):
        if records[0][str(i)] is not None:
            defaulthtml += '<tr>'
            defaulthtml += '<td>' + str(sensors[i - 1]['id']) + '</td>'
            defaulthtml += '<td>' + sensors[i - 1]['type'] + '</td>'
            defaulthtml += '<td>' + sensors[i - 1]['description'] + '</td>'
            defaulthtml += '<td>' + sensors[i - 1]['place'] + '</td>'
            defaulthtml += '<td>' + "%.1f" % records[0][str(i)] + '</td>'
            defaulthtml += '<td>' + sensors[i - 1]['valuename'] + '</td>'
            defaulthtml += '</tr>'
    defaulthtml += '<p><a href="sensors.py">Датчики</a></p>'
    print defaulthtml
elif method in args:
    if args[method].value == 'last':
        print "Content-type: application/json"
        print
        print (makeJSON(db.getLast()))
    elif args[method].value == 'all':
        print "Content-type: application/json"
        print
        print (makeJSON(db.getAll()))
    elif args[method].value == 'interval':
        if minThr in args:
            if maxThr in args:
                print "Content-type: application/json"
                print
                print (makeJSON(db.getInterval(args[minThr].value, args[maxThr].value)))
    elif args[method].value == version:
        print "Content-type: application/json"
        print
        print (json.JSONEncoder().encode({version: db.getDBVersion()}))

db.close()


Скрипт sensors.py
#!/usr/bin/python
# - *- coding: utf- 8 - *-
import sys
import os
import cgi

modulePath = os.path.dirname(__file__) + '/../../'
# modulePath = os.path.abspath('/home/weather') + '/'
sys.path.append(modulePath)
from dbhelper import DBHelper

method = 'mtd'
sensorNumber = 'sensornumber'

dbFileName = modulePath + 'weatherstation.db'
db = DBHelper(dbFileName)
args = cgi.FieldStorage()
if len(args) == 0:
    sensors = db.getSensors()
    print 'Content-Type: text/html; charset=utf-8'
    print
    sensorshtml = """
    <title>Метеостанция</title>
    <h1>Датчики</h1>
    <hr>
    <table border=0>
    <tr>
        <td> № </td>
        <td>Тип</td>
        <td> s/n </td>
        <td>Описание</td>
        <td>Место установки</td>
        <td>Ед. измерения</td>
    </tr>"""
    url = 'sensors.py?mtd=sensor&'
    for s in sensors:
        sensorshtml += '<tr>'
        sensorshtml += '<td>' + '<a href="' + url + sensorNumber + '=' + str(s['id']) + '">' + str(s['id'])     + '</a></td>'
        sensorshtml += '<td>' + '<a href="' + url + sensorNumber + '=' + str(s['id']) + '">' + s['type']        + '</a></td>'
        sensorshtml += '<td>' + '<a href="' + url + sensorNumber + '=' + str(s['id']) + '">' + s['sernum']      + '</a></td>'
        sensorshtml += '<td>' + '<a href="' + url + sensorNumber + '=' + str(s['id']) + '">' + s['description'] + '</a></td>'
        sensorshtml += '<td>' + '<a href="' + url + sensorNumber + '=' + str(s['id']) + '">' + s['place']       + '</a></td>'
        sensorshtml += '<td>' + '<a href="' + url + sensorNumber + '=' + str(s['id']) + '">' + s['valuename']   + '</a></td>'
        sensorshtml += '</tr>'
    print sensorshtml

elif method in args:
    if args[method].value == 'sensor':
        if sensorNumber in args:
            numstr = args[sensorNumber].value
            if numstr.isdigit():
                num = int(numstr) - 1
                sensors = db.getSensors()
                if 0 <= num <= len(sensors):
                    sensor = sensors[num]
                    sensorshtml = """<!DOCTYPE html>
                                    <html lang="en">
                                    <head>
                                    <meta charset="UTF-8">
                                    <title>Редактор</title>
                                    </head>
                                    <body>
                                    <H1>Корректировка датчика</H1>
                                    <hr>
                                    <form method=POST action="sensors.py">
                                        <B> № %s</B>
                                        <input type=text name=id value="%s" hidden>
                                        <B>Тип</B>
                                        <input type=text name=type value="%s" disabled>
                                        <B> s/n </B>
                                        <input type=text name=sernum value="%s" disabled>
                                        <B>Описание</B>
                                        <input type=text name=description value="%s">
                                        <B>Место установки</B>
                                        <input type=text name=place value="%s">
                                        <B>Ед. измерения</B>
                                        <input type=text name=valuename value="%s" disabled>
                                        <input type=submit name="save" value="Сохранить">
                                    </form>
                                    </body>
                                    </html>""" % (sensor['id'], sensor['id'], sensor['type'], sensor['sernum'], sensor['description'], sensor['place'], sensor['valuename'])
                    print 'Content-Type: text/html; charset=utf-8'
                    print
                    print sensorshtml

elif 'save' in args:
    description = cgi.escape(args['description'].value) if 'description' in args else ''
    place = cgi.escape(args['place'].value) if 'place' in args else ''
    sensorid = int(args['id'].value)
    print 'Content-Type: text/html; charset=utf-8'
    print
    db.updateSensor(sensorid, description, place)
    savehtml = """<!DOCTYPE html>
                  <html lang="en">
                  <head>
                  <meta charset="UTF-8">
                  <meta http-equiv="refresh" content="1;url=sensors.py">
                  <title>Редактор</title>
                  </head>"""
    print savehtml

db.close()


Эти два скрипта необходимо поместить в каталог cgi-bin web-сервера (в моем случае, это /www/cgi-bin), сделать их исполняемыми и дать права на выполнение:

chmod -R 755 /www/cgi-bin
chmod -R +x /www/cgi-bin

Для тех, кто не желает выделять для сбора метеоданных отдельный компьютер и (или) не хочет устанавливать полноценный web-сервер на свой единственный, могу порекомендовать этот скрипт:

Скрипт webserver.py
#!/usr/bin/python
# - *- coding: utf- 8 - *-

import BaseHTTPServer
import CGIHTTPServer
import cgitb
cgitb.enable()

server = BaseHTTPServer.HTTPServer
handler = CGIHTTPServer.CGIHTTPRequestHandler
server_address = ("", 8000)
handler.cgi_directories = ["/cgi-bin"]

httpd = server(server_address, handler)
httpd.serve_forever()


Запустив его в командной строке, вы получите web-сервер, позволяющий отлаживать cgi-скрипты и простые страницы. В итоге, должно получиться следующее:







Весь код доступен на гитхабе.

Заключение.


Я рассказал на примере задачи сбора метеоданных о том, как можно построить систему фиксации любых измеряемых процессов — как построить протокол обмена, как защитить данные от искажения при передаче, и как хранить их в базе данных. Читатель, при желании, сможет адаптировать этот проект под свои нужды.

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

Спасибо за внимание, надеюсь, было интересно.
Поделиться с друзьями
-->

Комментарии (7)


  1. arvitaly
    30.05.2016 13:25

    Кстати, google выпустил интересное приложение Научный журнал. Обещают потом и другие датчики подключать.


  1. DarkByte
    30.05.2016 18:33

    Если датчики заменить на BMP180 и SHT21, то для подключения потребуется только i2c шина.


    1. ndrwK
      30.05.2016 19:41

      SHT21 имеет фиксированный адрес 0х80. Просто так несколько штук на одну шину их не подключить. А ds1820 — запросто. Софт из статьи поддерживает опрос гирлянды датчиков на одной линии.
      Про цену SHT21 я вообще молчу (если он не халявный).
      Да и в закромах были только ds1820.


      1. DarkByte
        30.05.2016 20:02

        SHT21 я предлагал на замену DHT22, всё таки они оба больше датчики влажности, чем температуры, хотя и умеют её измерять. Разница в цене всего в два раза, но если DHT22 у меня часто залипал на 100% влажности, то с SHT21 такой проблемы не встречал. BMP180 стоит дешевле BMP085, хотя я смотрю, что ему на замену уже вышел BMP280. Цена BMP180 не сильно отличается от DS18B20, но зато первый умеет измерять атмосферное давление, а второй больше подходит когда требуется подключить большое количество датчиков на одну линию, это да.


        1. ndrwK
          30.05.2016 20:14

          Если взамен DHT22, тогда — да. Его можно заменить, медленный он очень. А BMP085 в свое время был достаточно дорогим. Я его купил зачем-то несколько лет назад. Вот только сейчас пригодился.


          1. DarkByte
            30.05.2016 20:21

            Кстати, я упомянул BMP280, но только что заметил, что уже есть в продаже BME280, он умеет измерять температуру, влажность и давление, причём стоит дешевле чем SHT21, умеет общаться и по I2C и по SPI, интересно было бы пощупать.


  1. m0Ray
    31.05.2016 05:36

    Посмотрите в сторону MODBUS — в нём и физическая реализация, и логика, на мой взгляд, гораздо более соответствуют тому, что у вас сделано с SLIP.