Помодоро-таймер на Attiny45

Коробка с ардуино и кучкой электронных компонентов стояла у меня в шкафу уже несколько лет. Я купил её в районе 2017-го года, побаловался с Arduino IDE, потом с программатором, приехавших ко мне с aliexpress, помигал диодами и успокоился. Интерес иссяк, придумать проект, который вдохновил бы меня на действия, мне так и не удалось. Я пережил несколько переездов, сменил город, переехал ещё раз, каждый раз с упорством таская за собой оранжевую коробку, с надписью Amperka на боку, и, наконец-то, спустя пять лет, понял что таскал её не зря. Что мне снова хочется что-то делать. И у меня даже была идея - помидоро таймер.

Мой список требований к прибору выглядел так:

Примеряем компоненты на площадке

Bill of materials

Вот так выглядела получившаяся в KiCad схема

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

Пройдемся по коду

В main.c файле у нас содержится общий код проекта, осуществляющий оркестрацию работы устройства. Например, там хранится текущий режим работы, в виде enum и переменной для его хранения:

enum status_e
{
    ON,
    WORK,
    REST
};

// int8_t чтобы сэкономить пару спичек на хранении значения
int8_t status = ON;

Для перехода из статуса в статус написано три функции, выполняющие всю нужную работу. Так как они более-менее однотипные, то я покажу только одну.

void
status_to_rest()
{
    status = REST;
    // подаем 1 на ножку с зеленым диодом, и 0 на ножку с красным
    PORTB &= ~(1 << LED_RED);
    PORTB |= (1 << LED_GREEN);
    // играем мелодию (об этом ниже)
    play_melody(&rest_melody);
    // вызываем переключение в другой статус через REST_MIN минут (опять же, подробности ниже)
    set_timer_callback(REST_MIN, &status_to_on);
}

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

Сама конструкция может выглядеть загадочно, но там работает обычная битовая логика. (1 << LED_GREEN) дает нам число вида 00001000, с единичкой на том месте, которое соответствует пину, связанному с зеленым диодом. Соответственно выражение PORTB |= (1 << LED_GREEN) устанавливает в единичку этот же самый бит (так как мы выполняем побитовое ИЛИ), а PORTB &= ~(1 << LED_GREEN) наоборот, зануляет его (так как мы выполняем побитовое И с инвертированным значением, таким в котором на нужном месте, и только на нем, стоит нолик).

В самой функции main мы сначала настраиваем контроллер под наши нужды:

// указываем что обе эти ножки работают на вывод
DDRB |= (1 << LED_GREEN);
DDRB |= (1 << LED_RED);

// указываем какой именно режим сна нам нужен и разрешаем его использование
set_sleep_mode(SLEEP_MODE_PWR_DOWN);
sleep_enable();
// включаем обработку прерываний
sei();

// настраиваем наши прерывания, инициализируем код связанный с музыкой и таймером
init_interrupt(switch_status);
init_music();
init_timer();

DDRB это ещё один специальный регистр. Он также относится к порту B, но регулирует не состояние, а режим работы ножки. Ножка может работать как выход, то есть мы можем сами подавать на неё ток или убирать его, а может как вход. В последнем случае мы хотим проверять есть ли напряжение на ножке и, если есть, то сколько. Эти две ножки должны управлять диодами, так что мы включаем для них режим вывода.

Теперь, когда все настроено, мы устраиваем маленький стартовый концерт:

// зажигаем все диоды
PORTB |= (1 << LED_GREEN);
PORTB |= (1 << LED_RED);
// играем стартовую мелодию
play_melody(&start_melody);
// по её окончании все диоды гасим
PORTB &= ~(1 << LED_GREEN);
PORTB &= ~(1 << LED_RED);

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

while (true) {
    if (sleep)
    {
        sleep = false;
        sleep_cpu();
    }
}

Что будет, если нажать на кнопку

Чтобы упростить код нашей программы, мы не будем проверять нажата ли кнопка на каждом цикле. Микроконтроллер может сам сообщать нам, если её состояние меняется. Происходит это с помощью механизма прерываний. Чуть выше мы использовали функцию init_interrupt, чтобы повесить на нажатие кнопки смену режим работы устройства. Давайте посмотрим на её код.

void
init_interrupt(void (*on_press) (void))
{
    // сохраняем коллбэк функцию, чтобы вызвать её позже
    callback = on_press;

    // ножку, к которой привязана кнопка, переключаем в режим ввода
    DDRB &= ~(1 << PB2);
    // включаем внешние прерывания
    GIMSK |= (1 << INT0);
}

Режим работы микроконтроллера можно настраивать меняя состояние тех или иных регистров. Мы уже работали с ножками, меняя DDRB и PORTB, теперь же мы воспользуемся новым регистром - GIMSK. В документации на Attiny45 он гордо именуется General Interrupt Mask Register и выставляя на нем INT0 в единичку мы включаем внешние прерывания. Теперь изменение напряжения на ножке PB2 будет приводить к срабатыванию прерывания, которое остановит нормальное выполнение программы и передаст управление сециальному куску кода. Вот этому:

ISR (INT0_vect)
{
    if (PINB & (1 << PB2))
        return;
    _delay_ms(30);
    if (PINB & (1 << PB2))
        return;
    (*callback)();
}

ISR это специальный макрос, который позволяет нам определять функции для обработки прерываний. В скобках после него указывается название прерывания, в данном случае INT0_vect. Далее мы два раза, с перерывом в 30мс, проверяем состояние кнопки. Делается это чтобы избежать ложных срабатываний при дребезге контактов. Между контактами кнопки могут проскакивать отдельные электрические сигналы, особенно если мы начинаем её нажимать или отпускать, приближая контакты друг к другу. Два замера через 30мс дают нам большую уверенность, в том, что прерывание сработало на полноценное нажатие кнопки. После этого мы вызываем коллбэк функцию, которая меняет режим работы устройства и проигрывает какую-нибудь мелодию.

Издаем звуки

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

Звук это волна, но так как наш микроконтроллер работает по цифровым, а не по аналоговым принципам, то просто так подать волну на пьезодинамик мы не можем. Мы будем имитировать её с помощью PWM, широтно-импульсной модуляции. Суть её состоит в том, что вместо того, чтобы подавать на выход 0.5 мы будем 50% времени держать на выходе 1 и 50% времени 0. Если делать это достаточно быстро, то для внешнего наблюдателя это будет выглядеть как работа в половину мощности.

void
init_music(void)
{
    // ножка с пьезодинамиком работает на выход и, по началу, сигнал на неё мы не даем
    DDRB |= (1 << SOUND);
    PORTB &= ~(1 << SOUND);

    TCCR0A |= (1 << WGM01);
    TCCR0A |= (1 << COM0A0);
}

TCCR0A это ещё один регистр нашего микроконтроллера, отвечающий за настройки его внутреннего таймера. Устанавливая в единичку бит WGM01 мы говорим, что работать он должен в режиме CTC (Clear Timer on Compare Match). При каждом срабатывании таймера его значение будет увеличиваться на единицу и сравниваться со значением, лежащим в регистре OCR0A. Когда они окажутся равны, то состояние ножки с динамиком переключится, а когда значение переполнится и отсчет начнется с 0, то состояние ножки поменяется ещё раз. Таким образом мы можем управлять тем, какой процент времени на ножку подается напряжение. Это значение называется Duty Cycle или коэффициент заполнения.

Мелодия для проигрывания у нас хранится в простенькой структуре, содержащей число нот и сами эти ноты.

typedef struct melody_s {
    uint8_t length;
    uint8_t tones[];
} melody_t;

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

#define TONE_A 42
#define TONE_AS 39
#define TONE_B 37
#define TONE_C 71
#define TONE_CS 67
#define TONE_D 63
#define TONE_DS 59
#define TONE_E 56
#define TONE_F 53
#define TONE_FS 50
#define TONE_G 47
#define TONE_GS 44

#define DELAY 250

Значения для нот высчитывались исходя из частоты микроконтроллера, но статью с формулами для расчета я потерял. Так что просто поверьте в эти значения. Сами мелодии объявлены в main.c, вот пример одной из них:

melody_t start_melody = {
    .length = 5,
    .tones = {
        TONE_A,
        TONE_D,
        TONE_G,
        TONE_E,
        TONE_B
    }
};

Логика функции, которая играет музыку, крайне проста:

void
play_melody(const melody_t *melody)
{
    // запускаем таймер, выставляя ему частоту срабатывания
    TCCR0B |= (1 << CS01);

    for (int8_t i = 0; i < melody->length; i++)
    {
        // выставляем duty cycle, равный значению текущей ноты
        OCR0A = melody->tones[i];
        // пока мы здесь ждем 250мс, нота продолжает звучать
        _delay_ms(DELAY);
    }

    // выключаем таймер
    TCCR0B &= ~(1 << CS01);
}

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

Ещё один таймер

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

void
init_timer(void)
{
    // отключаем прерывания, чтобы они не помешали нам полностью настроить таймер
    cli();
    // на каждом шаге таймер будет сранивать свое значени вот с этим, как было с `OCR0A` у таймера звука
    OCR1A = 244;
    // указываем что этот таймер тоже работает в режиме CTC
    TIMSK |= (1 << OCIE1A);
    // выставляем ещё несколько бит для настройки таймера
    // CTC1 запускает таймер
    // CS13 CS12 CS10 - настраиваем то, с какой периодичностью, 
    // относительного частоты процессора, он будет срабатывать,
    // в данном случае это CK/4096
    TCCR1 |= (1 << CTC1) | (1 << CS13) | (1 << CS12) | (1 << CS10);
    // снова включаем прерывания
    sei();
}

Если мы умножим 4096 на 244, то получим 999424. Так как наш микроконтроллер должен работать с частотой 1000000 операций в секунду, то мы можем ожидать, что таймер будет вызывать прерывание где-то раз в эту самую секунду. Но Attiny не очень точны в плане частоты. У микроконтроллеров из разных партий частота может немного отличаться, к тому же на неё может влиять температура и поданое напряжение. Чтобы приблизить ожидаемое время срабатывания к реальному, я прогонял тесты и выяснил, что за одну минуту таймер срабатывает в среднем 57 раз. Это значение записано у меня в SEC и если вы решите использовать мой код со своим микроконтроллером, то скорее всего вам нужно будет его поменять. Для устройств, которые требуют более точного измерения времени, стоит воспользоваться внешними часами реального времени, например DS1307.

static uint8_t minutes = 0;
static uint8_t seconds = 0;
static void (*callback) (void) = NULL;

ISR (TIMER1_COMPA_vect)
{
    if (seconds > 0)
    {
        --seconds;
        return;
    }
    else
    {
        // тот самый SEC равный 57, а не 60
        seconds = SEC;
        --minutes;
    }
    if (minutes == 0 && callback != NULL)
        callback();
}

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

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

Вот такой вот получился проект. С исходным кодом целиком можно познакомиться здесь. Несмотря на его простоту, в процессе я успел познакомиться с многими функциями Attiny, такими как таймеры, прерывания и режимы сна. Ну а главное, я получил нечто физическое, как результат своей работы. И это очень приятное чувство.