Deep sleep no ESP32, ESP32 OTA e máquina de estado

ESP32 OTA

Ainda não está completo, mas agora vamos entrar em detalhes importantes sobre a T Wristband. Nesse artigo disponibilizo o código para deep sleep, ESP32 OTA e máquina de estado no ESP32, como também discorro sobre informações importantes das situações que me deparei durante o desenvolvimento.

Por que usar deep sleep no ESP32?

No caso específico da T Wristband, porque sua bateria é de 80mAh e o que não falta no ESP32 são rádios para consumir energia.

Utilizar o deep sleep nos permite aumentar a duração da carga da bateria, pois nesse modo os rádios não estarão funcionando. Mas dá pra ir além.

Como desabilitar WiFi e Bluetooth no ESP32?

E com ele ligado, pode ser que não precisemos do WiFi e/ou bluetooth. Nesse caso, há uma maneira também de desligar os rádios quando o ESP32 estiver ligado. Implementei nesse código que disponho mais adiante.

E se o firmware permitir atualizar o ESP32 por OTA?

Pois é. O programa foi feito para fazer atualização  ESP32 OTA, utilizando o browser. Mas se o WiFi estiver desligado não tem como. Por isso implementei duas funções; startOTA() e stopOTA(). Ao iniciar com startOTA(), o WiFi é ligado e o modo AP+STA é configurado. Ao chamar a função stopOTA(), o WiFi é desligado e o modo deep sleep é executado através da função dream().

Limitação de espaço no ESP32

Pela primeira vez consegui esgotar o espaço do ESP32! Acredito que seja possível reduzir esse consumo, mas a quantidade de bibliotecas utilizadas para facilitar as coisas foi realmente demasiada. E nem implementei o IMU de 9 eixos ainda. A questão é que meu propósito era utilizar o bluetooth e manter também a atualização via ESP32 OTA, mas não houve espaço para ambos. A biblioteca do PCF8563 foi removida e implementei diretamente através do datasheet, mas cometi alguma cag… digo, algum equívoco, pois não estou conseguindo ajustar a hora ainda. Mas sem problemas, em algum momento dará certo, estou focado em outro objetivo agora.

Placa de gravação da T Wristband

Enquanto mexendo diretamente com a conexão WiFi e com o touch, achei prudente manter a gravação do firmware pela placa, uma vez que qualquer erro poderia impossibilitar a atualização por OTA no ESP32.

Abaixo, a foto da placa conectada ao T Wristband.

ESP32 OTA

Deep sleep no ESP32

Para fazer o deep sleep, duas linhas foram utilizadas.

esp_sleep_enable_ext1_wakeup(GPIO_SEL_33, ESP_EXT1_WAKEUP_ANY_HIGH);
esp_deep_sleep_start();

A primeira define o modo utilizado para fazer o wakeup. No caso, utilizando o pino 33 e qualquer pino em HIGH. No GPIO 33 está conectado o TP223, que é o sensor de toque, portanto para tirar o ESP32 do deep sleep, basta tocar. Poderia também fazer wakeup a partir do timer, mas não tem muito propósito para mim por enquanto.

Dependendo do momento que for feito o deep sleep (por exemplo, após uma atualização via OTA), será necessário desligar o WiFi e bluetooth previamente. Para isso, temos a função stopOTA(), que desliga os recursos e o servidor web:

server.stop();
WiFi.mode(WIFI_OFF);
btStop();

Máquina de estado no ESP32

É bem fácil pegar em um loop o estado do sensor de toque e contabilizar o tempo pressionado. O problema é que se tivermos 5 funções no menu precisaremos de um cronômetro para acessar cada função. A melhor opção é caminhar através de um menu, e isso pode ser feito de duas formas.

Seleção e ENTER

Podemos contabilizar apenas 2 itens; o Enter e a caminhada por um array. Fica fácil; se o toque for menor que 1 segundo, muda o item do menu. Se o toque for superior a 1 segundo, executa a função correspondente ao item do menu.

Seleção de um item dentro de um intervalo de tempo

Esse é mais difícil de implementar. Escolhi esse modo por diversão, mas não é ideal para menus longos onde todo o controle fica por conta de 1 botão – no caso, o sensor de toque.

Uma função fica rodando em um loop; se houver toque, dispara uma contagem de tempo de X segundos. O número de toques é contabilizado e, ao estourar os X segundos, a função correspondente ao item selecionado é executada.

Tem 2 desvantagens em usar esse modo: se estourar o tempo antes de selecionar o item correto, executará a função que estiver no momento. Mesmo se selecionar logo a função, terá que esperar estourar o tempo para que a função seja executada. Defini 3 segundos para fazer a seleção.

Primeiro projeto com o T Wristband

Esse mês ministro uma palestra e um mini-curso no Arduino Day UNIVAG. Para trocar os slides, pretendo utilizar o T Wristband. O primeiro passo era ter esse controle sobre o menu e a ideia inicial era utilizar o bluetooth, mas ao que parece a opção tangível para esse momento de pressa (estamos em cima da data) é utilizar o WiFi. Vou concluir o projeto nos próximos dias e farei uma apresentação, mas mesmo sem funcionalidades efetivas, já dá pra fazer o vídeo que prometi no artigo anterior relacionado, portanto já amanhã ou depois devo colocar o vídeo do estado atual do programa e assim consigo apresentar melhor a T Wristband.

Código para deep sleep, OTA e máquina de estado no ESP32

Esse código será modificado para a apresentação em vídeo, mas já está bastante funcional. Só que as partes que funcionavam por temporização do toque agora devem ser migradas para a máquina de estado para voltarem a funcionar.

No código tem também recursos comentados, como o bluetooth. Tem um scanner i2c para detectar os endereços da IMU e do RTC PCF8563, além da função iniciada do bluetooth.

#include <Arduino.h>
#include <WiFi.h>
#include <Wire.h>
#include <TFT_eSPI.h>
#include <ESPmDNS.h>
#include <Update.h>
#include <ESPmDNS.h>
#include <WebServer.h>
#include "BluetoothSerial.h"

#include "/home/djames/.platformio/lib/TFT_eSPI_ID1559/examples/320 x 240/Free_Font_Demo/Free_Fonts.h"

#define TP_PIN_PIN          33
#define I2C_SDA_PIN         21
#define I2C_SCL_PIN         22
#define IMU_INT_PIN         38
#define RTC_INT_PIN         34
#define BATT_ADC_PIN        35
#define VBUS_PIN            36
#define TP_PWR_PIN          25
#define LED_PIN             4
#define CHARGE_PIN          32
#define TIMEOUT_TO_TIME     5000
#define RTC_ADDR            0x51

const char* hostn       = "esp32";
const char* ap_ssid     = "Wristband";
const char* ap_password = "030041975";
const char* sta_ssid    = "SuhankoFamily";
const char* sta_passwd  = "fsjmr112";

bool initial            = 1;
bool timeTosleep        = false;
bool ota_running        = false;
bool showTimeRunning    = false;
bool was_touched        = false;

TFT_eSPI tft            = TFT_eSPI();

uint8_t xcolon          = 0;
uint8_t touched_times   = 0;

uint32_t targetTime     = 0;
uint32_t touched_time   = 0;

int vref                = 1100;
int pressed_time        = 0;

/* Style */
String style =
"<style>#file-input,input{width:100%;height:44px;border-radius:4px;margin:10px auto;font-size:15px}"
"input{background:#f1f1f1;border:0;padding:0 15px}body{background:#3498db;font-family:sans-serif;font-size:14px;color:#777}"
"#file-input{padding:0;border:1px solid #ddd;line-height:44px;text-align:left;display:block;cursor:pointer}"
"#bar,#prgbar{background-color:#f1f1f1;border-radius:10px}#bar{background-color:#3498db;width:0%;height:10px}"
"form{background:#fff;max-width:258px;margin:75px auto;padding:30px;border-radius:5px;text-align:center}"
".btn{background:#3498db;color:#fff;cursor:pointer}</style>";

/* Login page */
String loginIndex = 
"<form name=loginForm>"
"<h1>ESP32 Login</h1>"
"<input name=userid placeholder='User ID'> "
"<input name=pwd placeholder=Password type=Password> "
"<input type=submit onclick=check(this.form) class=btn value=Login></form>"
"<script>"
"function check(form) {"
"if(form.userid.value=='admin' && form.pwd.value=='admin')"
"{window.open('/serverIndex')}"
"else"
"{alert('Error Password or Username')}"
"}"
"</script>" + style;
 
/* Server Index Page */
String serverIndex = 
"<script src='https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js'></script>"
"<form method='POST' action='#' enctype='multipart/form-data' id='upload_form'>"
"<input type='file' name='update' id='file' onchange='sub(this)' style=display:none>"
"<label id='file-input' for='file'>   Choose file...</label>"
"<input type='submit' class=btn value='Update'>"
"<br><br>"
"<div id='prg'></div>"
"<br><div id='prgbar'><div id='bar'></div></div><br></form>"
"<script>"
"function sub(obj){"
"var fileName = obj.value.split('\\\\');"
"document.getElementById('file-input').innerHTML = '   '+ fileName[fileName.length-1];"
"};"
"$('form').submit(function(e){"
"e.preventDefault();"
"var form = $('#upload_form')[0];"
"var data = new FormData(form);"
"$.ajax({"
"url: '/update',"
"type: 'POST',"
"data: data,"
"contentType: false,"
"processData:false,"
"xhr: function() {"
"var xhr = new window.XMLHttpRequest();"
"xhr.upload.addEventListener('progress', function(evt) {"
"if (evt.lengthComputable) {"
"var per = evt.loaded / evt.total;"
"$('#prg').html('progress: ' + Math.round(per*100) + '%');"
"$('#bar').css('width',Math.round(per*100) + '%');"
"}"
"}, false);"
"return xhr;"
"},"
"success:function(d, s) {"
"console.log('success!') "
"},"
"error: function (a, b, c) {"
"}"
"});"
"});"
"</script>" + style;

byte  second     = 0;
byte  minute     = 0;
byte  hour       = 0; 
byte  dayOfMonth = 0;
byte  dayOfWeek  = 0; 
byte  month      = 0;
byte  year       = 0;


//Rtc_Pcf8563 rtc;

WebServer server(80);
BluetoothSerial SerialBT;

byte bcdToDec(byte value){
    Serial.print("decToBcd ");
    return ( (value/16*10) + (value%16) );
    Serial.println(( (value/10*16) + (value%10) ));
}

byte decToBcd(byte value){
 Serial.print("decToBcd ");
 Serial.println(( (value/10*16) + (value%10) ));
  return ( (value/10*16) + (value%10) );
}

void showClock();
void dream();

void stopOTA(){
    server.stop();
    WiFi.mode(WIFI_OFF);
    btStop();
}
/*
void scanI2Cdevice(void)
{
    uint8_t err, addr;
    int nDevices = 0;
    for (addr = 1; addr < 127; addr++) {
        Wire.beginTransmission(addr);
        err = Wire.endTransmission();
        if (err == 0) {
            Serial.print("I2C device found at address 0x");
            if (addr < 16)
                Serial.print("0");
            Serial.print(addr, HEX);
            Serial.println(" !");
            nDevices++;
        } else if (err == 4) {
            Serial.print("Unknow error at address 0x");
            if (addr < 16)
                Serial.print("0");
            Serial.println(addr, HEX);
        }
    }
    if (nDevices == 0)
        Serial.println("No I2C devices found\n");
    else
        Serial.println("Done\n");
}
*/

void readPCF8563(){

  Wire.beginTransmission(RTC_ADDR);
  Wire.write(0x02);
  Wire.endTransmission();
  Wire.requestFrom(RTC_ADDR, 7);
  second     = bcdToDec(Wire.read() & B01111111); // remove VL error bit
  minute     = bcdToDec(Wire.read() & B01111111); // remove unwanted bits from MSB
  hour       = bcdToDec(Wire.read() & B00111111); 
  dayOfMonth = bcdToDec(Wire.read() & B00111111);
  dayOfWeek  = bcdToDec(Wire.read() & B00000111);  
  month      = bcdToDec(Wire.read() & B00011111);  // remove century bit, 1999 is over
  year       = bcdToDec(Wire.read());

  Serial.print(hour);
  Serial.print(":");
  Serial.print(minute);
}
/*
void setPCF8563(){
  Wire.beginTransmission(RTC_ADDR);
  Wire.write(0x02);

  Wire.write(decToBcd(second));  
  Wire.write(decToBcd(minute));
  Wire.write(decToBcd(hour));     
  Wire.write(decToBcd(dayOfMonth));
  Wire.write(decToBcd(dayOfWeek));  
  Wire.write(decToBcd(month));
  Wire.write(decToBcd(year));  
  Wire.endTransmission();
}*/

void readTime(){
  Wire.beginTransmission(RTC_ADDR);
  Wire.write(0x03);
  Wire.endTransmission(); 
  Wire.requestFrom(RTC_ADDR,1);

  minute = bcdToDec(Wire.read() & B01111111);
  //hour   = bcdToDec(Wire.read() & B00111111); 

  Serial.print(hour);
  Serial.print(":");
  Serial.println(minute);
}

void changeSlide(){
    //Foi tocado? Se não, retorna.
   // if (!was_touched) {
   //     return;
   // }
   if (touched_times == 0 && was_touched){
       touched_time = millis();

       tft.fillScreen(TFT_SKYBLUE);
       tft.setTextColor(TFT_BLACK,TFT_DARKCYAN);
       tft.drawCentreString("Temporizador",6,0,4);
   }
   else if (touched_times == 1 && was_touched){
       tft.fillScreen(TFT_SKYBLUE);
       tft.setTextColor(TFT_BLACK,TFT_DARKCYAN);
       tft.drawCentreString("            Avancar >>",6,0,4);
   }
   else if (touched_times == 2 && was_touched){
       tft.fillScreen(TFT_SKYBLUE);
       tft.setTextColor(TFT_BLACK,TFT_DARKCYAN);
       tft.drawCentreString("<< Voltar             ",6,0,4);
   }
    int  interval = millis()-touched_time;
    //Foi tocado!
    //Serial.println("foi tocado");

    //Faz menos de 2 segundos? Se sim, incrementa o número de toques
    if (interval < 2000 && was_touched){
        touched_times += 1;
        vTaskDelay(pdMS_TO_TICKS(50));
    }
    //Passaram-se 2 segundos e foi tocado?
    if (interval >1999 && touched_times > 0){
        Serial.println("TIMOUT COM TOQUE");
        switch(touched_times){
            case 2:
                Serial.println("NEXT: >>");
                tft.fillScreen(TFT_SKYBLUE);
                tft.setTextColor(TFT_BLACK,TFT_DARKCYAN);
                tft.drawCentreString("Avancar   [OK]",6,0,4);
                break;

            case 3:
                Serial.println("PREVIOUS: <<");
                tft.fillScreen(TFT_SKYBLUE);
                tft.setTextColor(TFT_BLACK,TFT_DARKCYAN);
                tft.drawCentreString("Voltar        [OK]",6,0,4);
                break;

            case 1:
                Serial.println("Wakeup or clock requested");
                break;
        }
        touched_times = 0; //se passaram-se 2 segundos, começa novamente
        touched_time  = millis(); //zera o timer
        vTaskDelay(pdMS_TO_TICKS(1000));
        tft.fillScreen(TFT_BLACK);
        dream();
        
    }
    else if (interval> 1999){
        touched_time = millis();
        Serial.println("TIMOUT");
        touched_times = 0;
    }
    was_touched = false; // < proximo
    /*
    btStart();
    vTaskDelay(pdMS_TO_TICKS(200));
    SerialBT.begin("Djames Suhanko");

    delay(2000);
    SerialBT.end();
    Serial.println("desligado");
    SerialBT.begin("Joao");
    Serial.println("agora eh Joao");
    */
    

}

/*O processador estará dormindo. Um toque mostra a hora por TIMEOUT_TO_TIME
e volta a dormir
 */
void helloMan(){
    tft.fillScreen(TFT_SKYBLUE);
    tft.setTextColor(TFT_BLACK,TFT_DARKCYAN);
    //Do bit Ao Byte
    tft.drawCentreString("Do bit Ao Byte ",6,0,4);
}

void showTime(void *pvParameters){
    if (showTimeRunning){
        vTaskDelete(NULL);
    }
    showTimeRunning = true;
    long int initial_time = millis();
    Serial.println("showTime()");

    showClock();
    while ((millis() - initial_time) < TIMEOUT_TO_TIME){
        //showClock();
        vTaskDelay(pdMS_TO_TICKS(2));
    }
    timeTosleep     = true;
    showTimeRunning = false;
    vTaskDelete(NULL);
}

void startOTA(){
    tft.fillScreen(TFT_BLACK);

    Serial.print("Setting AP (Access Point)…");
    WiFi.mode(WIFI_AP_STA);
    WiFi. begin(sta_ssid, sta_passwd);
    while (WiFi.status() != WL_CONNECTED) {
        delay(500);
        Serial.print(" (wifi) ");
    }
    WiFi.softAP(ap_ssid, ap_password);

    if (!MDNS.begin(hostn)) { //http://esp32.local
        Serial.println("Error setting up MDNS responder!");
        while (1) {
            vTaskDelay(pdMS_TO_TICKS(1000));
        }
    }

    Serial.println("mDNS responder started");
  /*return index page which is stored in serverIndex */
  server.on("/", HTTP_GET, []() {
    server.sendHeader("Connection", "close");
    server.send(200, "text/html", loginIndex);
  });
  server.on("/serverIndex", HTTP_GET, []() {
    server.sendHeader("Connection", "close");
    server.send(200, "text/html", serverIndex);
  });
  /*handling uploading firmware file */
  server.on("/update", HTTP_POST, []() {
    server.sendHeader("Connection", "close");
    server.send(200, "text/plain", (Update.hasError()) ? "FAIL" : "OK");
    ESP.restart();
  }, []() {
    HTTPUpload& upload = server.upload();
    if (upload.status == UPLOAD_FILE_START) {
      Serial.printf("Update: %s\n", upload.filename.c_str());
      if (!Update.begin(UPDATE_SIZE_UNKNOWN)) { //start with max available size
        Update.printError(Serial);
      }
    } else if (upload.status == UPLOAD_FILE_WRITE) {
      /* flashing firmware to ESP*/
      if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) {
        Update.printError(Serial);
      }
    } else if (upload.status == UPLOAD_FILE_END) {
      if (Update.end(true)) { //true to set the size to the current progress
        Serial.printf("Update Success: %u\nRebooting...\n", upload.totalSize);
      } else {
        Update.printError(Serial);
      }
    }
  });
  server.begin();
  Serial.println("Server started");

}

/*
String getVoltage(){
    uint16_t v            = analogRead(BATT_ADC_PIN);
    float battery_voltage = ((float)v / 4095.0) * 2.0 * 3.3 * (vref / 1000.0);
    return String(battery_voltage) + "V";
}
*/

void touchMonitor(void *pvParameters){
    touched_time = millis();

    while (true){
        changeSlide();
        //pega o tempo inicial toda a vez que der uma volta
        long int initial_time = millis();
        //se o touch estiver pressionado, acumula tempo
        while (digitalRead(TP_PIN_PIN) > 0){
            was_touched = true;
            if ((millis()-initial_time) >2999){
                //(essa variavel nao deixa o loop fazer deep sleep)
                ota_running = !ota_running;
                if (ota_running){
                    startOTA();
                    tft.fillScreen(TFT_BLACK);
                    Serial.println("OTA RUNNING");
                    tft.setTextColor(TFT_BLACK,TFT_RED);
                    //tft.drawCentreString("OTA",80,28,4);
                    tft.drawCentreString("::Over The Air::",6,0,4);
                    vTaskDelay(pdMS_TO_TICKS(500));
                }
                else{
                    Serial.println("OTA STOPPED");
                    tft.setTextColor(TFT_BLACK,TFT_CYAN);
                    //tft.drawCentreString("OTA",80,28,4);
                    tft.drawCentreString("::OTA stopped::",6,0,4);
                    stopOTA();
                    vTaskDelay(pdMS_TO_TICKS(2000));
                    tft.fillScreen(TFT_BLACK);
                    timeTosleep = true;
                    ota_running = false;
                }
                
            }
           vTaskDelay(pdMS_TO_TICKS(1)); 
        }  
        //se foi maior ou igual a 2 segundos...
            
            //...senão, se foi menor que 2 segundos, mostra o relogio. Se não houve toque, não tem tempo acumulado.
            if ((millis()-initial_time) > 1000 && (millis()-initial_time) < 3000 ){
                xTaskCreatePinnedToCore(showTime,"showtime", 10000, NULL, 1, NULL,0);
                Serial.println("touched. showing time");
                vTaskDelay(pdMS_TO_TICKS(100));
                showTimeRunning = true;
            }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

void showClock(){
    readTime();
    tft.fillScreen(TFT_BLACK);
    pressed_time = millis();

        if (targetTime < millis()) {
            targetTime = millis() + 1000;
            if (second == 0 || initial) {
                initial = 0;
                tft.setTextColor(TFT_GREEN, TFT_BLACK);
                tft.setCursor (8, 60);
                tft.print(__DATE__); // This uses the standard ADAFruit small font
            }

            //tft.setTextColor(TFT_BLUE, TFT_BLACK);
            //tft.drawCentreString(getVoltage(), 120, 60, 1); // Next size up font 2


            // Update digital time
            uint8_t xpos = 6;
            uint8_t ypos = 0;
            //if (omm != mm) { // Only redraw every minute to minimise flicker
            if (true){
                // Uncomment ONE of the next 2 lines, using the ghost image demonstrates text overlay as time is drawn over it
                tft.setTextColor(0x39C4, TFT_BLACK);  // Leave a 7 segment ghost image, comment out next line!
                //tft.setTextColor(TFT_BLACK, TFT_BLACK); // Set font colour to black to wipe image
                // Font 7 is to show a pseudo 7 segment display.
                // Font 7 only contains characters [space] 0 1 2 3 4 5 6 7 8 9 0 : .
                tft.drawString("88:88", xpos, ypos, 7); // Overwrite the text to clear it
                tft.setTextColor(0xFBE0, TFT_BLACK); 

                if (hour < 10) xpos += tft.drawChar('0', xpos, ypos, 7);
                xpos += tft.drawNumber(hour, xpos, ypos, 7);
                xcolon = xpos;
                xpos += tft.drawChar(':', xpos, ypos, 7);
                if (minute < 10) xpos += tft.drawChar('0', xpos, ypos, 7);
                    tft.drawNumber(minute, xpos, ypos, 7);
            }

            if (second % 2) { // Flash the colon
                tft.setTextColor(0x39C4, TFT_BLACK);
                xpos += tft.drawChar(':', xcolon, ypos, 7);
                tft.setTextColor(0xFBE0, TFT_BLACK);
            } 
            else {
                tft.drawChar(':', xcolon, ypos, 7);
            }
        }

}

void dream(){
    uint8_t xpos = 6;
    uint8_t ypos = 0;
    tft.setTextColor(0x39C4, TFT_BLACK);
    tft.drawString("88:88", xpos, ypos, 7);
    vTaskDelay(pdMS_TO_TICKS(100));
    tft.setTextColor(0x39C4, TFT_BLACK);
    tft.drawString("OFF", xpos, ypos, 7);
    vTaskDelay(pdMS_TO_TICKS(2000));
  
    esp_sleep_enable_ext1_wakeup(GPIO_SEL_33, ESP_EXT1_WAKEUP_ANY_HIGH);
    esp_deep_sleep_start();
}

void setup() {
    tft.init();
    tft.setRotation(1);
    Serial.begin(9600);
    Wire.setClock(400000);

    pinMode(TP_PIN_PIN,INPUT);
    pinMode(TP_PWR_PIN, PULLUP);
    pinMode(RTC_INT_PIN, INPUT_PULLUP);
    pinMode(CHARGE_PIN, INPUT_PULLUP);

    stopOTA();
    
    second     = 0;
    minute     = 20;
    hour       = 13;
    dayOfWeek  = 4;
    dayOfMonth = 4;
    month      = 3;
    year       = 20;
    //setPCF8563();
    vTaskDelay(pdMS_TO_TICKS(100));
    readPCF8563();

    xTaskCreatePinnedToCore(touchMonitor,"touchMonitor", 10000, NULL, 1, NULL,0);

    Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN);

    helloMan();
    //scanI2Cdevice();
  
}

void loop(){
    server.handleClient();
    delay(1);
    if (timeTosleep && !ota_running){
        timeTosleep = false;
        dream();
    }
}

Esse código tem algum aproveitamento do sketch de exemplo da própria LilyGo.

Onde comprar a LilyGo T Wristband?

Por enquanto, só por importação. Tenho certeza que em breve teremos parceiros aqui no Brasil vendendo essa belezinha.

Vídeo de apresentação

O vídeo estará disponível em nosso canal DobitaobyteBrasil no Youtube. Se não é inscrito, inscreva-se e clique no sininho para receber notificações. Aproveite o momento em que estou disponibilizando o curso de Raspberry Pi que outrora estava na Udemy, mas resolvi tirar e disponibilizar para o público devido às insatisfações pelas mudanças feitas na Udemy, onde não pretendo voltar a colocar nada.

Não percam o vídeo, sério. Está uma delícia esse programa!

 

Revisão: Ricardo Amaral de Andrade

Comments are closed, but trackbacks and pingbacks are open.