[Из песочницы] Еще один термостат на Arduino, но с OpenTherm

53105733876d4483b6f08190f00b19b8.jpgЧитая первую часть заголовка многие из вас, наверняка, подумали — еще один термостат на многострадальной Arduino. И… Это правда — да, это очередной термостат для очередного котла, очередного дома, но правда это только отчасти — в статье я не хочу концентрироваться на самом устройстве — их (статей) действительно предостаточно. Несомненно, я опишу термостат, но больше хотел бы рассказать о том, как я связывал сам микроконтроллер с котлом. Итак, кому интересно — прошу…

Как все начиналосьПрежде всего хочу сказать, что я нисколько не программист и с настоящим микроконтроллером дела до этого не имел. Мое первое знакомство с МК AVR (да и вообще с МК) было еще в старшей школе, когда мне захотелось узнать, как же все-таки работает эта загадочная штука. Я прочел несколько статей и с тех пор в памяти у меня остались лишь отрывки, которые можно было описать всего двумя словами — DDR и PORT — на этом мои познания и обрывались. Потом был универ, 5-й курс — «Программирование микроконтроллеров» где мы все познакомились с MSC51 в виртуальной среде. Тут уже были и прерывания, и таймеры, и все остальное. Ну, вот с таким багажом знаний я и пришел к проблеме. Закончим на этой автобиографической ноте и перейдем к более интересной части.Итак, собственно, с чего началось создание термостата — после установки автономного отопления с газовым котлом, я, как и многие, столкнулся с обычными проблемами — температура в доме очень зависела от погоды на улице — мороз — в квартире холодно, нужно увеличивать температуру теплоносителя в батареях, потеплело — наоборот. Такие танцы с бубном меня не сильно устраивали, т.к. регулировка котла осложнялась тем, что он был установлен за дверцей, а дверца подперта микроволновкой, на которой лежала куча хлама. Ну, вы поняли — иголка в яйце, яйцо в утке и т.д.

Решалась эта проблема очень просто — датчиком OTC (Outside Temperature Compensation), который подключается к котлу и позволяет ему автоматически подстраивать температуру теплоносителя в зависимости от уличной температуры. Проблема, казалось бы, решена, но чтение сервис-мануала на котел (Ferolli Domiproject C24D) быстро растоптало мою надежду — подключение датчика внешней температуры в данной модели не предусмотрено. Все? Все. И вот, наверное, можно было бы закончить, но летом в котле в грозу до сих пор непонятным мне способом сгорает плата управления, и разговаривая с сервис-мэном (плату в последствии отремонтировали) я спросил, возможно ли подключение OTC на мой котел? Он ответил, что подключают, используя внешние термостаты. Это отложилось у меня в памяти, но я не особо на этом концентрировался до наступления холодов, а дальше всё таже проблема.

Листая все ту же сервисную инструкцию, но уже с целью посмотреть, как же подключается термостат, я заметил, что на те же клеммы подключается «OpenTherm регулятор». Тут-же я понял — вот ОНО! Поиск в Google «OpenTherm Arduino» же меня опять огорчил — ничего особо толкового. Был монитор сообщений, но это не то — мне и слушать, то нечего — нужен именно термостат.

Тут я наткнулся на статью habrahabr.ru/post/214257/, окончание которой меня огорчило — автору без осциллографа так и не удалось связать котел с микроконтроллером. И тут уж если у человека знакомого с МК не вышло, то, что пробовать мне?! В интернете было найдено полное описание протокола Opentherm v2.2, что еще больше охлаждало мой пыл — физический уровень протокола был несколько замудренный — токовая петля в которой данные от котла передаются уровнем тока (5–7 мА — низкий уровень, 17–23 мА — высокий), а от термостата котлу уровнем напряжения (<7В – низкий уровень, 15-18В — высокий), что в свою очередь потребует схемы сопряжения, а я от электроники весьма неблизок. Плюс данные передавались манчестерским кодом, что… ну вы поняли.

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

Итак, вкратце опишу, как работает OT/+ протокол (в вышеприведенной статье описаны виды ОТ). Общение между устройствами происходит в формате запрос-ответ. Инициатором может быть только термостат. Он посылает запрос не реже 1 раза в секунду и ждет ответ от 20 до 800 мс. Сами сообщения — 32-битные с одним стартовым и одним стоповым битами:

Где MSG-TYPE — тип сообщения — для термостата:0×0 — READ-DATA — прочесть данные;0×1 — WRITE-DATA — записать; для котла:0×4 — READ-ACK — подтвердить чтение (в нем приходит ответ);0×5 — WRITE-ACK — подтвердить запись;0×6 — DATA-INVALID — данные неверны;0×7 — UNKNOWN-DATAID — такого DATA-ID нет.

DATA-ID — идентификатор параметра. Первые 128 — зарезервированы и большая часть уже прописана в спецификации, остальные 128 — каждый вендор использует на свое усмотрение.

DATA-VALUE — собственно, само значение параметра. Может быть в разном формате, в зависимости от самого параметра. Два 8-разрадных флаговых значения, беззнековое 16-ти разрядное целое, дробное с фиксированной точкой и т.д. Для каждого параметра определен тип значения.

Для каждого ОТ-устройства определен набор параметров, которое оно должно поддерживать в обязательном порядке:

Data-ID Описание Термостат Котел 0 Флаги состояния устройства Должен отправлять запрос на чтение, с выставленными в старшем байте поля DATA-VALUE требуемыми функциями Должен отвечать на запрос, с выставленными в младшем байте поля DATA-VALUE битами состояния 1 Установка требуемой температуры теплоносителя Должен отправлять запрос на запись с требуемым значение температуры Должен отвечать с подтверждением утановки требуемой температуры 3 Конфигурация котла Должен читать параметр Должен отвечать на запрос и поддерживать все функции переданные в ответе 14 Максимальный уровень модуляции пламени Необязательный параметр Должен быть реализован 17 Текущий уровень модуляции пламени Необязательный параметр Должен быть реализован 25 Температура теплоносителя Необязательный параметр Должен быть реализован Ответ на все остальные параметры — по желанию производителя котла.Немного поясню, что такое модуляция пламени для тех, кто не знает. Все современные газовые котлы умеют регулировать уровень пламени в топке — это называется модуляцией. Чем больше мощность горелки, тем быстрее нагревается теплоноситель, слишком большая мощность приводит к постоянному включению/выключению котла (тактованию), слишком малая — к невозможности достичь заданную температуру. Наилучшим считается режим работы, в котором котел не выключается и горит с такой интенсивностью, которой достаточно для поддержания заданного уровня теплоносителя.

Как видите — все предельно просто — пиши реализацию и получишь управление котлом. Но дьявол, как говорят, кроится в мелочах. Вспомните физический уровень — данные от котла передаются уровнем тока, к котлу уровнем напряжения — человека плохо знакомого с электроникой это ставит в тупик. Питание от котла? Так как управлять напряжением? Нельзя просто подключить провода от котла к Arduino. Для знающих ответ очевиден — это же токовая петля и схема сопряжения довольно проста. Я же нахожусь где-то посредине, а потому полез искать в интернет и довольно быстро нашел сайт otgw.tclcode.com где энтузиасты сделали OpenTherm Gateway, правда, на PIC. Там и оказалась схема сопряжения. Казалось бы — возьми PIC, да прошей — хорошая попытка, лень, но нет — я хочу свое, не такое.

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

Манчестерский код В протоколе Opentherm, для физического представления данных используется манчестерский код, или, как он назван в спецификации Bi-Phase L.Собственно, вот он:

4f36f04b19a4462fa55ada6b8bb0e8aa.png

Вся соль в том, что биты кодируются не уровнем сигнала, а переходом между уровнями в средине периода:

4626b5bab7b146a9b89497a5d4c28710.png

Вдобавок этот самый переход происходит не ровно посредине периода, а где-то около:

0ab68b7aa44a454ab38e355f0b136665.png

Как видно, сам период составляет 1 мс -10%+15% и переход где-то посредине.

Вот на этом я и остановился надолго. Копировать что-то из интернета не было никакого желания, т.к. в каждом проекте есть свой камень преткновения, решив который чувствуешь себя не просто бездумным копипастером библиотек, а разработчиком, который этот самый проект и делает. Это, собственно, и есть сердце термостата — работа с ОТ, скопировать это означает просто собрать очередную игрушку из конструктора, а не создать что-то свое.

Вот здесь-то мне и захотелось применить все свои скудные знания МК — и прерывания и таймеры и т.п. Да на delay () это все гораздо проще, но, во-первых передача слишком медленная, для того чтобы ее принимать с помощью delay (), а мне хотелось во время приема/передачи выполнять другие действия параллельно, во-вторых мне хотелось, чтобы все выглядело достойно, а не как поделка школьника.

Думал я думал — как же красиво отловить тот самый переход, да еще и разобрать был ли это фронт или спад, и стоило мне посмотреть на это под другим углом, как все тут-же стало кристально ясно — зачем вообще разбираться какой был переход?! Ведь уровень первой половины периода и есть искомое значение бита:

10c8e40b47654ee3928f6de111afbfe0.png

Есть первый шаг — брать уровень первой половины периода (где то через 250 мкс после начала) — вот и все декодирование. Но тут меня ждало следующее разочарование — отловить начало периода не всегда представляется возможным: если идет комбинация 01 или 10, то ничего примечательного между периодами не происходит, т.к. очевидно, что уровень не меняется — нужно искать дальше. И тут второе откровение — в средине периода ВСЕГДА происходит переход — именно им кодируются 0 и 1. Значит можно к нему привязаться, и значение следующего бита будет через половину периода! Здесь-то все и стало окончательно ясно.

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

28f88827cc5f4f1a9991be470cd74a8f.png

Все что нужно сделать — включить прерывание по изменению сигнала на входе. Как только это случится, значит, мы ровно посредине периода. Выключаем прерывание по изменению сигнала, обнуляем таймер, и делаем так, чтобы прерывание по таймеру произошло где-то через ¾ периода (что для ОТ составит 750 мкс), при сработке прерывания таймера записать уровень на входе, отключить прерывание таймера, который и является искомым битом и повторить все сначала для всех оставшихся бит.

Особым случаем будет первый бит, т.к. ждать его уровень нужно не ¾, а ¼ периода.

Быстренько были набросаны обработчики прерываний и собрана прошивка, но как же ее отладить без осциллографа и генератора сигнала? На помощь пришли знания из универа — Proteus, в котором можно было пошагово посмотреть, что происходит.

Нашел, установил и тут пошли все прелести практики — оказывается флаги прерываний нужно принудительно убирать перед включениям прерывания, а если загрузить OCR2A/B до изменения режима таймера с FastPWM на CTC, то «такая классная жижа получилась» (сначала я думал что это глюк эмулятора в программе, но как оказалось в железе все еще веселее). Ну и прочие мелочи, которые довольно быстро решились. Напоминаю, что на данном этапе железа у меня еще не было, и конкретных требований к термостату я не предъявлял.

Привожу фрагмент кода, отвечающий за прием манчестерского кода:

inline void OpenTherm: receive (){ cli (); first=1;//receiving first bit buf=0;//clear buffer rx=1; data_ready=0; length=0; parity=0;

PCICR|=bit (rx_pcie); //enabling Pin Change Interrupt for RX port *rx_pcmsk|=rx_bitmask; //enabling Pin Change Interrupt for RX pin of rx Port

TCCR2A=bit (WGM21);//mode CTC TCCR2B=0; OCR2A=TICKS_PER_MS*0.75;//interrupt at 0.75ms TCNT2=TICKS_PER_MS*0.5;//preload 0.5ms TIMSK2=bit (OCIE2A);//interrupt on OC0A TIFR2=bit (OCF2A); sei ();

bool OpenTherm: extIntHandler (){ *rx_pcmsk&=~rx_bitmask; //Disable PCINT for RX pin

if (first) { first=0; } else{ TCNT2=0; } if (length > MSG_LENGTH){ data_ready=1; TCCR2B=0;//disable Timer2 TIMSK2=0; return 1; } else TCCR2B=bit (CS22); //start timer clk/64 return 0; };

void OpenTherm: timer2CompAHandler (){ uint32_t tmp_buf; tmp_buf=buf; tmp_buf=tmp_buf<<1; if (rx) { if (*rx_port & rx_bitmask) { //Reading RX value tmp_buf=tmp_buf | 1; if (length > 1) parity^=1; //Don’t calculate parity for start bit } *rx_pcmsk|=rx_bitmask; //Re-enabling PCINT for RX pin TCCR2B=0; //stop Timer2 } length++; buf=tmp_buf; };

}

Передача, позднее, отлично логически и практически вписалась в эти же обработчики.

Таймер 2 работает все в том же режиме CTC с обнулением каждую миллисекунду, В обработчике прерывания при обнулении (OC2A в режиме CTC) переключаем выход в соответствии со следующим битом. Дополнительно включено прерывание OC2B, срабатывающее ровно на половине периода (0,5 мс) и инвертирующее состояние выхода. Вот и вся премудрость передачи.

Здесь же я собрал схему сопряжения и за часа 3 разобрался с принципом ее работы, который оказался достаточно прост, немного изменил схему, чтобы передавать данные в прямом, а не инвертированном виде, как в оригинале:

1247c1de387b498aa75f126c5417f149.png

Помимо этого пришлось написать (ну как написать, взять с Интернета и изменить под себя) генератор сигнала на HDL, т.к. вручную набрать 34 бита в манчестерском коде, это не 4 и не 5.

В конце концов, прием/передача были налажены, и пришло время определяться с необходимым оборудованием.

Оборудование Итак, в наличии у меня был котел (Ferolli Domiproject C24D) и работающая в эмуляторе прошивка приема/передачи OT сообщений в манчестерском коде. Пора двигаться дальше.Что, прежде всего, необходимо практически каждому устройству на Arduino? Сама Arduino? Неправильно! Энкодер, и обязательно с кнопкой, как же по-другому? Просто меня всегда привлекал этот тип устройства ввода — нужно обязательно попробовать.

Дальше термостату нужно получать температуру в помещении и снаружи — для этих целей вполне подойдет 2 датчика DS18B20, один залить термоклеем и выставить на улицу, второй будет на плате. Естественно нужен дисплей — без него можно просто насыпать радиодеталей в баночку, завернуть в пакет Сильпо и помешать — эффект идентичен. Я решил взять стандартный знакогенерирующий LCD 20×4 — его вполне бы хватило, I2C расширитель портов для LCD. Сам контроллер — вполне достаточно Arduino Nano (сейчас бы я взял Pro Mini с программатором, дальше расскажу почему). Ну и горстку деталей для схемы сопряжения.

Как видно по фотографии в заглавии, что-то пошло не так. У продавца были очень привлекательные цены на все, потому я решил немного изменить перечень. Вместо двух DS18B20 я взял один в влагозащищенном корпусе + DHT22, так что теперь можно будет получать еще и влажность в комнате. LCD 20×4 в наличии не было, и я взял дисплей от Nokia 5110, энкодеров с кнопкой не было, поэтому был взят обычный + 2 кнопки.

После получения все было распаяно и проверено на тестовых примерах. Первым же делом мигаем светодиодом (я так понял при первом включении платы Arduino это святое). Все работало как положено, поэтому можно начинать улучшать.Несмотря на наличие аппаратного SPI на Atmega 328, я решил от него отказаться, т.к. ограничения, накладываемые им, меня не сильно радовали — MISO автоматически настраивается на вход, а SS — обязан быть выходом — минус два выхода, а потому оставляем программный SPI. Убираю из библиотеки управление выходом дисплея CE и сразу на LCD соединяю с GND. Получаю — что-то наподобие:

a5b86118d9054b268b2f24c796f68cc7.jpg

Развожу в Proteus схему сопряжения и пытаюсь повторить что-то подобное на макетной плате:

67abf5d2fd4a4bf0a5afbf10824daa7b.jpg

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

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

Котел работает как нужно, статью можно закрывать.Trollface.jpg

Естественно ничегошеньки не работает.Тыкаю тестером в плату сопряжения — она не работает — на входе всегда 20В. Разбираю, смотрю, улыбаюсь. Плату я паял у друга. Сразу вспомнился диалог.

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

Догадайтесь с первого раза, что я забыл?

Итак «Изделие» исправлено, подключаю, проверяю. Уровни в норме, можно подключать контроллер. Подключаю — тишина. Все — приехали. Дальше без осциллографа делать нечего. Закончу как автор первой статьи. Но есть еще маленькая надежда.

Вспоминаем часть про прием, а конкретно обнуление таймера на изменении уровня в средине периода. Я подумал, мало ли, вдруг обнулять недостаточно и с таймингами входящего сигнала вообще беда, значит, буду выключать его перед этим в момент считывания уровня на четверти периода, и включать с нуля на изменении, — как ни странно, помогло. Это была та самая победа — не общавшись с МК ни разу до этого, написать с нуля библиотеку, которой я не нашел в интернете, да еще чтобы она заработала со второго раза без осциллографа. Моей радости не было предела.

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

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

Отладка и работа с OpenTherm Итак, первое значение — статус котла — получено. Единственное, что сразу же смутило, — температура ГВС при подключении микроконтроллера сразу становилась максимально допустимой, независимо от настройки на панели котла, как я потом прочитал в мануале к штатному OT термостату от производителя (Ferolli Romeo W): при его подключении ручки служат лишь для включения/отключения соответствующих функций, и более ни на что не влияют. Логично было предположить, что значение параметра температуры ГВС останется таким, каким оно было до подключения по ОТ, но у программистов Honeywell (производитель платы и остальной автоматики для котла) свое мнение на этот счет.Процесс управления котлом осложнился тем, что у меня на руках не было образца термостата, чтобы посмотреть правильную последовательность сообщений, так что это было сродни хождения с завязанными глазами. В спецификации был найден параметр DHW Setpoint (здесь и далее в скобках я буду приводить DATA-ID называемых параметров, в данном случае — 56), который, судя по всему, и отвечал за температуру ГВС. Снимаю, прошиваю — теперь при нажатии на кнопку отправлялась нужная температура. Проверяю, так и есть. Температура воды сразу стала нормальной, пора приниматься за регулировку CH Setpoint (1) — температуру контура отопления — то ради чего все и затевалось. Вот здесь и я застрял надолго, дня на три. Как я ни старался, какую бы требуемую температуру не задавал, котел все равно удерживал ее около 30 градусов. У меня уже была идея, что производитель защитился от подключения чужих термостатов — единственным очевидным решением было считать с котла Member-ID (3) и ответить ему таким же (2). И это тоже не помогло, обрисовал вкратце ситуацию другу, и у него появилось предположение: — Слушай, а что если один вендор для ведущего (термостат) и ведомого (котел) устройств использует разные Member-ID. Прикинь, как удивляется автоматика котла, когда понимает, что ей управляет другой котел?

Но все оказалось гораздо проще. Дело было в параметре Max CH Setpoint (57) — максимальная температура контура отопления. После событий с контуром ГВС логично было предположить, что там максимальное значение, но нет, там было установлено значение в 30 градусов. После этого дело пошло заметно веселее.Вот тут и начинается повесть о самом термостате.

Термостат Очевидно, что он должен выдавать кучу параметров котла, и уметь их менять, без этого не было бы вообще смысла создавать очередной термостат. Как я уже говорил каждый запрос нужно отправлять приблизительно раз в секунду, самым логичным было написать конечный автомат, который будет переходить из одного состояния в другое один раз за цикл, а его состояния будут отвечать текущим запрашиваемым параметрам. В конце концов, ветвей автомата получилось 3:1. Рабочая ветвь, в которой опрашивается текущее состояние котла, температура, уровень модуляции. Вкратце ее можно представить так: cefa021268f2454b9c3ce6b87efcf881.png

2. Ветвь получения информации и котле: дополнительные датчики температуры, датчики протока и т.п. При выходе из меню «Инфо», возвращаемся в первую ветвь.3. Ветвь получения статистики: время работы горелки, количество запусков вентиляторов/насосов и т.д. При выходе идентично п.2.

За всю эту работу отвечает вызов функции update (), без параметров. Достаточно вызывать ее приблизительно 1 раз в секунду. Она получает результат предыдущего запроса и на его основании отправляет новый. Для перехода между ветвями нужно вызвать функцию с параметром, который равняется первому параметру желаемой ветви. Например update (0) перейдет в основную ветвь (0 — запрос статуса котла), update (18) — вторая ветвь, которая начинается с запроса параметра 18(запрос давления в контуре отопления), ветвь 3 — параметр 116(количество стартов горелки).

Также есть другие значения аргумента:1 — записать новую температуру контура отопления;56 — записать новую температуру контура ГВС и т.д.

Все возможные аргументы видно в конце функции update.

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

Самым рутинным и скучным занятием, вполне очевидно, оказалось написание меню. Как и обещал, не буду вдаваться в подробности о самом термостате, все можно увидеть в итоговой прошивкеПоследнее, что я решил добавить во время написания этой статьи — вместо скучного экрана с «много букв», сделал подобие графического дисплея (что-то похожее на образец — заводской Romeo W). Ну и часы — их в квартире много не бывает, не хватает только RTC DS323x — при выключении питания время приходится выставлять заново. Для этого пришлось вручную нарисовать несколько символов и добавить их к шрифту. Результат можно увидеть на фото в заголовке и ниже.

12fec153167140e5a1253a20dfccbb8d.jpg

Скетч #include #include #include #include #include #include //#include

#define DHT22_PIN 14

#define LCD_IDLE 0 #define LCD_MAIN 1 #define LCD_MENU 2 #define LCD_CONFIG_1 11 #define LCD_CONFIG_2 12 #define LCD_CONFIG_3 13 #define LCD_INFO_1 21 #define LCD_INFO_2 22 #define LCD_STATISTICS_1 31 #define LCD_STATISTICS_2 32 #define LCD_ITEM_MODE 1 #define LCD_ITEM_CH_EN 2 #define LCD_ITEM_DHW_EN 3 #define LCD_ITEM_CH_MAX 4 #define LCD_ITEM_CH 5 #define LCD_ITEM_DHW 6 #define LCD_ITEM_ROOM 7 #define LCD_ITEM_BRIGH 8 #define LCD_ITEM_ACTIVE 9 #define LCD_ITEM_MAX_MODULATION 10 #define LCD_ITEM_DAYS 11 #define LCD_ITEM_HOURS 12 #define LCD_ITEM_MINUTES 13 #define LCD_ITEM_KP 14 #define LCD_ITEM_KI 15

dht internal_s; OneWire ow (A1); Dallastemp external_s (&ow); OpenTherm ot (8,7); LCD5110 lcd (13,11,10,12,0); //Power sleep;

extern uint8_t SmallFont[]; extern uint8_t MediumNumbers[]; extern uint8_t BigNumbers[];

//Encoder handling volatile uint32_t ts_enc=0; volatile int8_t encoder=0;

//Clock handling uint32_t clock_ts=0, clock_delta=0; uint8_t hour=0, minute=0, second=0, day=0;

//Menu Handling uint8_t menu, item; int8_t pos=0; const char* day_names[7]={«Mon», «Tue», «Wed», «Thu», «Fri», «Sat», «Sun»}; const char* mode_names[2]={«Manual», «Auto»};

//Misc uint8_t display_enabled, cfg_enabled, button1=0, button2=0, item_tmp=0, update_period=0; float iSum;

struct thermostat_config{ uint8_t address[8]; ot_init_settings ot_settings; float indoor_target_temp; uint8_t active_time; uint8_t brightness; uint8_t mode; uint8_t Kp; uint8_t Ki; uint8_t reserved[8]; };

//thermostat_config settings={{0×28,0xFF,0×53,0×76,0×60,0×14,0×02,0xFC},{1,1,70,30.0,40},22.0,30,130,1}; thermostat_config settings;

/* ISR (INT0_vect){ ot.extIntHandler (); } */ ISR (PCINT0_vect){ ot.extIntHandler (); }

ISR (PCINT1_vect){ ot.extIntHandler (); }

ISR (PCINT2_vect){ ot.extIntHandler (); }

ISR (TIMER2_COMPA_vect){ ot.timer2CompAHandler (); }

ISR (TIMER2_COMPB_vect){ ot.timer2CompBHandler (); }

ISR (WDT_vect) { // sleep.watchdogEvent (); }

ISR (INT1_vect){ display_enabled=settings.active_time; OCR1A=settings.brightness; if ((millis ()-ts_enc >20) && cfg_enabled) { ts_enc=millis (); if ((PIND&bit (4))) { encoder++; } else { encoder--; } } }

void fade_display (){ for (uint8_t i=settings.brightness; i>10; i-=10){ OCR1A=i; delay (20); } OCR1A=0; }

void enc_setup (){ cli (); DDRD&=~(bit (3)|bit (4)); //set A and B to input EIMSK|=bit (INT1); EICRA|=bit (ISC11); EICRA&=~bit (ISC10); EIFR|=bit (INTF1); sei (); }

void button_setup (){ DDRC&=~(bit (2)|bit (3));

}

void read_config (){ eeprom_read_block (&settings, 0, sizeof (thermostat_config)); }

void write_config (){ eeprom_write_block (&settings, 0, sizeof (thermostat_config)); }

void lcd_idle (){ char mod_lev[2]={'\0','\0'}; lcd.setFont (BigNumbers); lcd.print (»:»,24,0); lcd.print (»;»,0,24);//»;» — thermometer icon lcd.print (»0»,0,0); lcd.print (»0»,34,0); lcd.print (»<=>»,42,24);//»<=>» — House icon lcd.printNumI (hour,(hour>9)?0:14,0); lcd.printNumI (minute,(minute>9)?34:48,0); lcd.setFont (SmallFont); lcd.print (day_names[day],62,16); lcd.printNumF (internal_s.temperature,1,60,40); switch (ot.status&0×7){ case OT_STATUS_CH: lcd.print (»>»,78,32); break; case OT_STATUS_DHW: lcd.print (»=»,78,32); break; case OT_STATUS_FAULT: lcd.print (»?@»,72,32); break; }; if (ot.status&0×8){ if (ot.modulation < 34) *mod_lev='^'; else if (ot.modulation < 67) *mod_lev='_'; else *mod_lev='`'; lcd.print(mod_lev,72,32); } lcd.setFont(MediumNumbers); lcd.printNumF(external_s.getTemp(settings.address),0,6,32); lcd.printNumI(internal_s.temperature,48,32);

}

void lcd_main (){ lcd.print («Room: /», LEFT,0); lcd.printNumF (internal_s.temperature,1,30,0); if (settings.mode) lcd.printNumF (settings.indoor_target_temp,1,60,0); else lcd.print (» »,54,0); lcd.print («Hum: \%», LEFT,8); lcd.printNumF (internal_s.humidity,1,24,8); lcd.print («Outdoor:», LEFT,16); lcd.printNumF (external_s.getTemp (settings.address),1,48,16); lcd.print («Heat: /», LEFT,24); lcd.printNumF (ot.CH,1,30,24); lcd.printNumF (settings.ot_settings.CH_temp,1,60,24); lcd.print («DHW: /», LEFT,32); lcd.printNumF (ot.DHW,1,24,32); lcd.printNumF (ot.target_DHW,1,54,32); switch (ot.status&0×7){ case OT_STATUS_CH: lcd.print («CH », LEFT,40); break; case OT_STATUS_DHW: lcd.print («DHW », LEFT,40); break; case OT_STATUS_FAULT: lcd.print («FAULT:», LEFT,40); switch (ot.fault&0×3D){ case OT_FAULT_SERVICE: lcd.print («FAULT: SERV REQ», LEFT,40); break; case OT_FAULT_LOW_WATER: lcd.print («FAULT: NO WATER», LEFT,40); break; case OT_FAULT_GAS: lcd.print («FAULT: NO FLAME», LEFT,40); break; case OT_FAULT_AIR_PRESSURE: lcd.print («FAULT: AIR PRES», LEFT,40); break; case OT_FAULT_WATER_OV_TEMP: lcd.print («FAULT: WATER OT», LEFT,40); break; } break; default: lcd.print (» », LEFT,40); break; }; if (ot.status&0×8) { lcd.print («Flame: %»,24,40); lcd.printNumI (ot.modulation,60,40); } }

void lcd_menu (){ lcd.print (» 1)Config», LEFT,0); lcd.print (» 2)Info», LEFT,8); lcd.print (» 3)Stats», LEFT,16); }

void lcd_config_1(){ lcd.print (» Mode:», LEFT,0); lcd.print (mode_names[(item==LCD_ITEM_MODE)? item_tmp: settings.mode], RIGHT,0); lcd.print (» CH enabled:», LEFT,8); lcd.printNumI ((item==LCD_ITEM_CH_EN)? item_tmp: ot.CH_enabled, RIGHT,8); lcd.print (» DHW enabled:», LEFT,16); lcd.printNumI ((item==LCD_ITEM_DHW_EN)? item_tmp: ot.DHW_enabled, RIGHT,16); lcd.print (» CH max t:», LEFT,24); lcd.printNumI ((item==LCD_ITEM_CH_MAX)? item_tmp: ot.CH_max, RIGHT,24); lcd.print (» CH temp:», LEFT,32); lcd.printNumI ((item==LCD_ITEM_CH)? item_tmp: ot.target_CH, RIGHT,32); lcd.print (» DHW temp:», LEFT,40); lcd.printNumI ((item==LCD_ITEM_DHW)? item_tmp: ot.target_DHW, RIGHT,40); }

void lcd_config_2(){ lcd.print (» Room temp:», LEFT,0); lcd.printNumF ((item==LCD_ITEM_ROOM)?(float)item_tmp/10+10: settings.indoor_target_temp,1, RIGHT,0); lcd.print (» Brightness:», LEFT,8); lcd.printNumI ((item==LCD_ITEM_BRIGH)? item_tmp: settings.brightness, RIGHT,8); lcd.print (» Light time:», LEFT,16); lcd.printNumI ((item==LCD_ITEM_ACTIVE)? item_tmp: settings.active_time, RIGHT,16); lcd.print (» Max modul:», LEFT,24); lcd.printNumI ((item==LCD_ITEM_MAX_MODULATION)? item_tmp: settings.ot_settings.max_modulation, RIGHT,24); lcd.print (» Days:», LEFT,32); lcd.print (day_names[(item==LCD_ITEM_DAYS)? item_tmp: day], RIGHT,32); lcd.print (» Hours:», LEFT,40); lcd.printNumI ((item==LCD_ITEM_HOURS)? item_tmp: hour, RIGHT,40); }

void lcd_config_3(){ lcd.print (» Minutes», LEFT,0); lcd.printNumI ((item==LCD_ITEM_MINUTES)? item_tmp: minute, RIGHT,0); lcd.print (» Kp», LEFT,8); lcd.printNumI ((item==LCD_ITEM_KP)? item_tmp: settings.Kp, RIGHT,8); lcd.print (» Ki», LEFT,16); lcd.printNumI ((item==LCD_ITEM_KI)? item_tmp: settings.Ki, RIGHT,16); }

void lcd_info_1(){ lcd.print (» CH press:», LEFT,0); lcd.printNumF (ot.CH_water_pressure,1, RIGHT,0); lcd.print (» DHW flow:», LEFT,8); lcd.printNumF (ot.DHW_flow,1, RIGHT,8); lcd.print (» Ret.temp:», LEFT,16); lcd.printNumF (ot.CH_return_temp,1, RIGHT,16); lcd.print (» Max cap.:», LEFT,24); lcd.printNumI (ot.max_capacity, RIGHT,24); lcd.print (» Min mod.:», LEFT,32); lcd.printNumI (ot.min_modulation, RIGHT,32); lcd.print (» DHW min lim:», LEFT,40); lcd.printNumI (ot.DHW_min_lim, RIGHT,40); }

void lcd_info_2(){ lcd.print (» DHW lim:», LEFT,0); lcd.printNumI (ot.DHW_max_lim, RIGHT,0); lcd.print (» CH lim:», LEFT,8); lcd.printNumI (ot.CH_min_lim, RIGHT,8); lcd.print (» CH lim:», LEFT,16); lcd.printNumI (ot.CH_max_lim, RIGHT,16); lcd.print (» Int.Sum.:», LEFT,24); lcd.printNumF (iSum,1, RIGHT,24); }

void lcd_stats_1(){ lcd.print (» Burn.st:», LEFT,0); lcd.printNumI (ot.burner_starts, RIGHT,0); lcd.print (» CH pump:», LEFT,8); lcd.printNumI (ot.CH_pump_starts, RIGHT,8); lcd.print (» DHW p/v:», LEFT,16); lcd.printNumI (ot.DHW_pump_starts, RIGHT,16); lcd.print (» DHW bur:», LEFT,24); lcd.printNumI (ot.DHW_burner_starts, RIGHT,24); lcd.print (» B.hours:», LEFT,32); lcd.printNumI (ot.burner_op_hours, RIGHT,32); lcd.print (» CHpump H», LEFT,40); lcd.printNumI (ot.CH_pump_op_hours, RIGHT,40); }

void lcd_stats_2(){ lcd.print (» DHWp/v H», LEFT,0); lcd.printNumI (ot.DHW_pump_op_hours, RIGHT,0); lcd.print (» DHWburn H», LEFT,8); lcd.printNumI (ot.DHW_burner_op_hours, RIGHT,8); }

void update_clock (){ uint32_t tmp=millis ()-clock_ts; clock_delta+=(tmp>0)? tmp:0; clock_ts=millis (); while (clock_delta >= 1000){ second++; clock_delta-=1000; if (second > 59){ second=0; minute++; if (minute > 59){ minute=0; hour++; if (hour > 23){ hour=0; day++; if (day>6) day=0; } } } } }

void update_display (){ lcd.clrScr (); lcd.setFont (SmallFont); if (menu!= LCD_MAIN && menu!= LCD_IDLE) { if (pos>5){ pos=0; switch (menu){ case LCD_INFO_1: menu=LCD_INFO_2; break; case LCD_STATISTICS_1: menu=LCD_STATISTICS_2; break; case LCD_CONFIG_1: menu=LCD_CONFIG_2; break; case LCD_CONFIG_2: menu=LCD_CONFIG_3; break; default: pos=5; } } if (pos<0){ pos=5; switch(menu){ case LCD_INFO_2: menu=LCD_INFO_1; break; case LCD_STATISTICS_2: menu=LCD_STATISTICS_1; break; case LCD_CONFIG_2: menu=LCD_CONFIG_1; break; case LCD_CONFIG_3: menu=LCD_CONFIG_2; break; default: pos=0; } } };

switch (menu){ case LCD_IDLE: lcd_idle (); break; case LCD_MAIN: lcd_main (); break; case LCD_MENU: lcd_menu (); break; case LCD_CONFIG_1: lcd_config_1(); break; case LCD_CONFIG_2: lcd_config_2(); break; case LCD_CONFIG_3: lcd_config_3(); break; case LCD_INFO_1: lcd_info_1(); break; case LCD_INFO_2: lcd_info_2(); break; case LCD_STATISTICS_1: lcd_stats_1(); break; case LCD_STATISTICS_2: lcd_stats_2(); break; }; if (menu!= LCD_IDLE && menu!= LCD_MAIN) if (! item) lcd.print (»\\», LEFT, pos*8); else lcd.print (»*», LEFT, pos*8); }

void setup (){ lcd.InitLCD (); lcd.setFont (SmallFont); lcd.clrScr (); enc_setup (); button_setup (); read_config (); //sleep.measure_wdt (1); // external_s.setRes (settings.address, TEMP_11_BIT); // external_s.startConv (settings.address); analogWrite (9, settings.brightness); display_enabled=settings.active_time; lcd.print («OT init.», CENTER,16); ot.begin (&settings.ot_settings); //sleep.measure_wdt (0); lcd.clrScr (); lcd.print («Slave OT ver.:», CENTER,0); lcd.printNumF (ot.slave_ver,1, CENTER,8); lcd.print («Slave Memb. ID:», CENTER,16); lcd.printNumI (ot.member_id, CENTER,24); lcd.print («Slave CFG:», CENTER,32); lcd.printNumI (ot.sl_cfg, CENTER,40); } void loop (){

//DEBUG //uint8_t type=0, id=0; //uint16_t data=0; float ext_temp, int_temp, t_error;

if (! update_period) { internal_s.read22(DHT22_PIN); external_s.startConv (settings.address); } if (display_enabled){ OCR1A=settings.brightness; if (! --display_enabled) { fade_display (); cfg_enabled=0; menu=LCD_IDLE; item=0; delay (1000); ot.update (0); //main thread update_display (); } }

if (cfg_enabled) delay (100); else delay (1000); //sleep 1s here // else sleep.sleep ();

if (cfg_enabled || ! update_period) { update_display (); }

if ((PINC&bit (2)) && button1) button1=0; //if button released reset state if ((PINC&bit (3)) && button2) button2=0; //Esc if (! (PINC&bit (2)) && ! button1) { button1=1; display_enabled=settings.active_time; OCR1A=settings.brightness; cfg_enabled=1; switch (menu){ case LCD_IDLE: menu=LCD_MAIN; break; case LCD_MAIN: if ((ot.status&0×7) == OT_STATUS_FAULT) { delay (1000); ot.communicate (1,4,256); } break; case LCD_MENU: menu=LCD_MAIN; break; case LCD_CONFIG_1: case LCD_CONFIG_2: case LCD_CONFIG_3: if (! item) menu=LCD_MENU; else item=0; break; default: menu=LCD_MENU; break; }; pos=0; }

//Enter if (! (PINC&bit (3)) && ! button2) { button2=1; display_enabled=settings.active_time; OCR1A=settings.brightness; cfg_enabled=1; if (! item){ //standart menu navigation switch (menu){ case LCD_IDLE: case LCD_MAIN: menu=LCD_MENU; break; case LCD_MENU: switch (pos){ case 0: menu=LCD_CONFIG_1; break; case 1: menu=LCD_INFO_1; delay (1000); ot.update (18); //start to get info break; case 2: menu=LCD_STATISTICS_1; delay (1000); ot.update (116); //start to get stats break; } break; case LCD_CONFIG_1: if (! item) item=pos+1; case LCD_CONFIG_2: if (! item) item=pos+7; case LCD_CONFIG_3: if (! item) item=pos+13; switch (item){ case LCD_ITEM_MODE: item_tmp=settings.mode; break; case LCD_ITEM_CH_EN: item_tmp=ot.CH_enabled; break; case LCD_ITEM_DHW_EN: item_tmp=ot.DHW_enabled; break; case LCD_ITEM_CH_MAX: item_tmp=ot.CH_max; break; case LCD_ITEM_CH: item_tmp=settings.ot_settings.CH_temp; break; case LCD_ITEM_DHW: item_tmp=ot.target_DHW; break; case LCD_ITEM_ROOM: item_tmp=(uint8_t)(settings.indoor_target_temp*10 — 100); break; case LCD_ITEM_BRIGH: item_tmp=settings.brightness; break; case LCD_ITEM_ACTIVE: item_tmp=settings.active_time; break; case LCD_ITEM_MAX_MODULATION: item_tmp=settings.ot_settings.max_modulation; break; case LCD_ITEM_DAYS: item_tmp=day; break; case LCD_ITEM_HOURS: item_tmp=hour; break; case LCD_ITEM_MINUTES: item_tmp=minute; break; case LCD_ITEM_KP: item_tmp=settings.Kp; break; case LCD_ITEM_KI: item_tmp=settings.Ki; break; } break; case LCD_INFO_1: break; case LCD_INFO_2: break; }; if (! item) pos=0; } else { //item save switch (item){ case LCD_ITEM_MODE: settings.mode=item_tmp; break; case LCD_ITEM_CH_EN: ot.CH_enabled=item_tmp; settings.ot_settings.CH_enabled=item_tmp; break; case LCD_ITEM_DHW_EN: ot.DHW_enabled=item_tmp; settings.ot_settings.DHW_enabled=item_tmp; break; case LCD_ITEM_CH_MAX: ot.CH_max=item_tmp; settings.ot_settings.CH_max_temp=item_tmp; delay (1000); ot.update (57); break; case LCD_ITEM_CH: settings.ot_settings.CH_temp=item_tmp; ot.target_CH=item_tmp; delay (1000); ot.update (1); break; case LCD_ITEM_DHW: ot.target_DHW=item_tmp; settings.ot_settings.DHW_temp=item_tmp; delay (1000); ot.update (56); break; case LCD_ITEM_ROOM: settings.indoor_target_temp=(float)item_tmp/10+10.0; break; case LCD_ITEM_BRIGH: settings.brightness=item_tmp; break; case LCD_ITEM_ACTIVE: settings.active_time=item_tmp; break; case LCD_ITEM_MAX_MODULATION: ot.max_modulation=item_tmp; settings.ot_settings.max_modulation=item_tmp; delay (1000); ot.update (14); break; case LCD_ITEM_DAYS: day=item_tmp; break; case LCD_ITEM_HOURS: hour=item_tmp; break; case LCD_ITEM_MINUTES: minute=item_tmp; break; case LCD_ITEM_KP: settings.Kp=item_tmp; break; case LCD_ITEM_KI: settings.Ki=item_tmp; break; } write_config (); item_tmp=0; item=0; } } /* ot.complete (&type,&id,&data); lcd.setFont (SmallFont); lcd.print (» / /», LEFT,40); lcd.printNumI (type,0,40); lcd.printNumI (id,12,40); lcd.printNumI (data,36,40); //debug */ if (encoder!=0) { if (menu!= LCD_MAIN) if (! item) pos=constrain (pos+encoder,-1,6); else switch (item){ case LCD_ITEM_MODE: item_tmp=constrain (item_tmp+encoder,0,1); break; case LCD_ITEM_CH_EN: item_tmp=constrain (item_tmp+encoder,0,1); break; case LCD_ITEM_DHW_EN: item_tmp=constrain (item_tmp+encoder,0,1); break; case LCD_ITEM_CH_MAX: item_tmp=constrain (item_tmp+encoder, ot.CH_min_lim, ot.CH_max_lim); break; case LCD_ITEM_CH: item_tmp=constrain (item_tmp+encoder, ot.CH_min_lim, ot.CH_max_lim); break; case LCD_ITEM_DHW: item_tmp=constrain (item_tmp+encoder, ot.DHW_min_lim, ot.DHW_max_lim); break; case LCD_ITEM_ROOM: item_tmp=constrain (item_tmp+encoder,50,180); break; case LCD_ITEM_BRIGH: item_tmp=constrain (item_tmp+encoder*10,0,255); break; case LCD_ITEM_ACTIVE: item_tmp=constrain (item_tmp+encoder,10,100); break; case LCD_ITEM_MAX_MODULATION: item_tmp=constrain (item_tmp+encoder,10,100); break; case LCD_ITEM_DAYS: item_tmp=constrain (item_tmp+encoder,0,6); break; case LCD_ITEM_HOURS: item_tmp=constrain (item_tmp+encoder,0,23); break; case LCD_ITEM_MINUTES: item_tmp=constrain (item_tmp+encoder,0,59); break; case LCD_ITEM_KP: case LCD_ITEM_KI: item_tmp=constrain (item_tmp+encoder,0,255); break; item_tmp=constrain (item_tmp+encoder,0,255); break; } else if (settings.mode) settings.indoor_target_temp=constrain (settings.indoor_target_temp+encoder*0.1,15.0,28.0); else settings.ot_settings.CH_temp+=encoder*0.1; encoder=0; };

if (menu == LCD_IDLE && ! cfg_enabled && ! update_period--) { update_period=60; update_clock (); int_temp=internal_s.temperature; ext_temp=external_s.getTemp (settings.address); t_error=settings.indoor_target_temp-int_temp; iSum=constrain (iSum+t_error,-ot.CH_max_lim, ot.CH_max_lim); if (settings.mode) settings.ot_settings.CH_temp=1*(20.0 + (settings.indoor_target_temp-ext_temp)) + settings.Kp*t_error + settings.Ki*iSum/256; if (settings.ot_settings.CH_temp < ot.CH_min_lim) ot.CH_enabled=0; else ot.CH_enabled = 1; settings.ot_settings.CH_temp=constrain(settings.ot_settings.CH_temp,ot.CH_min_lim,ot.CH_max_lim); if ((!settings.mode && ot.target_CH != settings.ot_settings.CH_temp ) || (settings.mode && abs(ot.target_CH - settings.ot_settings.CH_temp) > 0.5)){ ot.target_CH=settings.ot_settings.CH_temp; // delay (1000); ot.update (1); return; } } ot.update (); } Формулу вычисления требуемой температуры теплоносителя долго крутил-выводил, пока она, в результате, не оказалась ПИ регулятором. В настройки добавил изменение коэффициентов и индикацию интегральной составляющей. До самого ПИ регулятора, для вычисления требуемой температуры я взял формулу из первой статьи, но без интегральной составляющей температура либо не соответствовала требуемой, либо, при большом пропорциональном коэффициенте не соответствовала требуемой и прыгала туда-сюда. Но чтение Хабра быстро дало мне ссылку с описанием ПИД-регулятора, который и был реализован.Вообще я сделал два режима работы:

1) Прямое управление CH Setpoint — в результате получилось ни что иное как выносная передняя панель котла (ну плюс еще показывает температуры снаружи/внутри и влажность).2) С

© Habrahabr.ru