Как стать автором
Обновить

STM32: FreeRTOS и пьезокерамический излучатель

Время на прочтение 12 мин
Количество просмотров 24K
image

Керамический пьезоизлучатель (buzzer) — простая деталь, наравне со светодиодом требующая минимального набора ресурсов для управления и настолько же легко подключаемая к микроконтроллеру. Как и светодиоду с возможностью плавной регулировки яркости, от микроконтроллера ему требуется не более одного канала таймера и внешний вывод.

Много в интернете уроков «Подключаем пищалку к ардуино», только вот заканчиваются они проигрыванием «В траве сидел кузнечик» или озвучкой срабатывания RFID датчика. Наверное тем, кто занят этим профессионально и серьезно, не до ведения блогов и записи видеоуроков.

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

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

Железки


Использовать будем самодельную плату с микроконтроллером stm32f103 в 144-ногом корпусе и пьезоизлучатель PKLCS1212E40A1-R1 фирмы Murata.

image

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

image

Пьезодинамик включен через транзистор и сделано это для большей громкости звучания (раскачивается амплитудой 5V), хотя можно вешать напрямую на ногу микроконтроллера (3.3V). Документация на него содержит АЧХ, из которого видно, что максимальная амплитуда достигается при входном сигнале 4 кГц. Да и в парт-номере компонента (PKLCS1212E40A1-R1) это отражено (Expressed resonant frequency by two-digit alphanumerics. The unit is in 100 hertz (Hz.) 4kHz (4000Hz) is denoted as «40.»).

image

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

u16 GL_BuzzerAllNotes[] = {
	261, 277, 294, 311, 329, 349, 370, 392, 415, 440, 466, 494,
	523, 554, 587, 622, 659, 698, 740, 784, 831, 880, 932, 988,
	1046, 1109, 1175, 1245, 1319, 1397, 1480, 1568, 1661, 1760, 1865, 1976,
	2093, 2217, 2349, 2489, 2637, 2794, 2960, 3136, 3322, 3520, 3729, 3951,
	4186, 4434, 4699, 4978, 5274, 5588, 5920, 6272, 6645, 7040, 7459, 7902};

#define OCTAVE_ONE_START_INDEX		(0)
#define OCTAVE_TWO_START_INDEX		(OCTAVE_ONE_START_INDEX + 12)
#define OCTAVE_THREE_START_INDEX	(OCTAVE_TWO_START_INDEX + 12)
#define OCTAVE_FOUR_START_INDEX		(OCTAVE_THREE_START_INDEX + 12)
#define OCTAVE_FIVE_START_INDEX		(OCTAVE_FOUR_START_INDEX + 12)

#define BUZZER_DEFAULT_FREQ		(4186) //C8 - 5th octave "Do"
#define BUZZER_DEFAULT_DURATION		(20) //20ms
#define BUZZER_VOLUME_MAX		(10)
#define BUZZER_VOLUME_MUTE		(0)

Драйвер пьезодинамика


Пьезодинамик — не светодиод, широтно-импульсной модуляцией с постоянной частотой и переменной скважностью импульсов тут не отделаешься. Ножку, на которой висит управляющий транзистор (PA15, TIM2, CH1), настраиваем в режиме PWM:

void BuzzerConfig(void)

void BuzzerConfig(void)
{
	GPIO_InitTypeDef GPIO_Options;
	TIM_TimeBaseInitTypeDef TIM_BaseOptions;
	TIM_OCInitTypeDef TIM_PWM_Options;

	RCC_APB2PeriphClockCmd(BUZZER_CLK_PINS | RCC_APB2Periph_AFIO, ENABLE);
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);

	GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE);
	GPIO_PinRemapConfig(GPIO_PartialRemap1_TIM2, ENABLE);

	//PA.15 TIM2_CH1, BUZZER
	GPIO_Options.GPIO_Pin = BUZZER_PIN;
	GPIO_Options.GPIO_Speed = GPIO_Speed_10MHz;
	GPIO_Options.GPIO_Mode = GPIO_Mode_AF_PP;
	GPIO_Init(BUZZER_PORT, &GPIO_Options);

	TIM_BaseOptions.TIM_Period = 2 * BUZZER_VOLUME_MAX - 1;
	TIM_BaseOptions.TIM_ClockDivision = TIM_CKD_DIV1;
	TIM_BaseOptions.TIM_CounterMode = TIM_CounterMode_Up;
	TIM_TimeBaseInit(TIM2, &TIM_BaseOptions);

	TIM_PWM_Options.TIM_OCMode = TIM_OCMode_PWM1;
	TIM_PWM_Options.TIM_OutputState = TIM_OutputState_Enable;
	TIM_PWM_Options.TIM_OutputNState = TIM_OutputNState_Disable;
	TIM_PWM_Options.TIM_OCPolarity = TIM_OCPolarity_High;
	TIM_PWM_Options.TIM_Pulse = 0;
	TIM_OC1Init(TIM2, &TIM_PWM_Options);

	TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable);
	TIM_ARRPreloadConfig(TIM2, ENABLE);

	TIM_Cmd(TIM2, ENABLE);
}

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

Очевидно, что смена частоты сигнала приводит к изменению звучания, а вот как быть со скважностью импульсов? Я не нашел ничего полезного по этому вопросу в документации, но было предположение, что изменение скважности влечёт за собой смену громкости. Если это правда, то меандр (скважность = 50%) будет давать максимальную громкость, а схождение к 0% (или симметрично, к 100%) ослабит громкость, в конце концов, до нуля. Реально это работает так себе, поэтому я только включаю и выключаю пищалку, используя два следующих макроса:

#define BUZZER_VOLUME_MAX	10
#define BUZZER_VOLUME_MUTE	0

BUZZER_VOLUME_MAX — это такое количество импульсов, которое дважды уложится в необходимый период работы, который обратно пропорционален частоте. Нужную частоту (установку) мы знаем, период тоже понятен (x2), а значит и предделитель для таймера найти не составит труда. В STM32 это любое число от 1 до 0xFFFF.

Оборачиваем все действия в функцию установки частоты:


void BuzzerSetFreq(u16 freq)
{
	TIM2->PSC = (SYSCLK_FREQ  / (2 * BUZZER_VOLUME_MAX * freq)) - 1; //prescaller
}

И смена скважности для задания громкости:


void BuzzerSetVolume(u16 volume)
{
	if(volume > BUZZER_VOLUME_MAX)
		volume = BUZZER_VOLUME_MAX;

	TIM2->CCR1 = volume;
}

Всё, драйвер пищалки готов. Можно сыграть что-нибудь, предварительно создав массив частот (и длительностей неплохо бы).

Happy Birthday

u32 HappyBirthday[] = {
	262, 262, 294, 262, 349, 330, 262,
	262, 294, 262, 392, 349, 262, 262,
	523, 440, 349, 330, 294, 466, 466,
	440, 349, 392, 349};

for(i = 0; i < sizeof(HappyBirthday) / sizeof(u32); i++)
{
	BuzzerSetFreq(HappyBirthday[i]);
	BuzzerSetVolume(BUZZER_VOLUME_MAX);
	DelayTime(400);
	BuzzerSetVolume(BUZZER_VOLUME_MUTE);
}

Пьезодинамик, как совместно используемый ресурс


Глобальная идея состоит в создании удобного интерфейса псевдопараллельного доступа различных задач к аппаратному модулю пьезодинамика средствами FreeRTOS. О самой FreeRTOS рассказывать не буду, эта тема не для одной статьи, которых уже очень не мало (в том числе и неплохая онлайн документация на www.freertos.org. На русском могу посоветовать этот ресурс).

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

typedef struct
{
	u16 freq;
	u16 volume;
	u16 duration;
} BuzzerParameters_t;

Для использования пищалки в качестве ресурса, которому любая задача может отдать на «озвучивание» какие-то данные, будем использовать стандартный механизм межзадачной коммуникации и синхронизации FreeRTOS — очередь.

Очередь хранит в себе конечное множество элементов данных фиксированного размера и представляет собой FIFO буфер, в который задачи могут как записывать данные, так и забирать — с последующим удалением (или без, по желанию). Любое количество задач может записать в очередь свои данные, а вот читать из неё будет только задача пьезодинамика.

Создадим очередь длиной 10 элементов, состоящую из кирпичиков типа BuzzerParameters_t:

#define BUZZER_QUEUE_LEN	10
QueueHandle_t BuzzerQueue = xQueueCreate(BUZZER_QUEUE_LEN, sizeof(BuzzerParameters_t);

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

TaskHandle_t BuzzerHandle;

xTaskCreate(vTask_BuzzerBeep, "BuzzerBeep", configMINIMAL_STACK_SIZE, NULL, tskIDLE_PRIORITY + 2, &BuzzerHandle);

В бесконечном цикле задача будет ждать появления данных в очереди. Параметр portMAX_DELAY означает, что задача заблокирована планировщиком до тех пор, пока очередь пуста. Как только это становится не так, драйвер пищалки инициализируется переданными через очередь параметрами, а считанный элемент удаляется из очереди (если удалять не требуется, есть функция xQueuePeek()).
Вместо задержки, основанной на бездействии микроконтроллера в течение какого-то времени, используется функция vTaskDelay(), блокирующая задачу на заданное количество времени в миллисекундах (на самом деле, на количество системных тиков ОСРВ, но у меня 1 тик = 1 мс). Таким образом, задача блокируется снова на время воспроизведения звука, а по истечении времени блокировки прекращает его генерацию.

void vTask_BuzzerBeep(void *pvParameters)
{
	BuzzerParameters_t buzzerParameters;

	for(;;)
	{
		xQueueReceive(BuzzerQueue, &buzzerParameters, portMAX_DELAY);

		BuzzerSetFreq(buzzerParameters.freq);
		BuzzerSetVolume(buzzerParameters.volume);
		vTaskDelay(buzzerParameters.duration);
		BuzzerSetVolume(BUZZER_VOLUME_MUTE);
	}
}

Выглядит несложно и логично, в отличие от шаманства с таймерами, прерываниями и флагами без использования ОСРВ. Попробуем теперь этот механизм в деле.

Дано:

  • Кнопка. Неплохо бы различать длинные и короткие нажатия.
  • Механический квадратурный энкодер. Можно крутить по часовой стрелке, против часовой, а так же нажимать на кнопку по центру. Для кнопки короткие и длинные нажатия тоже актуальны.

image

Начнём с кнопки. Она может находится в одном из трёх состояний:

typedef enum
{
	BUTTON_RELEASED = 0,
	BUTTON_SHORT_PRESSED,
	BUTTON_LONG_PRESSED
} BUTTON_PARAMETERS_t;

Инициализируем ножку микроконтроллера, настроим прерывание:

void StartButtonConfig(void)
void StartButtonConfig(void)
{
	GPIO_InitTypeDef GPIO_Options;
	EXTI_InitTypeDef EXTI_Options;
	NVIC_InitTypeDef NVIC_Options;

	RCC_APB2PeriphClockCmd(START_BUTTON_CLK_PINS | RCC_APB2Periph_AFIO, ENABLE);

	GPIO_Options.GPIO_Pin = START_BUTTON_PIN;
	GPIO_Options.GPIO_Mode = GPIO_Mode_IN_FLOATING;
	GPIO_Init(START_BUTTON_PORT, &GPIO_Options);

	GPIO_EXTILineConfig(START_BUTTON_PORTSOURCE, START_BUTTON_PINSOURCE);

	EXTI_Options.EXTI_Line = START_BUTTON_EXTI_LINE;
	EXTI_Options.EXTI_Mode = EXTI_Mode_Interrupt;
	EXTI_Options.EXTI_Trigger = EXTI_Trigger_Rising;
	EXTI_Options.EXTI_LineCmd = ENABLE;
	EXTI_Init(&EXTI_Options);

	NVIC_Options.NVIC_IRQChannel = EXTI2_IRQn;
	NVIC_Options.NVIC_IRQChannelPreemptionPriority = 13;
	NVIC_Options.NVIC_IRQChannelSubPriority = 0;
	NVIC_Options.NVIC_IRQChannelCmd = ENABLE;
	NVIC_Init(&NVIC_Options);
}

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

void EXTI2_IRQHandler(void)
void EXTI2_IRQHandler(void)
{
	static portBASE_TYPE xHigherPriorityTaskWoken = pdFALSE;
	EXTI_InitTypeDef EXTI_Options;

	EXTI_ClearITPendingBit(EXTI_Line2);

	EXTI_Options.EXTI_Line = EXTI_Line2;
	EXTI_Options.EXTI_Mode = EXTI_Mode_Interrupt;
	EXTI_Options.EXTI_Trigger = EXTI_Trigger_Rising;
	EXTI_Options.EXTI_LineCmd = DISABLE;
	EXTI_Init(&EXTI_Options);

	xSemaphoreGiveFromISR(StartButtonSemaphore, &xHigherPriorityTaskWoken);

	if(xHigherPriorityTaskWoken == pdTRUE)
	{
		portEND_SWITCHING_ISR(xHigherPriorityTaskWoken);
	}
}

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

К этому времени задача vTask_GetStartButton() с хендлом StartButtonHandle уже должна быть создана и заблокирована функцией xSemaphoreTake(), ожидающей семафора из прерывания. Логика работы следующая:

  1. Ждем, пока xSemaphoreTake() получит желаемое из прерывания
  2. Пикаем динамиком (с помощью очереди, ага!) и блокируем задачу на 1/4 секунды
  3. Пикаем каждые 100 мс в течение 300 мс, если кнопка в зажатом состоянии. Используем разные ноты в сторону повышения частоты из массива GL_BuzzerAllNotes[]
  4. В бесконечном цикле ждем, пока кнопку отпустят окончательно (обязательно внутри делаем задержку средствами ОСРВ, иначе ожидание заберет все процессорное время себе — а вдруг пользователь поставит бутылку виски на кнопку, как это было в Silicon Valley =) )
  5. Определяем по переменной notePointer, как долго удерживали кнопку (BUTTON_LONG_PRESSED или BUTTON_SHORT_PRESSED)
  6. Пикаем в последний раз, возобновляем реакцию на прерывание

Но лучше прочесть комментарии в коде — они более последовательны:

void vTask_GetStartButton(void *pvParameters)
void vTask_GetStartButton(void *pvParameters)
{
	BuzzerParameters_t buzzerLocalParameters;
	u32 localStartButtonState;
	EXTI_InitTypeDef EXTI_Options;
	u32 notePointer = 0;

	EXTI_Options.EXTI_Line = START_BUTTON_EXTI_LINE;
	EXTI_Options.EXTI_Mode = EXTI_Mode_Interrupt;
	EXTI_Options.EXTI_Trigger = EXTI_Trigger_Rising;
	EXTI_Options.EXTI_LineCmd = ENABLE;

	buzzerLocalParameters.volume = BUZZER_VOLUME_MAX;
	buzzerLocalParameters.duration = BUZZER_DEFAULT_DURATION;

	/*
	 * first semaphore take after creation (NEED!! it issued after power up)
	 */
	xSemaphoreTake(StartButtonSemaphore, portMAX_DELAY);

	for(;;)
	{
		/*
		 * take semaphore from button interrupt
		 */
		xSemaphoreTake(StartButtonSemaphore, portMAX_DELAY);

		/*
		 * buzzer "pick" on button click and wait
		 */
		buzzerLocalParameters.freq = NOTE_C7;
		xQueueSend(BuzzerQueue, (void *)&buzzerLocalParameters, portMAX_DELAY);
		vTaskDelay(250);

		/*
		 * "pick" new note while button pressed, but not more 3 times
		 */
		while(GPIO_ReadInputDataBit(START_BUTTON_PORT, START_BUTTON_PIN) == 1)
		{
			buzzerLocalParameters.freq = GL_BuzzerAllNotes[OCTAVE_FOUR_START_INDEX + notePointer];
			xQueueSend(BuzzerQueue, (void *)&buzzerLocalParameters, portMAX_DELAY);
			vTaskDelay(100);

			if(notePointer++ >= 3)
				break;
		}

		/*
		 * wait while button pressed
		 */
		while(GPIO_ReadInputDataBit(START_BUTTON_PORT, START_BUTTON_PIN) == 1)
		{
			vTaskDelay(100);
		}

		localStartButtonState = (notePointer >= 3) ? (BUTTON_LONG_PRESSED) : (BUTTON_SHORT_PRESSED);
		xQueueSend(StartButtonQueue, (void *)&localStartButtonState, 0);

		/*
		 * "pick" the last time and re-enable interrupt on click
		 */

		buzzerLocalParameters.freq = NOTE_C8;
		xQueueSend(BuzzerQueue, (void *)&buzzerLocalParameters, portMAX_DELAY);

		EXTI_Init(&EXTI_Options); //Enable interrupt (disabled in interrupts.c)
		notePointer = 0;

		vTaskDelay(100);
	}
}

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

StartButtonQueue = xQueueCreate(1, sizeof(u32));

После обработки нажатия очередь будет хранить результат до тех пор, пока какая-либо задача не считает его оттуда.

Тут стоит отдельно заострить внимание на политике добавления в очередь данных. Нам в помощь третий параметр функции xQueueSend(). Если это 0 и очередь заполнена, то игнорируем запись и идем дальше по коду. portMAX_DELAY наоборот же, позволяет блокировать выполнение задачи, пока в очереди не будет свободен хотя бы один элемент для записи. В общем случае этот параметр есть время, на которое нужно блокировать задачу для ожидания появления свободного места. Нажатие кнопки, например, можно и проигнорировать, но вот озвучить это надо всегда, учитывая, что озвучка не занимает много времени при разумном параметре duration.

То же самое делаем с кнопкой энкодера (отдельное прерывание, отдельная очередь EncoderButtonQueue, отдельная задача обработки, отправляющая данные в общую очередь динамика)

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

void EncoderConfig(void)
void EncoderConfig(void)
{
	GPIO_InitTypeDef GPIO_Options;
	EXTI_InitTypeDef EXTI_Options;

	RCC_APB2PeriphClockCmd(ENCODER_CLK_PINS | RCC_APB2Periph_AFIO, ENABLE);
	GPIO_Options.GPIO_Pin = ENCODER_A_PIN | ENCODER_B_PIN;
	GPIO_Options.GPIO_Mode = GPIO_Mode_IN_FLOATING;
	GPIO_Init(ENCODER_PORT, &GPIO_Options);

	GPIO_EXTILineConfig(ENCODER_PORTSOURCE, ENCODER_PINSOURCE); //Only one line interrupt!

	EXTI_Options.EXTI_Line = ENCODER_EXTI_LINE;
	EXTI_Options.EXTI_Mode = EXTI_Mode_Interrupt;
	EXTI_Options.EXTI_Trigger = EXTI_Trigger_Rising_Falling;
	EXTI_Options.EXTI_LineCmd = ENABLE;
	EXTI_Init(&EXTI_Options);
}

По входу в прерывание определим, куда же повернули вал: по часовой стрелке или против:

void EXTI0_IRQHandler(void)
u32 localEncoderAction;

if(GPIO_ReadInputDataBit(ENCODER_PORT, ENCODER_A_PIN) == 1)
{
	if(GPIO_ReadInputDataBit(ENCODER_PORT, ENCODER_B_PIN) == 1)
	{
		localEncoderAction = ENCODER_WAS_INCR;
	}
	else
	{
		localEncoderAction = ENCODER_WAS_DECR;
	}
}
else
{
	if(GPIO_ReadInputDataBit(ENCODER_PORT, ENCODER_B_PIN) == 1)
	{
		localEncoderAction = ENCODER_WAS_DECR;
	}
	else
	{
		localEncoderAction = ENCODER_WAS_INCR;
	}
}

EXTI_ClearITPendingBit(EXTI_Line0);

Все в той же функции обработки прерывания, на основании информации о направлении поворота будем изменять переменную buzzerRotationCounter, которая определяет индекс проигрываемой ноты из массива GL_BuzzerAllNotes[]. Вращая энкодер, получим увеличение или уменьшение частоты звучания на +-15 едениц от значения 25. Далее формируем и отправляем элемент в очередь динамика, семафорим о событии энкодера и выходим из прерывания:

void EXTI0_IRQHandler(void), продолжение
static portBASE_TYPE xHigherPriorityTaskWoken = pdFALSE;
static TickType_t xLastTime;
static s32 buzzerRotationCounter = 15;
BuzzerParameters_t localParameters;

if((xTaskGetTickCount() - xLastTime) > 300)
{
	buzzerRotationCounter = 25;
}

if(localEncoderAction == ENCODER_WAS_INCR)
{
	buzzerRotationCounter++;

	if(buzzerRotationCounter > 39)
	{
		buzzerRotationCounter = 39;
	}
}
else //ENCODER_WAS_DECR
{
	buzzerRotationCounter--;

	if(buzzerRotationCounter < 10)
	{
		buzzerRotationCounter = 10;
	}
}

xLastTime = xTaskGetTickCount();

localParameters.duration = 10;//BUZZER_DEFAULT_DURATION;
localParameters.freq = GL_BuzzerAllNotes[buzzerRotationCounter];
localParameters.volume = BUZZER_VOLUME_MAX;

xQueueSendFromISR(BuzzerQueue, (void *)&localParameters, &xHigherPriorityTaskWoken);
xQueueSendFromISR(EncoderQueue, (void *)&localEncoderAction, &xHigherPriorityTaskWoken);

if(xHigherPriorityTaskWoken == pdTRUE)
{
	portEND_SWITCHING_ISR(xHigherPriorityTaskWoken);
}

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



Ну и зачем всё это?


Не то, что бы вышеописанное очень сложно и обязательно надо было разобрать это по шагам. Серьезно, суть публикации глобально можно свести к предложению — создадим задаче динамика очередь и согласно придуманным алгоритмам будем запихивать туда данные. Однако мне показалось, что подобный пример будет не плох для демонстрации распараллеливания доступа различных задач к аппаратным ресурсам железа средствами FreeRTOS. То же самое, но сделанное своими руками на флагах и прерываниях с таймерами хоть и кушало памяти меньше, чем ОСРВ — но в плане читабельности, переносимости и удобства использования было на порядок хуже.

Ну и конечно же — устройства, которые мы проектируем, в первую очередь должны быть удобными в применении и не вызывать чувства ненависти у пользователя. Надеюсь, производители моего электрочайника когда-нибудь это поймут, а вызывающие кровь из ушей звуки уйдут в прошлое наравне с ослепляющими светодиодами. Спасибо за внимание!
Теги:
Хабы:
+39
Комментарии 14
Комментарии Комментарии 14

Публикации

Истории

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн