Содержание

Логические выражения и ветвление

Для работы с целыми числами в C/C++ существует тип переменных int. Но операции над целыми числами — не единственный инструмент в арсенале разработчика. Одними из важнейших элементов в программировании являются логические операции, выражения и типы данных.

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

Алгебра логики — умное название для интуитивно понятных вещей: операций «и», «или», «не» над высказываниями, которые либо справедливы, либо нет. Например, рассмотрим такое логическое выражение: «в коридоре темно и по коридору идёт человек». Оно состоит из двух логических значений (утверждения про темноту и человека) и одного оператора (союз «и»). Его итоговым значением также будет логическое значение, которое можно использовать, скажем, как сигнал для включения света в коридоре гостиницы.

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

Логические операции в C++

В C++ для булевых переменных существует тип bool. Для обозначения истины в C++ используется слово true, а для обозначения лжи — слово false. Таким образом, если для объявления целочисленных переменных мы используем:

int myValue = 42;

Для логических переменных, используется:

bool tooDark = false;
bool humanDetected = true;

Поскольку C++ для Arduino — это некоторая надстройка над голым C++, для обозначения логического типа наряду с bool существует ещё слово boolean. Это абсолютные синонимы. Можете использовать и то и другое. Просто из соображений единого стиля и повышения читаемости кода выберите один из терминов и используйте его всюду. Мы в статье будем использовать bool, просто потому что в нём меньше букв и он входит в стандарт языка.

Применение на практике: экологичный отель

Давайте разовьём тему с энергосберегающим освещением и сделаем на Arduino устройство, которое включает свет в коридоре только тогда, когда это действительно необходимо.

Допустим, к Arduino подключён аналоговый датчик уровня освещённости, пироэлектрический цифровой датчик движения тёплых объектов и экологичная светодиодная лампа.

Тогда скетч будет выглядеть следующим образом:

#define LIGHT_SENSOR_PIN    A0
#define MOTION_SENSOR_PIN   2
#define LED_LAMP_PIN        5
 
#define LIGHT_LEVEL_THRESHOLD   600
 
void setup()
{
    pinMode(LIGHT_SENSOR_PIN, INPUT);
    pinMode(MOTION_SENSOR_PIN, INPUT);
    pinMode(LED_LAMP_PIN, OUTPUT);
}
 
void loop()
{
    int lightLevel = analogRead(LIGHT_SENSOR_PIN);
    bool motionDetected = digitalRead(MOTION_SENSOR_PIN);
    bool tooDark = lightLevel < LIGHT_LEVEL_THRESHOLD;
    bool lightningRequired = tooDark && motionDetected;
    digitalWrite(LED_LAMP_PIN, lightningRequired ? HIGH : LOW);
}

Взглянем на loop и поймём что здесь происходит. С первой строкой всё понятно: мы считываем значение освещённости с аналогового сенсора с помощью встроенной функции analogRead и присваиваем его переменной с именем lightLevel.

Далее мы объявляем логическую переменную motionDetected в качестве значения которой присваиваем результат вызова встроенной функции digitalRead. Функция digitalRead похожа по своей сути на analogRead, но может возвращать лишь одно из двух значений: либо истину (true), либо ложь (false). То есть это функция, которая возвращает логическое значение. Она подходит для считывания показаний разнообразных бинарных цифровых датчиков. В нашем примере мы как раз использовали такой: пироэлектрический сенсор, который выдаёт 0 вольт, пока движения в его радиусе видимости нет и 5 вольт, когда замечено перемещение тёплого объекта: человека, кошки или кого-то ещё. Нулю вольт микроконтроллер ставит в соответствие значение false, а пяти вольтам — true.

Итак, по итогам исполнения второй строки, в переменной motionDetected будет храниться либо истина, либо ложь в зависимости от того замечено ли движение в коридоре.

Далее мы видим определение булевой переменной tooDark, которой в качестве значения присваивается значение выражения lightLevel < LIGHT_LEVEL_THRESHOLD. Символ <, как можно догадаться, в C++ означает оператор «меньше, чем». Из двух численных операндов, этот оператор делает один логический результат. Значение всего выражения считается истинным, если то, что записано слева от знака (lightLevel в нашем случае) меньше, чем то, что записано справа от знака (LIGHT_LEVEL_THRESHOLD). Довольно логично и интуитивно понятно, не правда ли?!

Таким образом в переменной tooDark окажется значение true, только если значение уровня освещённости lightLevel, полученное ранее, окажется меньше 600. В противном случае, в переменной окажется false.

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

Идём дальше. Мы видим объявление булевой переменной lightningRequired, в качестве значения которой присваивается значение выражения tooDark && motionDetected. Символ && в C++ означает оператор логического «и». Как можно догадаться, всё выражение считается истинным тогда и только тогда, когда и то, что справа, и то что слева от оператора истинно. Таким образом, переменная lightningRequired примет значение true, если в коридоре одновременно: и слишком темно, и замечено движение человека. Если не выполнено хоть одно из условий, результатом будет false. Как раз то, что нужно.

И наконец, последним выражением идёт вызов функции digitalWrite для пина Arduino, к которому подключена лампа. Нам нужно включить лампу, если переменная lightningRequired содержит истинное значение и выключить, если в ней хранится ложь. Чтобы сделать это, мы используем тернарный условный оператор ? :. Где в качестве условия используем просто значение переменной lightningRequired. Помните? В тернарном операторе условие считается выполненным, если его значение — не ноль; и не выполненным если его значение — ноль.

На самом деле для процессора не существует понятия логических значений. Всё что он умеет — оперировать над целыми числами. Поэтому в C++ существует автоматическое преобразование типов. Там, где ожидается int, а мы используем bool, за кадром происходит автоматическое преобразование: true превращается в целое число 1, а false — в целое число 0.

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

Возвращаясь к нашему примеру, в выражении:

digitalWrite(LED_LAMP_PIN, lightningRequired ? HIGH : LOW);

второй аргумент примет значение HIGH, если lightningRequired — это true; и LOW, если lightningRequired — это false. Таким образом, мы добились чего хотели: включения света, если он нужен и выключения, если он не уместен.

О краткости записи

Если вспомнить об автоматическом преобразовании переменных разных типов, а ещё о том, что HIGH — это ничто иное, как макроопределение числа 1, а LOW — макроопределение числа 0, последнюю строку в нашем примере можно сократить до лаконичного выражения:

digitalWrite(LED_LAMP_PIN, lightningRequired);

Мы обошлись без тернарного оператора: ведь всё равно функция digitalWrite получит:

Кроме того, логические выражения — это самые обычные выражения в C++, к которым применяются общие правила встраивания. Поэтому весь код loop в примере с экологичным освещением на самом деле мог бы быть записан в одну строку:

void loop()
{
    digitalWrite(LED_LAMP_PIN, analogRead(LIGHT_SENSOR_PIN) < LIGHT_LEVEL_THRESHOLD && digitalRead(MOTION_SENSOR_PIN));
}

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

void loop()
{
    bool tooDark = analogRead(LIGHT_SENSOR_PIN) < LIGHT_LEVEL_THRESHOLD;
    bool motionDetected = digitalRead(MOTION_SENSOR_PIN);
    digitalWrite(LED_LAMP_PIN, tooDark && motionDetected);
}

Или хотя бы использовать перенос строк для наглядности:

void loop()
{
    digitalWrite(LED_LAMP_PIN, 
        analogRead(LIGHT_SENSOR_PIN) < LIGHT_LEVEL_THRESHOLD &&
        digitalRead(MOTION_SENSOR_PIN)
    );
}

А если свет таки нужен: условное выражение if

Внимательный читатель мог заметить, что приведённый скетч едва ли может работать в реальных условиях. Если мы включим свет, когда слишком темно и зафиксировано движение, при следующем же прогоне loop датчик «увидит» свет от нашей же лампы, программа посчитает, что и так достаточно светло и выключит лампу. Процесс постоянного выключения и включения так и будет продолжаться пока пироэлектрический датчик будет фиксировать движение. В лучшем случае мы получим свечение лампы в пол силы, в худшем — раздражающее мерцание. Как быть?

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

Как сделать так, чтобы задержка на 30 секунд производилась только после включения лампы, но не после выключения? Для этого в C++ существует условное выражение «if». Используя его, скетч может выглядеть следующим образом:

#define LIGHT_SENSOR_PIN    A0
#define MOTION_SENSOR_PIN   2
#define LED_LAMP_PIN        5
 
#define LIGHT_LEVEL_THRESHOLD   600
 
void setup()
{
    pinMode(LIGHT_SENSOR_PIN, INPUT);
    pinMode(MOTION_SENSOR_PIN, INPUT);
    pinMode(LED_LAMP_PIN, OUTPUT);
}
 
void loop()
{
    bool tooDark = analogRead(LIGHT_SENSOR_PIN) < LIGHT_LEVEL_THRESHOLD;
    bool motionDetected = digitalRead(MOTION_SENSOR_PIN);
 
    if (tooDark && motionDetected) {
        digitalWrite(LED_LAMP_PIN, HIGH);
        delay(30000); // спать 30 секунд
    } else {
        digitalWrite(LED_LAMP_PIN, LOW);
    }
}

Начало программы прежнее, но в loop появляется новое составное выражение, обозначаемое словами if, else и фигурными скобками. Давайте поймём в чём его суть.

Сразу после слова if компилятор C++ ожидает в круглых скобках увидеть логическое выражение, которое в этом случае называется условием. Оно имеет тот же смысл, что и для тернарного оператора. Если его значение — не ноль или истинно, выполняется блок кода, который следует сразу после условия в фигурных скобках. В нашем случае, если tooDark и motionDetected истины, будет выполнен блок кода из двух строк:

digitalWrite(LED_LAMP_PIN, HIGH);
delay(30000); // спать 30 секунд

Если же условие было нулём, или что то же самое — ложно, блок кода следующий за условием пропускается и не выполняется вовсе. Зато, если после после этого пропущенного блока следует слово else, выполняется блок кода в фигурных скобках, следующий после этого слова. В нашем случае, если либо tooDark, либо motionDetected были false, выполнится блок кода из одной строки:

digitalWrite(LED_LAMP_PIN, LOW);

Стоит отметить, что в случае выполнения условия, блок кода следующий после else не выполняется, а пропускается. То есть, на самом деле, выражение if — это отображение в языке программирования простого и понятного утверждения: «если что-то, делай то-то, а иначе делай сё-то».

Возвращаясь к нашему примеру, код можно интерпретировать так: «если требуется освещение, включить свет и уснуть на 30 секунд, а если свет не нужен — выключить его». Довольно просто и логично.

Использование выражений, которые меняют ход программы в зависимости от каких-то условий называется ветвлением программы, а блоки кода после if и else называются ветками.

Краткая версия if

При использовании выражения if совершенно не обязательно использовать ветку else. Если что-то должно произойти при выполнении условия, а при невыполнении не должно происходить ничего, ветку else можно просто опустить. Например, мы можем изменить loop нашей программы следующим образом:

void loop()
{
    bool tooDark = analogRead(LIGHT_SENSOR_PIN) < LIGHT_LEVEL_THRESHOLD;
    bool motionDetected = digitalRead(MOTION_SENSOR_PIN);
    bool lightningRequired = tooDark && motionDetected;
 
    digitalWrite(LED_LAMP_PIN, lightningRequired);
 
    if (lightningRequired) {
        delay(30000); // спать 30 секунд
    }
}

Эта вариация кода делает абсолютно то же, что и раньше. Просто теперь код организован иначе. Мы в любом случае делаем указание лампе на включение или выключение, вызывая digitalWrite с булевым значением lightningRequired, но засыпаем на 30 секунд только если мы включаем свет, т.е. если переменная lightningRequired истинна. Если мы только что выключали свет, спать не нужно: нужно пропустить delay и сразу оказаться в конце функции loop. Поэтому мы не писали ветку else вовсе.

Более того, если в блоке кода после if или else содержится всего одно выражение, фигурные скобки можно не писать:

if (lightningRequired)
    delay(30000); // спать 30 секунд

Вложенные выражения if

Блоки кода, используемые в условных выражениях — это самые обычные блоки, которые следуют общим правилам C++. Поэтому в ветку одного if можно запросто вкладывать другой if.

Для примера рассмотрим скетч устройства, которое контролирует открывание двери холодильника, автомобиля или чего-то такого. Мы хотим, чтобы при открытии двери включалась подсветка, а если дверь остаётся открытой более 20 секунд, раздавался бы предупреждающий писк. При этом, хочется, чтобы писк можно было отключить переключателем, на случай когда производится длительная загрузка/разгрузка: так он не будет нервировать.

Допустим на дверце установлен постоянный магнит, а на раме бинарный цифровой датчик магнитного поля. Если магнитное поле фиксируется — дверца закрыта; иначе магнит отходит от датчика слишком далеко, магнитного поля нет — можно понять, что дверца открыта. Также мы подключим к Arduino лампу подсветки, пьезо-пищалку (buzzer) и рокерный выключатель.

В этом случае код работающего устройства может выглядеть так:

#define DOOR_SENSOR_PIN    2
#define BUZZER_PIN         3
#define SWITCH_PIN         4
#define LAMP_PIN           5
 
#define BUZZ_TIMEOUT       20
#define BUZZ_FREQUENCY     4000
 
void setup()
{
    pinMode(DOOR_SENSOR_PIN, INPUT);
    pinMode(SWITCH_PIN, INPUT);
    pinMode(BUZZER_PIN, OUTPUT);
    pinMode(LAMP_PIN, OUTPUT);
}
 
void loop()
{
    bool doorOpened = !digitalRead(DOOR_SENSOR_PIN);
 
    if (doorOpened) {
        digitalWrite(LAMP_PIN, HIGH);
        delay(BUZZ_TIMEOUT * 1000);
        bool buzzEnabled = digitalRead(SWITCH_PIN);
        if (buzzEnabled) {
            tone(BUZZER_PIN, BUZZ_FREQUENCY);
        }
    } else {
        digitalWrite(LAMP_PIN, LOW);
        noTone(BUZZER_PIN);
    }
}

Давайте разберём происходящее в loop. Первым делом мы определяем переменную doorOpened и присваиваем ей значение выражения !digitalRead(DOOR_SENSOR_PIN). Как уже говорилось, дверь стоит считать открытой, если магнитного поля нет, т.е. digitalRead для нашего сенсора возвращает false. Символ ! перед логическим выражением в C++ означает оператор логического «не» или просто оператор отрицания. Он действует на значение, записанное после него, и из true делает false, а из false делает true. Как раз то, что нам нужно в этом случае.

Далее следует выражение if, проверяющее открыта ли дверь. Если да, начинается исполнение блока кода, следующего непосредственно за условием. В нём мы первым делом включаем подсветку. Затем засыпаем на 20 секунд (20×1000 мс).

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

Обратите внимание, переменная buzzEnabled объявлена прямо внутри блока кода, следующего за if. Так делать можно и нужно: хорошей практикой является объявление переменных как можно ближе к тому месту, где они впервые используются.

Напомним о понятии области видимости переменных: переменные доступны для использования только внутри того блока, где они объявлены. В нашем случае buzzEnabled может быть использована в выражениях внутри блока кода, следующего за if (doorOpened), но попытка обращения к ней откуда-то ещё: например, из блока кода ветки else или непосредственно в loop вне ветки if, приведёт к ошибке на этапе компиляции программы. И это хорошо: нам не стоит впутывать переменную, которая нужна сию секунду в другие происходящие процессы; это делает программу чище и нагляднее.

Вслед за чтением переключателя следует вложенное условное выражение. Его суть абсолютно та же, что и ранее: в зависимости от условия выполнить или не выполнить код. Одно лишь отличие: оно расположено прямо в блоке кода ветки другого if. Это распространённая практика, которая встречается в программировании довольно часто. На самом деле, во вложенный if можно вкладывать другие if, в них ещё одни и т.д. до бесконечности: язык C++ вас в этом не ограничивает.

Возвращаясь к примеру, если переключатель находится в положении «включён», т.е. buzzEnabled содержит true, мы включаем писк. Это делается с помощью встроенной функции tone. Она принимает 2 аргумента: номер пина Arduino, куда подключён пьезоизлучатель и частоту писка. В данном случае, мы выбрали частоту 4000 Гц, т.е. 4 КГц.

Наконец, если дверь была закрыта, т.е. doorOpened содержит ложь, мы убеждаемся, что подсветка и пищалка выключены. Это делается в ветке else первого условия if. Как выключить лампу вы понимаете, а функция noTone, как можно догадаться, отключает писк на указанном пине.

Вспоминая о правилах короткой записи и встраивании выражений, мы можем чуть упростить код loop. Он может выглядеть так:

void loop()
{
    if (!digitalRead(DOOR_SENSOR_PIN)) {
        digitalWrite(LAMP_PIN, HIGH);
        delay(BUZZ_TIMEOUT * 1000);
        if (digitalRead(SWITCH_PIN))
            tone(BUZZER_PIN, BUZZ_FREQUENCY);
    } else {
        digitalWrite(LAMP_PIN, LOW);
        noTone(BUZZER_PIN);
    }
}

Мы убрали фигурные скобки для внутреннего if и встроили вызовы digitalRead прямо в условные выражения.

Обратите внимание, как правильное использование отступов в блоках кода, помогает легко понять к какому if относится else и что за чем и при каких условиях следует. Никогда не пишите без отступов несмотря на то, что компилятору всё равно. Это делает код не читаемым и для других людей, и для вас самих:

void loop()
{
if (!digitalRead(DOOR_SENSOR_PIN)) {
digitalWrite(LAMP_PIN, HIGH);
delay(BUZZ_TIMEOUT * 1000);
if (digitalRead(SWITCH_PIN))
tone(BUZZER_PIN, BUZZ_FREQUENCY);
} else {
digitalWrite(LAMP_PIN, LOW);
noTone(BUZZER_PIN);
}
}

Клики кнопок

Одной из типичных задач при создании устройств на микроконтроллерах является определение момента клика тактовой кнопки, чтобы на это можно было как-то отреагировать. Проблема в том, что кнопка, как сенсор, может лишь сообщить зажата ли она сейчас или отпущена. Она не может сообщить, что была «нажата только что».

Но эта проблема легко решается с помощью небольшого приёма в программной части проекта. Давайте сделаем примитивное устройство с лампой и кнопкой, которое бы включало и выключало лампу, поочерёдно, при нажатии кнопки: клик — включили, клик — выключили:

#define BUTTON_PIN    2
#define LAMP_PIN      5
 
bool lampState = false;
bool wasButtonDown = false;
 
void setup()
{
    pinMode(BUTTON_PIN, INPUT);
    pinMode(LAMP_PIN, OUTPUT);
}
 
void loop()
{
    bool isButtonDown = digitalRead(BUTTON_PIN);
    if (isButtonDown && !wasButtonDown) {
        lampState = !lampState;
        delay(10);
    }
 
    wasButtonDown = isButtonDown;
    digitalWrite(LAMP_PIN, lampState);
}

В скетче мы можем видеть 2 булевы переменные:

Теперь взглянем на сам loop. Первым делом мы считываем состояние кнопки в логическую переменную isButtonDown. Если кнопка зажата isButtonDown будет содержать true.

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

Таким образом, условие стоит воспринимать как «кнопка зажата сейчас и не была зажата ранее». Это и есть условие сиемоментного «клика» кнопкой.

Если условие клика выполнено — действуем. Переворачиваем значение lampState с ног на голову:

lampState = !lampState;

Далее, в ветке if следует delay(10). Вызов delay здесь сделан исключительно из-за несовершенства механических кнопок. При нажатии, за микросекунды, когда соприкасаются пластины, кнопка может зафиксировать замыкание и размыкание десятки раз. Добавив delay(10) мы просто пережидаем шторм. Десять миллисекунд — более чем достаточно для успокоения кнопки, но достаточно мало, чтобы человек заметил это.

Далее мы присваиваем переменной wasButtonDown недавно считанное значение isButtonDown, говорящее о том нажата ли кнопка сейчас. Если кнопка была зажата только что, произойдёт переключение состояния лампы lampState, а переменная wasButtonDown в итоге примет значение true и будет оставаться с ним пока кнопку не отпустят. Таким образом, при следующем вызове loop состояние не будет изменено снова.

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

Цепочки if

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

Тогда программа может выглядеть так:

#define CONDITIONER_PIN   4
#define HEATER_PIN        5
#define OK_LED_PIN        6
#define THERMISTOR_PIN    A0
 
#define TEMP_MIN  400
#define TEMP_MAX  500
 
void setup()
{
    pinMode(CONDITIONER_PIN, OUTPUT);
    pinMode(HEATER_PIN, OUTPUT);
    pinMode(OK_LED_PIN, OUTPUT);
}
 
void loop()
{
    int temp = analogRead(THERMISTOR_PIN);
 
    if (temp < TEMP_MIN) {
        digitalWrite(CONDITIONER_PIN, LOW);
        digitalWrite(HEATER_PIN, HIGH);
        digitalWrite(OK_LED_PIN, LOW);
    } else if (temp > TEMP_MAX) {
        digitalWrite(CONDITIONER_PIN, HIGH);
        digitalWrite(HEATER_PIN, LOW);
        digitalWrite(OK_LED_PIN, LOW);
    } else {
        digitalWrite(CONDITIONER_PIN, LOW);
        digitalWrite(HEATER_PIN, LOW);
        digitalWrite(OK_LED_PIN, HIGH);
    }
}

В этой программе всё должно быть знакомым. Вопрос может вызвать лишь использование else и if рядом, на одной строке. На самом деле — это всего лишь иной способ записать несколько вложенных условных выражений. Мы могли бы написать то же самое таким образом:

if (temp < TEMP_MIN) {
    digitalWrite(CONDITIONER_PIN, LOW);
    digitalWrite(HEATER_PIN, HIGH);
    digitalWrite(OK_LED_PIN, LOW);
} else {
    if (temp > TEMP_MAX) {
        digitalWrite(CONDITIONER_PIN, HIGH);
        digitalWrite(HEATER_PIN, LOW);
        digitalWrite(OK_LED_PIN, LOW);
    } else {
        digitalWrite(CONDITIONER_PIN, LOW);
        digitalWrite(HEATER_PIN, LOW);
        digitalWrite(OK_LED_PIN, HIGH);
    }
}

Но вспомним, что условное выражение — это полноправное выражение в C++. В внешней ветке else оно одно, поэтому фигурные скобки можно опустить:

if (temp < TEMP_MIN) {
    digitalWrite(CONDITIONER_PIN, LOW);
    digitalWrite(HEATER_PIN, HIGH);
    digitalWrite(OK_LED_PIN, LOW);
} else
    if (temp > TEMP_MAX) {
        digitalWrite(CONDITIONER_PIN, HIGH);
        digitalWrite(HEATER_PIN, LOW);
        digitalWrite(OK_LED_PIN, LOW);
    } else {
        digitalWrite(CONDITIONER_PIN, LOW);
        digitalWrite(HEATER_PIN, LOW);
        digitalWrite(OK_LED_PIN, HIGH);
    }

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

if (temp < TEMP_MIN) {
    digitalWrite(CONDITIONER_PIN, LOW);
    digitalWrite(HEATER_PIN, HIGH);
    digitalWrite(OK_LED_PIN, LOW);
} else if (temp > TEMP_MAX) {
    digitalWrite(CONDITIONER_PIN, HIGH);
    digitalWrite(HEATER_PIN, LOW);
    digitalWrite(OK_LED_PIN, LOW);
} else {
    digitalWrite(CONDITIONER_PIN, LOW);
    digitalWrite(HEATER_PIN, LOW);
    digitalWrite(OK_LED_PIN, HIGH);
}

Подобные цепочки из выражений if — довольно частое явление. Если бы вариантов было больше трёх, вложенные if смотрелись бы громоздко и уродливо, в то время как такая цепочка выглядела бы всё равно понятно и хорошо.

На самом деле, программу климат-контроля можно написать компактнее. В этом виде она приведена просто для демонстрации if - else if - else цепочки. Для полноты картины приведём краткую версию:

#define CONDITIONER_PIN   4
#define HEATER_PIN        5
#define OK_LED_PIN        6
#define THERMISTOR_PIN    A0
 
#define TEMP_MIN  400
#define TEMP_MAX  500
 
void setup()
{
    pinMode(CONDITIONER_PIN, OUTPUT);
    pinMode(HEATER_PIN, OUTPUT);
    pinMode(OK_LED_PIN, OUTPUT);
}
 
void loop()
{
    int temp = analogRead(THERMISTOR_PIN);
    bool tooCold = temp < TEMP_MIN;
    bool tooHot = temp > TEMP_MAX;
 
    digitalWrite(CONDITIONER_PIN, tooHot);
    digitalWrite(HEATER_PIN, tooCold);
    digitalWrite(OK_LED_PIN, !(tooCold || tooHot));
}

Опять же, всё знакомо за исключением, быть может значения выражения !(tooCold || tooHot). Символ || в C++ — это оператор логического «или». Значение выражения истинно если хотя бы одно из значений: слева или справа от оператора истинны.

Логические выражения — ни что иное, как арифметические выражения. Здесь действуют всё те же правила: в одном выражении может быть сколько угодно операторов, а очерёдность их применения можно обозначить скобками. В данном случае, мы сначала вычисляем логическое значение tooCold || tooHot, а затем оператором логического «не» (!) инвертируем полученное значение.

Итак, вы познакомились с логическими переменными, выражениями и сопутствующими операторами. Это уже позволяет делать устройства, которые выглядят умными. Включайте фантазию, дерзайте!