Електронний тортик в кишені: щоденник розробки

електронний тортик в осінній гамі

Близько року тому, коли я грався з офіційним Arduino Starter Kit, мені прийшла в голову думка зробити подарунок дружині «схемотехнічний тортик», а саме плату зі світлодіодами-свічками, які можна задувати. Ця задача здалася мені хорошим навчальним проектом для розуміння того, як програмувати мікроконтролери і як втілювати програми в матерію (адже електронікою я став захоплюватися відносно недавно, а програмувати почав ще в школі, тому відчував величезний пробіл у своєму освіту).

Мені хотілося б поділитися своїм досвідом розробки такого простого пристрою з нуля, і заодно викласти його схему та програму, так що ви зможете відтворити його будинку.

Історія
Перший прототип був зроблений на Arduino Uno і доданого до нього п'єзо-елемента, в якості детектора задування свічок:

прототип електронного тортика на Arduino Uno

Зчитуючи напруга на п'єзо-елементі, пристрій чекало найменшої зміни показань. Але навіть на максимальній роздільній здатності АЦП треба було дуже добре подути, щоб свічки почали гаснути. Зате гасли вони добре: спочатку починали мерехтіти з різною частотою і фазою, а потім по черзі гасли в залежності від заданого випадкового часу життя кожної свічки.

Наступним кроком я вирішив оформити тортик більш нарядно, ніж плата з стирчать проводками. А ще хотілося показати дружині, що тортик працює від маааленького мікроконтролера. Десь в інтернеті я знайшов статтю, що Arduino можна запускати на ATTiny85 контролері, його я і вирішив використовувати в якості мізків. Проблема була в тому, що у цього контролера всього п'ять ніжок загального призначення, які можна було використовувати для свічок і датчика, так що виходило, що я міг використовувати тільки чотири свічки, якщо посадити кожен світлодіод на окрему ніжку. Про чарлиплексирование я тоді не знав, і вирішив дублювати свічки схемою затримки на інверторі: коли світлодіоди блимають, то навряд чи хтось помітить, що вони дублюються із затримкою фази. Крім того, додаткові елементи добре вкладалися в круговій шаблон розміщення свічок:

прототип електронного тортика на ATTiny85 зі схемою затримки

В якості харчування я використовував чотири батарейки ААА, що порушувало лаконічність зовнішнього вигляду. Крім того, хотілося більше чесних свічок і більшої чутливості. Я пошуршал по специфікаціям мікроконтролерів серії ATTinyі виявив чудовий аналог — ATTiny84 з 11 загальними ніжками загального призначення. Крім того, у нього є вхід АЦП з диференціальним підсилювачем в 20 раз! Так з'явився новий прототип:

прототип електронного тортика на ATTiny44 з двома батарейками АА

Я сподівався засунути дві батарейки ААА між поверхами, але прорахувався з розміром стоїк і п'єзо-елемента, який не повинен був прилягати до плати впритул, тому довелося приліпити батарейки під плату. З посиленням сигналу тортик став набагато чутливіші, і з'явилися шуми, тому знадобилося змінити алгоритм: я вимірював середньоквадратичне відхилення сигналу і включав логіку задування, коли це відхилення перевищувало деякий емпірично знайдений поріг. Програма вийшла невеликий, менше 4 кБ, тому я взяв ATTiny44, у якого в два рази менше пам'яті.

Мені страшенно не подобалося харчування схеми і порожній простір між поверхами, я пошуршал по специфікаціям батарейок і виявив, що бувають батарейки АААА. Вони досить маленькі і забезпечують струм в сотні міліампер. Їх важко купити, але якщо взяти девятивольтовые батареї типу 6LR61, то вони всередині складаються з шести батарейок АААА!

Ще я знайшов цікаву мікросхему імпульсного джерела живлення HT7750, яка підвищує напругу живлення до +5 за рахунок збільшення струму батареї, і вирішив зробити тортик такого розміру, щоб по діагоналі вмістилася одна АААА батарейка, яка і буде його живити. Вийшов ось такий прототип:

прототип електронного тортика на ATTiny44 з однією батарейкою АААА

Використання ширококутних світлодіодів не тільки поліпшило естетику тортика, але і збільшило кут огляду. Плати я замовив у Китаї, що поліпшило якість готового пристрою. А ось чого я не очікував, так це різкого збільшення шумів при визначенні задування. Справа в тому, що імпульсний джерело живлення сам по собі галасливий, а тут ще й залежність вихідної напруги від струму, коли горить різну кількість світлодіодів. Старий алгоритм перестав працювати, і мені довелося підійти до завдання більш грунтовно: зчитувати показання з п'єзо-сенсора на комп'ютер і аналізувати амплітудно-частотну характеристику сигналу. Я записав одну хвилину тиші, одну хвилину задування і хвилину шерехів тертя об корпус. Виявилося, що п'єзо-елемент має резонанс близько 1 кГц, по якому можна досить достовірно визначити, що свічки зараз задуваються.

Отже, для визначення моменту задування свічок я став вважати швидке перетворення Фур'є з вікном Ханнинга для частоти 1 кГц. ATTiny44 не має вбудованої команди множення, так і пам'ять вельми обмежена (256 байт ОПЕРАТИВНОЇ пам'яті, 4 кБ на програму), тому всі множення довелося замінити на зрушення і додавання, так і взагалі істотно оптимізувати програму, щоб все влізло. В результаті зміни програми тортик перевершив по чутливості всі інші прототипи. Далі я став прибирати зайві компоненти на шляху сигналу, які я по наївності вважав фільтром високих частот, і тортик став реагувати ще краще. Замінивши HT7750 HT7733, тобто замінивши вихід з +5 на +3.3 У, я отримав більш економне використання батареї.

Останнім моментом, який мене бентежив, було те, що мікросхема харчування у вимкненому режимі споживала приблизно 17 µА, що означало спустошення батарейки за рік. Але я згадав, що у цієї мікросхеми є один варіант виконання в корпусі SOT-25, в якому є стробирующий вхід, і склав невелику схему включення, яка при натисканні на кнопку скидання заряджала конденсатор для подачі високого рівня на стробирующем вході мікросхеми з подальшою повільною розрядкою протягом трьох хвилин через десятимегаомный резистор. Після таймауту тортик вимикається і починає споживати менше 1 µА, що сопостовимо з струмом саморозрядження батареї.

електронний тортик на тлі руки для порівняння розмірів

Принципова схема
принципова схема тортика

HT7733 — це імпульсний джерело живлення, підвищує напругу живлення на вході до +3.3 Ст.
П'єзо-елемент діаметром 20 мм поключен до диференціального АЦП мікроконтролера з посиленням в 20 раз.
При натисканні на RESET відкривається транзистор, через який заряджається конденсатор 10 мкФ і подається високий рівень на стробирующий вхід мікросхеми харчування. Мікросхема включається і починає видавати на вихід +3.3 протягом трьох хвилин, поки конденсатор 10 мкФ розряджається через десятимегаомный резистор.
Кожен світлодіод підключений до одній ніжці мікроконтролера.


Програма на C++ для Arduino IDE
/////////////////////////////////////////////////////////////////////////////////////////////////
// BitCake v1.1.1 / November 8, 2014
// by Maksym Ganenko <buratin.barabanus at Google Mail>
/////////////////////////////////////////////////////////////////////////////////////////////////

#include <avr/interrupt.h>
#include <avr/pgmspace.h>
#include <avr/power.h>
#include <avr/sleep.h>
#include <time.h>
#include <util/delay.h>

/////////////////////////////////////////////////////////////////////////////////////////////////

// set fixed delta loop time in milliseconds
// 0 to use internal timer
const uint8_t DELTA_LOOP_TIME_MS = 14;

// amplify intermediate values to get better calculation accuracy
const uint8_t SAMPLES_GAIN_ORDER = 5; // x32
const uint8_t RESULT_GAIN_ORDER = 2; // x4

// LEDs encoded by ports ID
const prog_int8_t LEDS[] PROGMEM = { 0xA6, 0xA7, 0xB2, 0xB1, 0xB0, 0xA2, 0xA3, 0xA4, 0xA5 };
const uint8_t LEDS_NUM = sizeof(LEDS) / sizeof(LEDS[0]);

// ADMUX register code for ADC
const uint8_t PIEZO_ADMUX = 0b10101001; // Vref = 1.1 V, (A1 - A0) x 20

// MCU prescaler
const uint8_t MCU_PRESCALER = 0b000; // 1 => 8 Mhz CPU clock

// ADC prescaler
const uint8_t ADC_PRESCALER = 0b100; // 16 => 512 kHz ADC clock => 38.5 k reads per sec

// number of piezo reads to average per sample (for noise reduction)
const uint8_t SUBSAMPLE_BUF_ORDER = 4; // => 16
const uint8_t SUBSAMPLE_BUF_SIZE = (1 << SUBSAMPLE_BUF_ORDER);

// FFT samples number - can't be changed without changing FFT calculation code
const uint8_t SAMPLE_BUF_ORDER = 5; // => 32
const uint8_t SAMPLE_BUF_SIZE = (1 << SAMPLE_BUF_ORDER);

// FFT signal threshold to activate blowing logic
// this value defines the sensitivity of device
// depends on electronic components noise
const uint8_t BLOWING_THRESHOLD = 3;

// timeouts in milliseconds
const uint32_t SETUP_TIME_MS = 750; // timeout before activating of cake logic
const uint32_t DELAY_BLOWING_MS = 0; // delay LEDs flickering when blowing detected
const uint32_t PROLONG_BLOWING_MS = 150; // prolong blowing logic when no blowing detected
const uint32_t NO_ACTIVITY_MS = 60000; // turn off cake if no blowing detected
const uint32_t TIME_LIMIT_MS = 150000; // time limit for cake to work

// LEDs blinking periods when blowing logic is activated
const uint8_t LEDS_PERIOD_MIN_MS = 100;
const uint8_t LEDS_PERIOD_MAX_MS = 150;

// LEDs time-to-live timeouts
const uint16_t LEDS_TTL_MIN_MS = 200;
const uint16_t LEDS_TTL_MAX_MS = 1000;

/////////////////////////////////////////////////////////////////////////////////////////////////

volatile uint8_t samplePos = SAMPLE_BUF_SIZE;

// FFT10 specific accumulators
int16_t sampleAccA [5];
int16_t sampleAccB [5];

// LEDs state variables
uint16_t ledsActivity;
uint8_t ledsPeriod [LEDS_NUM];
uint8_t ledsPhase [LEDS_NUM];
uint8_t ledsTTL [LEDS_NUM];

// blowing state logic
uint8_t blowing = false;
uint32_t lastBlowingTime = 0;
int16_t totalBlowingTime = 0;

uint32_t globalTime = 0;
uint32_t lastLoopTime;
uint32_t setupPhaseTime;

// for compatibility with other Atmel MCUs
uint8_t portA, portB, portC, portD;

/////////////////////////////////////////////////////////////////////////////////////////////////

// fast distance approximation
uint32_t approxDist(int32_t dx, int32_t dy)
{
uint32_t min, max;

if (dx < 0) dx = -dx;
if (dy < 0) dy = -dy;

if (dx < dy) { min = dx; max = dy; }
else { min = dy; max = dx; }

// coefficients equivalent to (123/128 * max) and (51/128 * min)
return (((max << 8) + (max << 3) - (max << 4) - (max << 1) +
(min << 7) - (min << 5) + (min << 3) - (min << 1)) >> 8);
}

const uint8_t FFT_DIVIDER_ORDER = 8; // => 256

// approximate multiplication
int32_t mul256(int32_t x) { return x << 8; }
int32_t mul240(int32_t x) { return (x << 8) - (x << 4); }
int32_t mul208(int32_t x) { return (x << 7) + (x << 6) + (x << 4); }
int32_t mul176(int32_t x) { return (x << 7) + (x << 5) + (x << 4); }
int32_t mul144(int32_t x) { return (x << 7) + (x << 4); }
int32_t mul96(int32_t x) { return (x << 6) + (x << 5); }
int32_t mul48(int32_t x) { return (x << 5) + (x << 4); }

typedef int32_t (*fmul32)(int32_t);
const fmul32 fmulVec[4] = { mul96, mul176, mul240, mul256 };

// calculate FFT[10] for 32 samples
uint8_t fft10() {
int32_t a = 0;
for (uint8_t i = 0; i < 4; i++) {
a += fmulVec[i](sampleAccA[i + 1]);
}

int32_t b = 0;
for (uint8_t i = 0; i < 4; i++) {
b += fmulVec[i](sampleAccB[i + 1]);
}

uint32_t result = approxDist(a << RESULT_GAIN_ORDER, b << RESULT_GAIN_ORDER);
result >>= FFT_DIVIDER_ORDER;
if (result > 0xff) return 0xff;
return result;
}

/////////////////////////////////////////////////////////////////////////////////////////////////

// fft10 specific coefficients
const prog_int8_t sampleAccDestA[SAMPLE_BUF_SIZE / 2] PROGMEM = { 
+4, -1, -2, +3, +0, -3, +2, +1, -4, +1, +2, -3, +0, +3, -2, -1 
};
const prog_int8_t sampleAccDestB[SAMPLE_BUF_SIZE / 2] PROGMEM = { 
+0, +3, -2, -1, +4, -1, -2, +3, +0, -3, +2, +1, -4, +1, +2, -3
};

const uint8_t HANNING_DIVIDER_ORDER = 6; // => 64

// hanning window coefficients
int16_t mul0(int16_t x) { return 0; }
int16_t mul1(int16_t x) { return x; }
int16_t mul3(int16_t x) { return (x << 2) - x; }
int16_t mul6(int16_t x) { return (x << 3) - (x << 1); }
int16_t mul10(int16_t x) { return (x << 3) + (x << 1); }
int16_t mul15(int16_t x) { return (x << 4) - x; }
int16_t mul21(int16_t x) { return (x << 4) + (x << 2) + x; }
int16_t mul27(int16_t x) { return (x << 5) - (x << 2) - x; }
int16_t mul34(int16_t x) { return (x << 5) + (x << 2); }
int16_t mul40(int16_t x) { return (x << 5) + (x << 3); }
int16_t mul46(int16_t x) { return (x << 5) + (x << 4) - (x << 2); }
int16_t mul52(int16_t x) { return (x << 6) - (x << 3) - (x << 2); }
int16_t mul56(int16_t x) { return (x << 6) - (x << 3); }
int16_t mul60(int16_t x) { return (x << 6) - (x << 2); }
int16_t mul63(int16_t x) { return (x << 6) - x; }
int16_t mul64(int16_t x) { return (x << 6); }

// hanning window coefficients
typedef int16_t (*fmul16)(int16_t);
const fmul16 hanningVec[] = {
mul0, mul1, mul3, mul6, mul10, mul15, mul21, mul27, 
mul34, mul40, mul46, mul52, mul56, mul60, mul63, mul64
};

// ADC interrup routine
// we average SUBSAMPLE_BUF_SIZE reads from ADC to reduce noise
// and apply the calculated value on fft10 specific accumulators
ISR(ADC_vect)
{
static uint8_t subsampleCtr = 0;
static int16_t subsampleSum = 0;

// read ADC
uint8_t low = ADCL, high = ADCH;
int16_t subsample = (high << 8) | low;

if (samplePos < SAMPLE_BUF_SIZE) {
subsampleSum += subsample;
++subsampleCtr;

if (subsampleCtr == SUBSAMPLE_BUF_SIZE) {
// average of subsamples
int16_t sample = (subsampleSum >> SUBSAMPLE_BUF_ORDER) << SAMPLES_GAIN_ORDER;

uint8_t halfPos = samplePos & (SAMPLE_BUF_SIZE / 2 - 1);
uint8_t mulPos = halfPos;
if (halfPos != samplePos) {
mulPos = SAMPLE_BUF_SIZE / 2 - mulPos;
}
// multiply by hanning window coefficient
sample = hanningVec[mulPos](sample) >> HANNING_DIVIDER_ORDER;

int8_t destA = pgm_read_byte_near(sampleAccDestA + halfPos);
int8_t destB = pgm_read_byte_near(sampleAccDestB + halfPos);

if (destA >= 0) sampleAccA[destA] += sample;
else sampleAccA[-destA] -= sample;
if (destB >= 0) sampleAccB[destB] += sample;
else sampleAccB[-destB] -= sample;

++samplePos;
subsampleSum = subsampleCtr = 0;
}
} 
}

/////////////////////////////////////////////////////////////////////////////////////////////////

void powerDown() {
// all pins to low
portA = portB = portC = portD = 0;
portsUpdateFinish();

// disable ADC
ADCSRA &= ~_BV(ADEN);

// power down
set_sleep_mode(SLEEP_MODE_PWR_DOWN);
sleep_mode();
}

void portsUpdateStart() {
#if defined(PORTA)
portA = PORTA;
#endif
#if defined(PORTB)
portB = PORTB;
#endif
#if defined(PORTC)
portC = PORTC;
#endif
#if defined(PORTD)
portD = PORTD;
#endif
}

void portsUpdateFinish() {
#if defined(PORTA)
if (PORTA != portA) { PORTA = portA; }
#endif
#if defined(PORTB)
if (PORTB != portB) { PORTB = portB; }
#endif
#if defined(PORTC)
if (PORTC != portC) { PORTC = portC; }
#endif
#if defined(PORTD)
if (PORTD != portD) { PORTD = portD; }
#endif
}

void writeLed(uint8_t anIndex, uint8_t aValue) {
uint8_t led = pgm_read_byte_near(LEDS + anIndex);
uint8_t code = _BV(led & 0x0F);
if (aValue && bitRead(ledsActivity, anIndex)) {
switch(led & 0xF0) {
#if defined(PORTA)
case 0xA0: portA |= code; break;
#endif
#if defined(PORTB)
case 0xB0: portB |= code; break;
#endif
#if defined(PORTC)
case 0xC0: portC |= code; break;
#endif
#if defined(PORTD)
case 0xD0: portD |= code; break;
#endif
}
} else {
switch(led & 0xF0) {
#if defined(PORTA)
case 0xA0: portA &= ~code; break;
#endif
#if defined(PORTB)
case 0xB0: portB &= ~code; break;
#endif
#if defined(PORTC)
case 0xC0: portC &= ~code; break;
#endif
#if defined(PORTD)
case 0xD0: portD &= ~code; break;
#endif
}
}
}

/////////////////////////////////////////////////////////////////////////////////////////////////

void setup() {
portsUpdateStart();
for (uint8_t i = 0; i < LEDS_NUM; ++i) {
bitSet(ledsActivity, i);
uint8_t led = pgm_read_byte_near(LEDS + i);
uint8_t code = _BV(led & 0x0F);
switch (led & 0xF0) {
#if defined(DDRA)
case 0xA0: DDRA |= code; break;
#endif
#if defined(DDRB)
case 0xB0: DDRB |= code; break;
#endif
#if defined(DDRC)
case 0xC0: DDRC |= code; break;
#endif
#if defined(DDRD)
case 0xD0: DDRD |= code; break;
#endif
}
writeLed(i, HIGH);
}
portsUpdateFinish();

// set MCU prescaler
CLKPR = 0b10000000;
CLKPR = MCU_PRESCALER;

// set ADC prescaler
ADCSRA = (ADCSRA & ~0b111) | ADC_PRESCALER;

// activate ADC auto-triggering
ADCSRA |= _BV(ADATE) | _BV(ADIE);
ADMUX = PIEZO_ADMUX;
ADCSRA |= _BV(ADSC);

// disable all digital inputs
DIDR0 = 0xff;

// disable analog comparator
ACSR |= _BV(ACD);

// disable timer if delta loop time is defined
if (DELTA_LOOP_TIME_MS) {
power_timer0_disable();
power_timer1_disable();
set_sleep_mode(SLEEP_MODE_ADC);
}

_delay_ms(100);

lastLoopTime = DELTA_LOOP_TIME_MS ? 0 : millis();
setupPhaseTime = lastLoopTime + SETUP_TIME_MS;
}

void loop() {
uint32_t time = DELTA_LOOP_TIME_MS ? globalTime : millis();
uint16_t loopDeltaTime = time - lastLoopTime;
uint8_t setupPhase = time < setupPhaseTime;
rand(); // update random seed

// wait for ADC routine to read all samples for FFT
memset(sampleAccA, 0, sizeof(sampleAccA));
memset(sampleAccB, 0, sizeof(sampleAccB));
samplePos = 0;
while (samplePos != SAMPLE_BUF_SIZE) {
if (DELTA_LOOP_TIME_MS) { sleep_mode(); }
}

portsUpdateStart();

// calculate FFT[10]
uint8_t signal = fft10();

// blowing detection
if (signal > BLOWING_THRESHOLD) {
if (!blowing) {
// generate LEDs flickering values
for (uint8_t i = 0; i < LEDS_NUM; ++i) {
ledsPeriod[i] = LEDS_PERIOD_MIN_MS + rand() % (LEDS_PERIOD_MAX_MS - LEDS_PERIOD_MIN_MS);
ledsTTL[i] = (LEDS_TTL_MIN_MS + rand() % (LEDS_TTL_MAX_MS - LEDS_TTL_MIN_MS)) >> 3;
ledsPhase[i] = rand() % ledsPeriod[i];
}
}
blowing = !setupPhase;
lastBlowingTime = time;
}

if (blowing && time - lastBlowingTime > PROLONG_BLOWING_MS) { 
blowing = false;
}

if (blowing) {
totalBlowingTime += loopDeltaTime;
if (totalBlowingTime >= DELAY_BLOWING_MS) {

// prolong startup time until noise stabilizes
if (setupPhase) { setupPhaseTime += SETUP_TIME_MS; }

// update LEDs state
for (uint8_t i = 0; i < LEDS_NUM; ++i) {
uint8_t level = ((time + ledsPhase[i]) % ledsPeriod[i] < (ledsPeriod[i] >> 1)) 
? LOW : HIGH;
if (signal <= BLOWING_THRESHOLD) { level = !level; }
writeLed(i, level);

if (!setupPhase && totalBlowingTime > (ledsTTL[i] << 3)) { bitClear(ledsActivity, i); }
}
}
} else {
totalBlowingTime = max(0, totalBlowingTime - loopDeltaTime);
if (totalBlowingTime < 0) totalBlowingTime = 0;
for (uint8_t i = 0; i < LEDS_NUM; ++i) { writeLed(i, HIGH); }
}

if (setupPhase) {
if (time >= 1500) { // show busy state
int lowLed = (time >> 6) % LEDS_NUM;
for (uint8_t i = 0; i < LEDS_NUM; ++i) {
writeLed(i, (i == lowLed) ? LOW : HIGH);
}
}
} else {
const bool DEBUG_MODE = false; // trace debug value using LEDs
const bool INVERT_LEVELS = true; // LOW level means 1, HIGH level means 0
const bool MEASURE_TIME = false; // measure time in ms (minus offset, see code)
const bool SHOW_ORDER = false; // show value as binary order

if (DEBUG_MODE) {
int value = signal; // value to show
if (MEASURE_TIME) {
static uint32_t totalLoopTime = 0;
static uint32_t loopCtr = 0;
totalLoopTime += loopDeltaTime;
++loopCtr;
// set time offset here
value = totalLoopTime / loopCtr - 10; 
}

int dbgValue = value;
if (SHOW_ORDER) {
dbgValue = 0;
for (; value > 0; ++dbgValue, value >>= 1);
}

for (uint8_t i = 0; i < LEDS_NUM; ++i) {
bitSet(ledsActivity, i);
writeLed(i, (dbgValue > i)
? (INVERT_LEVELS ? LOW : HIGH)
: (INVERT_LEVELS ? HIGH : LOW));
}

// the last LED shows blowing state
writeLed(LEDS_NUM - 1, blowing ? HIGH : LOW);
}
}

portsUpdateFinish();

if (ledsActivity == 0 || time - lastBlowingTime > NO_ACTIVITY_MS || time > TIME_LIMIT_MS) {
powerDown();
}

if (DELTA_LOOP_TIME_MS) { 
globalTime += DELTA_LOOP_TIME_MS; 
}
lastLoopTime = time;
}

/////////////////////////////////////////////////////////////////////////////////////////////////

Для програмування контролера знадобиться ICSP програматор типу USBTiny або плата Arduino, запрограмована бути програматором (шукайте «Arduino як програматор»). Програму можна завантажити прямо з Arduino IDE, але до цього потрібно поставити спеціальні бібліотеки для ATTiny і вибрати в якості контролера ATTiny44 8MHz.

Посилання
http://bitcake.eu — сайт, присвячений цьому проекту (англ.)

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.