Казалось бы, каждый, кто осваивает ардуино, первым делом конструирует или повторяет прибор для измерения температуры и(или) прочих параметров окружающей среды. Только большинство подобных конструкций, к сожалению, мало применимы в домашнем хозяйстве — в качестве тренировки сгодится, а пользы нет. Попробуем исправить эту недоработку. В статье расскажу о создании комплекса для измерения и хранения любых данных на примере сбора показаний датчиков температуры, влажности воздуха и атмосферного давления. Начну с требований к прибору и описания протокола обмена, закончу web-службой для получения данных из БД. Подробных выкладок и пошаговых руководств не будет, но будет немного теории и много кода.
История эта началась в один из дней, когда провод выносного датчика домашней метеостанции был покусан одним из домашних питомцев. Провод я починил, но после того случая прибор стал периодически показывать неправду. Налицо недостаток аналогового измерения, когда небольшого воздействия достаточно для того, чтобы полностью исказить данные. Этот случай и подтолкнул меня сделать собственный подобный комплекс.
После обдумывания проблемы получилось следующее ТЗ:
Так как обмен данными с контроллером будет асинхронным, с пакетами заранее неизвестной длины, за основу канального протокола был взят SLIP. Это протокол, в котором передача данных осуществляется при помощи SLIP-кадров. Границами SLIP-кадра является флаг END (0xC0). Если внутри кадра встречается байт 0xC0, он заменяется ESC-последовательностью 0xDB, 0xDC, а если встречается байт ESC (0xDB), он заменяется последовательностью (0xDB, 0xDD). Обратное преобразование симметричное.
В SLIP-кадры будем оборачивать сообщения с заранее рассчитанной контрольной суммой.
Для расчета контрольной суммы был применен алгоритм CRC16 с полиномом 0xA001 (modbus):
CRC добавляется в конец сообщения в формате сначала младший байт, потом старший байт.
адрес_прибора(1 байт) класс(1 байт) [метод(1 байт)] [данные(N байт)]
возвращает 0x55 0xAA 0x55 0xAA
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)
Тут фото промежуточного варианта с поддержкой экрана от Нокии (код есть в репозитории по ссылке в конце статьи).
Выбор упал на достаточно пожилой уже роутер Asus WL-500gp.
Установил на него OpenWrt, смонтировал USB-flash, установил Python, SQLite и библиотеки.
Если все работает нормально, вывод будет примерно таким:
База содержит три основных таблицы — sensortypes, sensors и metering (типы, датчики, измерения) и одно представление allrecords (плоская таблица измерений).
Таблицы hourlyrecords и dailyrecords содержат почасовые и посуточные усредненные данные.
В таблице dbversion - версия БД.
Для работы с базой данных был написан небольшой класс:
Далее, скопируем полученные файлы на наш хост-компьютер, добавим задачу в планировщик на запуск 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-службу и редактор датчиков.
Эти два скрипта необходимо поместить в каталог cgi-bin web-сервера (в моем случае, это /www/cgi-bin), сделать их исполняемыми и дать права на выполнение:
Для тех, кто не желает выделять для сбора метеоданных отдельный компьютер и (или) не хочет устанавливать полноценный web-сервер на свой единственный, могу порекомендовать этот скрипт:
Запустив его в командной строке, вы получите web-сервер, позволяющий отлаживать cgi-скрипты и простые страницы. В итоге, должно получиться следующее:
Весь код доступен на гитхабе.
Я рассказал на примере задачи сбора метеоданных о том, как можно построить систему фиксации любых измеряемых процессов — как построить протокол обмена, как защитить данные от искажения при передаче, и как хранить их в базе данных. Читатель, при желании, сможет адаптировать этот проект под свои нужды.
О том, как эти данные потом использовать, можно написать отдельную статью, например, как из старого заброшенного планшета на Android сделать пульт для отображения дневника наблюдений.
Спасибо за внимание, надеюсь, было интересно.
История эта началась в один из дней, когда провод выносного датчика домашней метеостанции был покусан одним из домашних питомцев. Провод я починил, но после того случая прибор стал периодически показывать неправду. Налицо недостаток аналогового измерения, когда небольшого воздействия достаточно для того, чтобы полностью исказить данные. Этот случай и подтолкнул меня сделать собственный подобный комплекс.
После обдумывания проблемы получилось следующее ТЗ:
- Датчики должны быть цифровыми, с приемлемой точностью. Температурные — DS1820, влажности — DHT22, давления воздуха — BMP085. Выбор датчиков был обусловлен их наличием “в закромах”. Кстати, функция измерения температуры есть во всех этих трех типах, но использовать будем именно DS1820, поскольку, их можно включать параллельно.
- Датчики эти должны подключаться «на лету», т.е. не требовать при этом вмешательства оператора.
- Контроллер, к которому должны подключаться датчики, должен быть доступным. Мой выбор пал на Arduino, ибо стоит недорого и имеет минимальный уровень вхождения.
- Контроллер должен подключаться к компьютеру посредством последовательного порта. Будем использовать USB2Serial адаптер, как распространенное и недорогое решение.
- Так как контроллер может располагаться на некотором удалении от компьютера, и контроллеров на одном порту может быть несколько, в протоколе обмена должны быть предусмотрены защита от искажения данных и возможность адресации приборов.
- Комплекс должен хранить в базе данных историю всех измерений. Мой выбор — SQLite.
- Все программы для работы с контроллером должны быть переносимыми, т.е. одинаково работать на разных платформах без серьезных доработок. Мой выбор — Python 2.7.
Описание протокола обмена между компьютером и контроллером
Так как обмен данными с контроллером будет асинхронным, с пакетами заранее неизвестной длины, за основу канального протокола был взят SLIP. Это протокол, в котором передача данных осуществляется при помощи SLIP-кадров. Границами SLIP-кадра является флаг END (0xC0). Если внутри кадра встречается байт 0xC0, он заменяется ESC-последовательностью 0xDB, 0xDC, а если встречается байт ESC (0xDB), он заменяется последовательностью (0xDB, 0xDD). Обратное преобразование симметричное.
В SLIP-кадры будем оборачивать сообщения с заранее рассчитанной контрольной суммой.
Для расчета контрольной суммы был применен алгоритм CRC16 с полиномом 0xA001 (modbus):
- В 16-битовый регистр(CRC) загружается 0xFFFF.
- Первый байт сообщения складывается по ИСКЛЮЧАЮЩЕМУ ИЛИ с содержимым регистра CRC. Результат помещается в регистр CRC.
- Регистр CRC сдвигается вправо на 1 бит, старший бит заполняется 0.
- (Если младший бит 1): Содержимое CRC складывается по ИСКЛЮЧАЮЩЕМУ ИЛИ с полиномиальным числом 0xA001.
- Шаги 3 и 4 повторяются восемь раз.
- Шаги 2 — 5 повторяются для всех последующих байтов посылки.
- Финальное содержание регистра 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 сделать пульт для отображения дневника наблюдений.
Спасибо за внимание, надеюсь, было интересно.