Цифровой термометр (ч. 1)
Май 12, 2020 в 01:08  •  40 мин  •  читали 699 раз

Всем привет! Давненько я ничего не писал, увы. Но, наконец то, появилась тема для статьи, собсна, вот он и я тут как тут.


Речь пойдет о цифровом термометре. Вот небольшое описание того, что должно получиться:

Мы хотим сделать цифровой термометр, чтобы у него был экран и он выводил туда температуру с точностью до .0


Было бы круто, если бы GUI прибора выглядел примерно так:


Теперь кое-что из фич:

  • Возможность менять шкалу измерения (масштаб)
  • Возможность менять интервал обновления
  • Устройство должно рисовать график температуры от времени
  • Возможность программирования хуков для автоматизации чего либо, но об этом позже

Перед нами стоит довольно трудная задача: все это нам нужно уместить в 8 килобайт флеша нашего МК. Нужно решить вопрос питания, потому что мы хотим, чтобы наше устройство было портативным насколько это возможно, таже должна быть возможность стационарного питания, чтобы можно было установить устройство у себя в сарае и греть курочек холодными зимними вечерами. Опционально - сделать датчик на проводе, чтобы интерфейс управления находился не в одном месте с датчиком.


Это первая часть и тут мы обсудим некоторые моменты, связанные с прошивкой устройства. Очевидно, что для написания прошивки мы должны знать и аппаратные параметры нашей будущей поделки.

Это что то типа такого:

  • Датчик температуры DS18B20
  • Экран от Nokia 5110,
  • ATmega8 (ниже доки)


В следующей части мы соберем аппаратную часть устройства и протестируем его.


*** тут заканчивается введение ***


Сразу дам ссылку на гитхаб:


Работать мы будем с AVR. Если точнее, то с atmega8 в среде WinAVR (avr-gcc).


ЧАСТЬ ПРО ЭКРАН


Пойдем сразу с козырей и рассмотрим библиотеку для работы с экраном. Честно сказать я ее позаимствовал вот тут и немного переписал. Хочу сказать ее автору огромное спасибо! В общем-то вся суть заключается в определении процедуры рисования пикселя, ибо любое изображение - комбинация пикселей. А потом уже определение других процедур в данном с позволения сказать базисе.


Экран следует понимать как микросхему оперативной памяти, которая помимо энергозависимого хранения данных может еще и отображать их. Мы определяем тип byte, который на самом деле есть unsigned char, т.е. принимает значения [0;255][0;255]. Мы инициализируем буфер (массив типа byte), размер (или длина) которого равна WH/8W*H/8, где W - ширина экрана, H - высота. Мы знаем, что каждый пиксель экрана может находиться только в одном из двух взаимоисключающих состояний (он может быть белым или черным), следовательно нам требуется один бит информации для хранения состояния пикселя. Таким образом, в одном элементе нашего массива (буфера) мы сможем хранить информацию сразу о 8 пикселях.


Теперь мы должны определить процедуру отправки нашего видеобуфера на экран. Этим занимается функция lcd_update(), так же время от времени нам будет необходимо чистить наш видеобуфер, сделать это мы можем с помощью lcd_clear(). Каждая из этих функций работает напрямую с экраном по SPI. Всего наш экран распознает два вида посылок: data и cmd. Будет удобно определить две процедуры отправки данных/команд по SPI.


Алгоритмы рисования линий - это чистая математика, у тому же это легко гуглится, поэтому рассмотрим, пожалуй, рисование символов (char).


У нас есть большой массив, в котором содержится информация о том, как должны выглядеть символы. Каждый символ это матрица пикселей 585*8. Матрица из 0 и 1. Массив определяется в EEPROM памяти, потому что program и data места не хватает для всей прошивки.

%lang(c)%
#ifndef __ALPHABET__
#define __ALPHABET__

#include <avr/eeprom.h>

const unsigned char CHARS_TABLE[] EEMEM = {
  0x00, 0x00, 0x00, 0x00, 0x00,   // (space)
  0x00, 0x00, 0x5F, 0x00, 0x00,   // !
  0x00, 0x07, 0x00, 0x07, 0x00,   // "
  0x14, 0x7F, 0x14, 0x7F, 0x14,   // #
  0x24, 0x2A, 0x7F, 0x2A, 0x12,   // $
  0x23, 0x13, 0x08, 0x64, 0x62,   // %
  0x36, 0x49, 0x55, 0x22, 0x50,   // &
  0x00, 0x05, 0x03, 0x00, 0x00,   // '
  ...
};

#endif

Вот так символы выводятся на экран:

%lang(c)%
void lcd_put_char(byte x0, byte y0, unsigned char ch, Color c) {
	for (int i = 0; i < 5; i++) {
		for (int j = 0; j < 8; j++) {
			if ((eeprom_read_byte(&CHARS_TABLE[(ch - 32) * 5 + i]) >> j) & 1) lcd_pixel(x0 + i, y0 + j, c);
		}
	}
}


Символы в массиве идут в порядке их расположения в таблице символов со смещением 32. Алгоритм, я думаю, объяснять не нужно.


Гораздо интереснее дела обстоят с картиночками (в нашем проекте они пригодятся).


Вот так они задаются:

%lang(c)%
const byte small_s[] EEMEM = {
	0b11001111, 
	0b1111001
};
			   
const byte ball[] EEMEM = {
	0b111100,
	0b1111110,
	0b11111111,
	0b10111111,
	0b10111111,
	0b11011111,
	0b1111110,
	0b111100
};


А вот так выводятся на экран:

%lang(c)%
void lcd_draw(const byte *texture, byte x, byte y, byte w, byte h, Color c) {
	for (int j = 0; j < h; j++) {
		for (int i = 0; i < w; i++) {
			char addr = j * w + i; 
			if (eeprom_read_byte(&texture[addr / 8]) >> (addr % 8) & 1) lcd_pixel(x + i, y + j, c);
		}
	}
}


Тут, наверное, нужно некоторое пояснение. Дело в том, что в byte мы можем засунуть только 8 бит, однако картинка может разных размеров (размер задается через w, h аргументы функции). Но и склеить все байты в один мы не можем, точнее можем, но максимум 8 (поместив их в long long), что даже звучит как то не очень. Поэтому мы просто считаем общий сдвиг, а потом сдвиг относительно байта и байт в котором нужно применить этот самый сдвиг. Круто же?


Данные о текстурах так же хранятся в EEPROM.


Вот внешний вид текстур из примера выше (колба на конце термометра и маленькая буква s).


ЧАСТЬ ПРО ВРЕМЯ


Нам нужно каким то образом ориентироваться на время. Например, конвертирование температуры с 12 битным разрешением по даташиту DS18B20 занимает 750 мс времени! Это значит, что если бы мы не использовали асинхронный механизм нам пришлось бы останавливать весь поток выполнения (в котором также рендер и опрос кнопок) на 750 мс. Это критично. Поэтому мы вводим такой функционал. Кстати, данный функционал так же полезен для реализации механизма антидребезга (debounce) кнопок.


По даташиту мы можем посмотреть, что у ATmega8 есть 8-битный таймер, отлично, его и возьмем. В нем нет вообще никаких наворотов. Просто таймер, просто считает, то что нужно.


Тут даже можно привести весь код этой библиотеки:

%lang(c)%
#ifndef __CLOCK__
#define __CLOCK__

#include <avr/interrupt.h>

#define CLK 8000000

typedef unsigned long long timestamp;

timestamp __clock__ = 0;

#define PRESCALER 256
#define TICKS (CLK / PRESCALER)
#define INTS_P_SEC (TICKS / 256)
#define RATE (1000 / INTS_P_SEC)

ISR(TIMER0_OVF_vect) {
    __clock__ += RATE;
}

void clock_init(void) {
	TCCR0 = 0b100; // 31 250 ticks/s
	TIMSK |= 1;
	sei();
}

timestamp clock(void) {
	return __clock__;
}

#endif


Что же тут происходит?


Мы объявляем некоторые константы и настраиваем таймер на работу с делителем частоты 256. Это значит, что таймер будет совершать 31250 тиков в секунду. Но не было бы никакого смысла в таймере, если б нам пришлось опрашивать его в цикле. В нашей задаче нам нужен именно бэкграундный процесс, который работает сам по себе. И тут нам на помощь приходят прерывания.


Что такое прерывание?

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

.

Важно понять, что это происходит само по себе по так называемому вектору прерывания. Мы нигде явно не вызываем обработчик, об этом заботится ядро AVR на аппаратном уровне.


У таймера Timer/Counter 0 (тот, с которым мы работаем) есть только прерывание по переполнению счетчика. Существует регистр TCNT0, который инкрементируется 31250 раз в секунду. Регистр восьмибитный, значит обработчик прерываний отработает ~122 раза. Значит, в каждом обработчике мы должны прибавлять у __clock__ 1000/1221000 / 122. Так, через секунду мы получим в __clock__ примерно 1000.


Как итог, мы создали специальный счетчик, который считает сколько времени (в мс) прошло с момента включения прибора. Для хранения значения мы определили тип timestamp, по размеру он как long long - 8 байт, а так как тип беззнаковый мы сможем хранить в нем значения от 0 до 26412^{64} - 1.


Переменная переполнится через 584942417.3550721 лет после включения. Функция clock() просто возвращает нам актуальное значение.


ЧАСТЬ ПРО ДАТЧИК


Датчик температуры DS18B20 работает через интерфейс OneWire, данные гоняются по одному проводу в обоих направлениях.

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


Прием бита:

%lang(c)%
char dt_rx(void) {
	unsigned char stack = SREG;
	cli();
	char bit;
	DT_DDR |= 1 << DT_SENSOR;
	_delay_us(2);
	DT_DDR &= ~(1 << DT_SENSOR);
	_delay_us(14);
	bit = (DT_PIN & (1 << DT_SENSOR)) >> DT_SENSOR;
	_delay_us(45);
	SREG = stack;
	return bit;
}


Все эти вещи описаны в даташите. Мы дрыгаем ногой (только не уровнем на ней, а направлением), затем принимаем бит данных и возвращаем его из функции.


Обратите внимание, какой мы здесь используем прием. Мы сохраняем значение регистра SREG в локальную переменную и глобально запрещаем прерывания. Это сделано для того, чтобы не попасть в обработчик (которые, у нас кстати есть). Если мы попадем в обработчик, временные интервалы будут нарушены и все. КОНЕЦ. (датчик нас просто не поймет). После того, как функция отработала перед возвращением значения мы восстанавливаем значение регистра SREG и наши часики опять начинают работать как надо. Вся эта операция занимает очень мало времени, поэтому не критично, что наш системный таймер на какое то время будет остановлен.


Кстати функция sei() - называется так потому что разрешает прерывания (set interrupts). А фунция cli() запрещает (clear interrupts).


По аналогии отправка бита:

%lang(c)%
void dt_tx(char b) {
	char stack = SREG;
	cli();
	DT_DDR |= 1 << DT_SENSOR;
	_delay_us(2);
	if (b) DT_DDR &= ~(1 << DT_SENSOR);
	_delay_us(65);
	DT_DDR &= ~(1 << DT_SENSOR);
	SREG = stack;
}


Отправка и передача байта это просто цикл, в каждой итерации которого вызывается отправка/передача бита. Нетрудно догадаться, что итераций 8.


Еще нам пригодится вот такая функция, она говорит нам, есть ли кто нибудь по ту сторону провода (1 - есть, 0 - нет).

%lang(c)%
char dt_test(void) {
	unsigned char stack = SREG;
	cli();
	char dt;
	DT_DDR |= 1 << DT_SENSOR;
	_delay_us(500);
	DT_DDR &= ~(1 << DT_SENSOR);
	_delay_us(70);
	if ((DT_PIN & (1 << DT_SENSOR)) == 0) {
		dt = 1;
	} else {
		dt = 0;
	}
	SREG = stack;
	_delay_us(420);
	return dt;
}


Воркфлоу работы с датчиком таков:

  • мы заказываем конвертацию значения и ждем
  • после ожидания мы читаем значение


%lang(c)%
void dt_convert(void) {
	if (!dt_test()) return;
	dt_tx8(0xCC);
	dt_tx8(0x44);
	// then conversion delay
}

int dt_read(void) {
	unsigned char l;
	unsigned int h = 0;
	if (!dt_test()) return 0;
	dt_tx8(0xCC);
	dt_tx8(0xBE);
	l = dt_rx8();
	h = dt_rx8();
	h = (h << 8) | l;
	return h;
}


0xCC - пропустить идентификацию. Дело в том, что у каждого датчика есть свой уникальный идентификатор. И мы можем подключить несколько датчиков на один провод. Потом, опрашивая их мы будем знать с какого датчика приходит информация (это полезно, подумайте). Мы пропускаем идентификацию потому что датчик у нас один.


0x44 - запросить конвертацию. После этой команды датчик начнет конвертацию физического значения температуры в какую то дрисню очень удобный формат. В зависимости от конфига (позже об этом) на эту операцию требуется разное количество времени.


0xBE - после конвертации жадюга-датчик не спешит отдавать нам то, что наконвертировал. Он кладет это себе в память и мы еще должны вежливо попросить его отдать нам значение. Эта команда обкашливает вопросы за нас.


Теперь про конфиг. Датчик поддерживает 4 разрешения конвертации


Принцип такой: точнее -> дольше


По умолчанию разрешение равно 12 битам, нам так много не надо, хватит и 9, а потому нам нужна команда


0x4E - записывает 3 следующих байта в конфиг датчика. Третий байт выглядит вот так:

Биты 6 и 5 - то, что нам и нужно, другие биты readonly, так что смело можно отправить туда просто ноль.


Инициализируем датчик вот так:

%lang(c)%
void dt_init(void) {
	dt_tx8(0x4E);
	dt_tx8(0xFF);
	dt_tx8(0xFF);
	dt_tx8(0x00);
}


Теперь наш датчик готов к работе и скоро мы рассмотрим и это.


ЧАСТЬ ПРО I/O


Мы хотим управлять как то нашим приборчиком. Более того, мы хотим, чтобы наш приборчик управлял чем-нибудь. Для этого мы будем использовать GPIO.


Опять я могу приложить весь код библиотеки, потому что она относительно маленькая

%lang(c)%
#ifndef __IO__
#define __IO__

#include <avr/io.h>

#define I_DDR DDRC
#define O_DDR DDRD
#define IO_PORT PORTD
#define IO_PIN PINC
#define PORT_SIZE 4

typedef enum { A = 0, B = 1, C = 2, D = 3 } Button;
typedef enum { Z = 4, Y = 5, X = 6, W = 7 } Port;

void io_init(void) { 
	I_DDR = 0;
	O_DDR = 0b11110000;
}

void io_write(Port o, char value) {
	if (value) {
		IO_PORT |= (1 << o);
	} else {
		IO_PORT &= ~(1 << o);
	}
}

int io_read(Button b) { 
	return (IO_PIN >> b) & 1; 
}

#endif


Я, если честно даже не знаю, что тут комментировать. Поэтому вкратце расскажу про GPIO в AVR.


Всего у нас есть три регистра для каждого порта. Порты обычно обозначаются латинскими буквами. Например, здесь мы работаем с С и D. В порте может быть разное количество ножек.


Если в порте 8 ножек, то регистр принимает 8 бит. Вообще все регистры в AVR восьмибитные. Если в порте 6 ножек, то регистр пример первые 6 бит справа, а остальные проигнорирует. Но лучше не экспериментировать, думаю.


Вот их нумерация:

  76543210
0b00000000


Регистры как бы привязаны к физическим ножкам микроконтроллера.


DDRx отвечает за направление порта. 1 - это выход, 0 - вход

Например, DDRC = 0b101 сделает выходами PC0 и PC2, в то время как PC1 - все еще вход.

(это типа pinMode, ардуинщики)


PORTx - регистр в который следует писать. Логика абсолютно такая же как и у DDRx, только 1 - это высокий уровень, а 0 - низкий уровень. Этот регистр используется для установки уровня на ножке, когда она сконфигурирована как выход.

(это типа digitalWrite, ардуинщики)


PINx - регистр из которого следует читать. Логика абсолютно такая же как и у PORTx, 1 - высокий уровень, а 0 - низкий уровень. Этот регистр используется для чтения уровня на ножке, когда она сконфигурирована как вход. Значение может меняться независимо от программы, например кто то реально соединил ножку и 5 вольт.

(это типа digitalRead, ардуинщики)


Искренне надеюсь, что код выше стал более понятным после этого пояснения.


Окончательную схему устройства я опубликую во второй части

(без нее часть про I/O не особо интересная, простите)


ЧАСТЬ С НЕБОЛЬШИМ ДЕМО


Наверное, самая интересная часть в этой статье.

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



Вот как то так.


Примерно таким образом мы опрашиваем датчик:

%lang(c)%

/* 
 * Non-blocking temp conversion
 */
  		 
if (!conversion) {
    dt_convert();
	conversion = 1;
  	conversion_start = clock();
}
if (clock() - conversion_start > CONV_TIME) {
  	actual_t = dt_read();
  	conversion = 0;
}


conversion - переменная флажок, которая говорит нам запрошено ли конвертирование. Мы сохраняем временную метку в момент запроса и пытаемся прочитать только после того, как пройдет время CONV_TIME, равное 100 мс. Все это крутится в цикле, а потому повторяется пока есть питание.


Вот так мы опрашиваем кнопки:

%lang(c)%
if (io_read(D) && clock() - debounce_d > DEBOUNCE_T) {
	change_view_mode();
	debounce_d = clock();
}

Кнопка будет опрашиваться не чаще DEBOUNCE_T, это предохраняет нас от странного поведения, когда вы нажали кнопку, а она сработала 2357245 раз.


Весь остальной код проекта - хуки и рисование, последнее не особо интересно, а вот хуки рассмотрим подробнее.


Что это и зачем?

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



Например, вот такая настройка

  • установит в 1 выход Z, если температура будет больше 4 градусов
  • установит в 1 выход X, если температура будет меньше либо равна 0 градусов
  • сбросит в 0 выход X, если температура будет меньше -4 градусов


Хуки применяются последовательно. Это означает, что выход X будет все таки 0, при температуре меньше -4 (несмотря на 3 хук).


Всего можно зарегистрировать 4 хука.

Хук состоит из

  • Операции сравнения =, <, >, <=, >=
  • Значение температуры для сравнения [55;99][-55; 99]
  • Действие при положительном результате сравнения set (установит), res (сбросить), nop (ничего не делать)
  • Выход применения (Z, Y, X, W)


К выходам устройства можно подключить устройство для управления (потребление не более 200 мА). Таким образом логика хуков может, например влючать обогреватель, когда холодно и выключать когда жарко. При этом лог изменений можно посмотреть на графике.

Хуки применяются в момент обновления температуры, это значит, что скорость обновления (их 5) влияет и на хуки.


Устройство поддерживает 5 скоростей обновления:

  • X1 - 1000 мс
  • X2 - 500 мс
  • X2 - 100 мс
  • M - 1 мин
  • H - 1 час


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


Спасибо за внимание!

Копирование материалов допускается только с разрешения автора (vladivanov.dev@gmail.com) в письменной форме.
(Copying of materials is allowed only with the written permission of the author)
Похожие статьи