SKAR: a Robotic Desk Companion

by bloudakm in Circuits > Microcontrollers

231 Views, 7 Favorites, 0 Comments

SKAR: a Robotic Desk Companion

FK8RUQ1MAL3IIZ9.jpg
PXL_20250512_235429204.jpg

Are you locked inside your room, sitting in front of the monitor, vigorously slamming the keys of your keyboard to code the next Boston Dynamics Spot or TikTok, pondering what is even happening outside of this realm you are in? Well, I have just the instructable for you, my friend, because I will guide you through creating your own amazing robotic desk companion.

Your little robotic companion will have a small TFT touchscreen powered by an ESP32. It will gaze at you with a pair of blinking cartoon eyes that react to the weather. Changing colour based on the temperature and tearing up when it’s raining. A quick tap on the screen will provide you a more detailed forecast screen, all pulled live from the OpenWeatherMap API.

So buckle up and prepare to finally have a friend who is not imaginary!

Supplies

FTPEL57MAL3IIZF.jpg
  1. ESP32 development board
  2. 320x240 3.2" TFT ILI9341 LCD touchscreen
  3. Step-down converter from 5V to 3.3V
  4. Female USB-C module
  5. Perfboard (at least 70x50mm)
  6. Female pin headers
  7. 2x 20-pin* (for the ESP32)
  8. 1x 14-pin (for the LCD display)
  9. 1x 3-pin (for the step-down converter)
  10. 10x M3x12 self-tapping screws
  11. A 3D printer with some material to print
  12. A micro USB cable for power and programming
  13. Weather API key from OpenWeatherMap.org
  14. A bit of patience and crippling loneliness (optional)

*The 20-pin headers will be used for the ESP32, which technically has 38 pins, so a 19-pin header would be more optimal, but I found it to be less of a hassle to just use the 20-pin.


Also for the screws and pin headers I included the exact numbers, but I highly advise to buy some more just to be safe in case for example a screw decides to play hide and seek for a couple of days.

Printing the Enclosure

Kebule_4_(Simple)_2025-May-11_10-21-26PM-000_CustomizedView5850179243_png.png
SKARRR.png

To model the shape of our friend, I have used Fusion 360, which is great news for you, because I can share the Fusion files, which are then easily editable, in case you decide to alter something. I would very much recommend this, at least for the embossed name on the top. Moreover, you can try to see all the different colour combinations of the files before actually printing them, and you can make cool little renders (without the need for a beefy PC, because Fusion does rendering in the cloud)

Overall, the enclosure is made of 5 parts, which can mostly be printed with very few supports from some basic PLA. For me, the prints took a little over 4 hours in total (on my Ender 3V3 SE), so they are not too filament and time-hungry either.

All of the parts can be printed at 0.2 layer height and any infill density you want (I printed them at 10% and they work just fine, so perhaps to lower your plastic usage, you could also go very low infill like me). The "screen bracket front" and "screen bracket back" can be printed without any supports at all, while the "bottom left part", "top right part", and the "back cover" have to be printed with supports. I printed them with organic ones, because they are easier to remove and also don't mess with other surfaces, which is quite crucial, as everything is quite a tight fit. Furthermore, some slicers might try and convince you to put supports even inside the screwholes, so tell them not to do that, as that will cause you more trouble than merit.

Down below are the STLs, which you can also find on here my Printables profile and the Fusion files.

Soldering the Circuit

PXL_20250429_202842383.jpg
SKAR_schematic.png
PXL_20250429_201318627.jpg
PXL_20250429_201424548.jpg
PXL_20250429_202429465.jpg
PXL_20250429_203555479.jpg
PXL_20250429_204030505.jpg
PXL_20250429_204040226.jpg
PXL_20250429_205036300.jpg
PXL_20250429_205723554.jpg
PXL_20250429_205728319.jpg
PXL_20250502_204431399.jpg
PXL_20250502_204437053.jpg
PXL_20250429_205057625.jpg
PXL_20250429_205449374.jpg
PXL_20250429_205501142.jpg

Continuing the theme of how awesome Fusion 360 is, I created the schematic for the circuit using the electronics designer functionality inside Fusion. So I could keep all the files nicely placed in one cloud folder accessible from anywhere. What I meant to say is that I would genuinely recommend using Fusion, because I have been using it for quite some time to model all of my funky 3D creations, and it is really nice and convenient, but I digress.

Since there is a very high chance you will be only able to buy a perfboard that is larger than the specific dimensions I mentioned, you will have to make it smaller in order to make it fit in the enclosure. This process is quite straightforward, as you just need a sharp object to scar a line across the perfboard and then gently bend in along this line, and it should nicely break into two pieces, one of which you are going to use. You can also maybe sandpaper the breakline down afterwards, as it can be quite sharp in places, and cutting yourself is not nice (at least that is what my therapist said).

After you are done breaking the perfboard, it is time to get to soldering. It is easiest to first solder the female header pins, as those will dictate where your large components will be.

Since the LCD is the largest component of them all, I placed the female 14-pin header completely to the side as it is visible on the picture (this is important, otherwise your creation might not fit in the enclosure). To make the soldering easier, I used the extra pin header I had and placed it at the other end, so the perfboard was basically sitting on those two. This ensures the headers are up straight and also as close to the perfboard as possible.

After that is done, I did the same for the two 20-pin headers for the ESP32, I placed them two pin holes away from the LCD header, so that there is enough space for routing wires, but also it is as close to the side as possible, as the USB programming port is there.

Next, solder the step-down header on the same opposite side as the ESP32 but opposite to where the LCD pins are peeking out.

Now comes the part that took me the most time, which is soldering the individual wires to make the connections from the LCD to the ESP32. Here, I would advise taking my photos as an example of how it should not really look, because my soldering skills are not the sharpest. Otherwise, just follow the schematic and have a systematic approach. I first connected all of the power lines, as those are easy to recognise while also very crucial. Afterwards, I went from left to right on all of the LCD pins I needed to connect.

The last step is soldering the USB-C for powering the robot. Here, you simply use the 5V and GND from the module and leave the data lines floating. Also, make sure you leave the wires quite loose, as the USB-C module will be pressed into the back cover, so if they are too short, removing it will be a bit annoying.

Assembly

PXL_20250512_235602566.jpg
PXL_20250513_022743201.jpg
PXL_20250513_022648346.jpg
PXL_20250512_231500644.jpg

The assembly is quite easy and fast once all of the models are printed and the electronics are soldered.

First, you take the front and back bracket for the screen, snap them onto the screen and connect them using 4 of the self-taping screws. Once the bracket is screwed together onto the screen, all that is left to do is to press the bracket into either on of the side parts, than press on the other sidepart. Once that is done, you can put the perfboard into the enclosure by sliding the pins into the female header. Since the perfboard is inside we can take the USB-C module and put it into the little cavity on the left bottom corner of the back cover. Lastly press in the back cover and screw the 6 remaining screws into the holes on the side. (with my printer the screws were not even necessary, as the whole assembly held together with the tight press fits)

Giving Life to SKAR

FJC48QHMAL3IIZ4.jpg
PXL_20250513_225933012.jpg

Now comes the programming part. If you are not a big fan of the programming and just want to get it working than here is the link to the github repo, but if you want to learn a bit about how your friend works and interacts with you (so you can finally understand a conversation between you and someone else) please stay.

As you noticed from the pictures, we are going to be programming two different screens. The main one is going to display two cute little eyes (coloured based on the temperature), which will stare very deeply into your timid little soul. The second one will show a detailed forecast with exact temperature and cloudiness for the upcoming hours. So let's get this started!


Libraries needed:

To get everything working smoothly, we’ll need a few libraries. Most of them are very common, and you should be able to install them from the Arduino Library Manager:

  1. TFT_eSPI: This one does all the heavy lifting when it comes to talking to the display. Make sure to configure the User_Setup.h file in the library by uncommenting the ILI9341 display option and then writing all of the connections from the schematic into the pins section.
  2. ArduinoJson: Used to parse all of the HTTPS data received from OpenWeatherMap.
  3. WiFi + WiFiClientSecure: These libraries will be used to connect to the internet and to

Built-in libraries:

  1. SPIFFS + FS: These are the libraries you will need to save the touch calibration file.
  2. SPI: This comes built-in with Arduino and is used for fast communication with the display.


TFT_eSPI setup:

As I already mentioned briefly, this library requires an additional step of configuration. Once you have installed the library through the Arduino Library Manager, open up File Explorer and navigate to the location of your Arduino folder. Once there, go to libraries/TFT_eSPI/. In this folder, look through the files and find the one called User_Setup.h and open it in your preferred text/code editor.

As you can see, the file has 4 main sections, but we will need to alter stuff only in the first two, where one is for selecting the display driver and the other is for specifying the pin connections between the ESP32 and the LCD display.

In the first section, make sure you have only uncommented the line "#define ILI9341_DRIVER" (for me it was on line 4,5 but this could differ). In the second section comment out everything except the ESP32 Dev Board setup (on lines 206-212). You can check the connections with the schematic, but they should be set as follows:

#define TFT_MISO 19
#define TFT_MOSI 23
#define TFT_SCLK 18
#define TFT_CS 15
#define TFT_DC 2
#define TFT_RST 4
#define TOUCH_CS 33

Once you have implemented all of these changes, make sure to save the file. That is all for the TFT_eSPI setup.


Include files:

#include <ArduinoJson.h>
#include "FS.h"
#include <HTTPClient.h>
#include "SPIFFS.h"
#include <SPI.h>
#include <TFT_eSPI.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>

#include "secrets.h"
#include "icons.h"

Apart from the libraries already mentioned, there are two files you need to include in the same folder you have your project, which are secrets.h and icons.h. The secrets header file is crucial for saving all of the stuff we need for connecting to the WiFi and OpenWeatherMap API. Here is the template for it (make sure to keep the variable names the same, otherwise they won't be recognized in the main Arduino code):

#define SSID = "YOUR_WIFI_SSID"
#define PASSWORD = "YOUR_WIFI_PASSWORD"
#define API_KEY = "YOUR_OPENWEATHER_API_KEY"
#define CITY = "YOUR_CITY"
#define COUNTRY = "YOUR_COUNTRY";// e.g., "US"

The icons header file is only a helper file for drawing icons on the second screen (meaning it only stores the bitmaps). You can copy this file from the GitHub repo, as the code itself is not special.


CA certificate:

Since we will be using the HTTPS connection, we need to store the CA certificate of the server we are connecting to. In this case, you can use the certificate I extracted, as you will also be accessing just the OpenWeatherMap; however, if you want to find the certificate of a different server/site, here is a very good tutorial on how to do exactly that.

const char *rootCACertificate = R"string_literal(
-----BEGIN CERTIFICATE-----
MIIF3jCCA8agAwIBAgIQAf1tMPyjylGoG7xkDjUDLTANBgkqhkiG9w0BAQwFADCB
iDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0pl
cnNleSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNV
BAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAw
MjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMCVVMxEzARBgNV
BAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVU
aGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0EgQ2Vy
dGlmaWNhdGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK
AoICAQCAEmUXNg7D2wiz0KxXDXbtzSfTTK1Qg2HiqiBNCS1kCdzOiZ/MPans9s/B
3PHTsdZ7NygRK0faOca8Ohm0X6a9fZ2jY0K2dvKpOyuR+OJv0OwWIJAJPuLodMkY
tJHUYmTbf6MG8YgYapAiPLz+E/CHFHv25B+O1ORRxhFnRghRy4YUVD+8M/5+bJz/
Fp0YvVGONaanZshyZ9shZrHUm3gDwFA66Mzw3LyeTP6vBZY1H1dat//O+T23LLb2
VN3I5xI6Ta5MirdcmrS3ID3KfyI0rn47aGYBROcBTkZTmzNg95S+UzeQc0PzMsNT
79uq/nROacdrjGCT3sTHDN/hMq7MkztReJVni+49Vv4M0GkPGw/zJSZrM233bkf6
c0Plfg6lZrEpfDKEY1WJxA3Bk1QwGROs0303p+tdOmw1XNtB1xLaqUkL39iAigmT
Yo61Zs8liM2EuLE/pDkP2QKe6xJMlXzzawWpXhaDzLhn4ugTncxbgtNMs+1b/97l
c6wjOy0AvzVVdAlJ2ElYGn+SNuZRkg7zJn0cTRe8yexDJtC/QV9AqURE9JnnV4ee
UB9XVKg+/XRjL7FQZQnmWEIuQxpMtPAlR1n6BB6T1CZGSlCBst6+eLf8ZxXhyVeE
Hg9j1uliutZfVS7qXMYoCAQlObgOK6nyTJccBz8NUvXt7y+CDwIDAQABo0IwQDAd
BgNVHQ4EFgQUU3m/WqorSs9UgOHYm8Cd8rIDZsswDgYDVR0PAQH/BAQDAgEGMA8G
A1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAFzUfA3P9wF9QZllDHPF
Up/L+M+ZBn8b2kMVn54CVVeWFPFSPCeHlCjtHzoBN6J2/FNQwISbxmtOuowhT6KO
VWKR82kV2LyI48SqC/3vqOlLVSoGIG1VeCkZ7l8wXEskEVX/JJpuXior7gtNn3/3
ATiUFJVDBwn7YKnuHKsSjKCaXqeYalltiz8I+8jRRa8YFWSQEg9zKC7F4iRO/Fjs
8PRF/iKz6y+O0tlFYQXBl2+odnKPi4w2r78NBc5xjeambx9spnFixdjQg3IM8WcR
iQycE0xyNN+81XHfqnHd4blsjDwSXWXavVcStkNr/+XeTWYRUc+ZruwXtuhxkYze
Sf7dNXGiFSeUHM9h4ya7b6NnJSFd5t0dCy5oGzuCr+yDZ4XUmFF0sbmZgIn/f3gZ
XHlKYC6SQK5MNyosycdiyA5d9zZbyuAlJQG03RoHnHcAP9Dc1ew91Pq7P8yF1m9/
qS3fuQL39ZeatTXaw2ewh0qpKJ4jjv9cJ2vhsE/zB+4ALtRZh8tSQZXq9EfX7mRB
VXyNWQKV3WKdwrnuWih0hKWbt5DHDAff9Yk2dDLWKMGwsAvgnEzDHNb842m1R0aB
L6KCq9NjRHDEjf8tM7qtj3u1cIiuPhnPQCjY/MiQu12ZIvVS5ljFH4gxQ+6IHdfG
jjxDah2nGN59PRbxYvnKkKj9
-----END CERTIFICATE-----)string_literal";


Constants and global variables:

Now we will define the most crucial variables for the whole GUI. Firstly, there are the touch calibration settings. The CALIBRATION_FILE name is not too important to us, as it is just a random name for the ESP32 to be able to store and retrieve the calibration data from its SPIFFS. The line below is a little more important because it dictates whether the calibration happens only once or during every boot-up of the screen. It is also very useful just in case you have calibrated the touch system poorly and want to recalibrate it.

Next, we have the screen dimensions, which are very much set and should not be changed. Lastly, we have the eye configuration. The X and Y constants refer to the top left coordinate of any object we are going to be drawing, since we are using the default datum (here are all the possible datums which can be set).

#define CALIBRATION_FILE "/TouchCalData"
#define REPEAT_CAL false

#define SCREEN_WIDTH 320
#define SCREEN_HEIGHT 240

#define EYE_X 60
#define EYE_Y 90
#define EYE_WIDTH 90
#define EYE_HEIGHT 60
#define EYE_SPACING 20


Custom data structures:

Since we are going to be concerned with the displayed eyes and the forecast, I figured it would be quite useful, perhaps to create some custom data structure to store the data we need in a more manageable manner than just an endless number of single variables.

The eyeState is a simple variable that is going to keep track of the current state of the eyes. The eyes array will store all of the data of the left and right eyes, together with the method to reset the eyes to their initial state. Lastly, the forecast array will store all the forecasts for the upcoming four 3-hour blocks.

enum EyeState {BLINK_GROW, BLINK_SHRINK, MOVE, IDLE};
EyeState eyeState = IDLE;

struct Eye {
int x, y, targetX, targetY, w, h;
int tearY = 0;
void reset(int i) {
x = EYE_X;
y = EYE_Y;
targetX = EYE_X + i*(EYE_WIDTH+EYE_SPACING);
targetY = EYE_Y;
w = EYE_WIDTH;
h = EYE_HEIGHT;
}
};
Eye eyes[2];

#define MAX_FORECAST 4
struct Forecast {
String time;
String condition;
int temp;
};
Forecast forecast[MAX_FORECAST];


Touch calibration:

Now comes the calibration of the touch system. If you feel a little lost in this code don't worry you don't have to change anything inside of it and it is basically just a quick function which starts the SPIFFS system, checks if there already is a valid calibration file and in the case that there is none it starts the calibration (asking the user to click on one arrow in each of the screen corners) and later saves it.

void touch_calibrate() {
uint16_t calData[5];
uint8_t calDataOK = 0;
// Starting SPIFFS file system
if (!SPIFFS.begin()) {
//Serial.println("Formatting file system");
SPIFFS.format();
SPIFFS.begin();
}
// Check if calibration already exists
if (SPIFFS.exists(CALIBRATION_FILE)) {
if (REPEAT_CAL) {
// Repeat calibration in case user wants to
SPIFFS.remove(CALIBRATION_FILE);
} else {
// Load last calibration file
fs::File f = SPIFFS.open(CALIBRATION_FILE, "r");
if (f) {
if (f.readBytes((char *)calData, 14) == 14)
calDataOK = 1;
f.close();
}
}
}
if (calDataOK && !REPEAT_CAL) {
// Set calibration for display
tft.setTouch(calData);
} else {
// In case of faulty calibration file repeat it
tft.fillScreen(TFT_BLACK);
tft.setCursor(20, 0);
tft.setTextFont(2);
tft.setTextSize(1);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.println("Touch corners as indicated");
tft.setTextFont(1);
tft.println();
if (REPEAT_CAL) {
tft.setTextColor(TFT_RED, TFT_BLACK);
tft.println("Set REPEAT_CAL to false to stop this running again!");
}
// During the calibration the user has to click on an arrow in each corner
tft.calibrateTouch(calData, TFT_MAGENTA, TFT_BLACK, 15);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.println("Calibration complete!");
// Saving calibration file into SPIFFS
fs::File f = SPIFFS.open(CALIBRATION_FILE, "w");
if (f) {
f.write((const unsigned char *)calData, 14);
f.close();
}
}
}


Fetching data:

Another undoubtedly crucial function is the one for fetching the results from the API. This function creates an HTTPS connection, sends the request for the data we want, and most importantly, parses the data we have received.

void fetchWeather() {
// Check if still connected to WiFi
if (WiFi.status() == WL_CONNECTED) {
// Create the new secure client and check validity
WiFiClientSecure *client = new WiFiClientSecure;
if(client) {
// Set the certificate mentioned previously
client->setCACert(rootCACertificate);
// Beware these parantheses ensure client is destroyed only after https
{
// Initialize https object
HTTPClient https;
// Craft the URL with parameters of user
String url = "https://api.openweathermap.org/data/2.5/forecast?q=";
url += String(CITY) + "," + String(COUNTRY);
url += "&units=metric&appid=" + String(API_KEY);
// Start https connection
if(https.begin(*client, url)) {
// Send the GET request
int httpCode = https.GET();
if (httpCode > 0) {
String payload = https.getString();
Serial.println("Request payload: ");
Serial.println(payload);
// Prepare JSON variables and parse
DynamicJsonDocument doc(8192);
deserializeJson(doc, payload);
// Save the parsed values into our forecast array
for (int i = 0; i < MAX_FORECAST; i++) {
String timestamp = doc["list"][i]["dt_txt"].as<String>();
forecast[i].time = timestamp.substring(11, 16);
forecast[i].temp = int(doc["list"][i]["main"]["temp"].as<float>());
forecast[i].condition = doc["list"][i]["weather"][0]["main"].as<String>();
}
}
https.end();
} else {
Serial.println("HTTPS Unable to connect\n");
}
}
delete client;
} else {
Serial.println("Unable to create client");
}
}
}


Drawing eyes:

I am sure you have seen that a lot of the popular desktop robots have the cute little eyes that move around, so we are going to be implementing the exact same thing. And you know what they say, the eyes don't lie, chico.

// Function for converting temperature to eye color
int tempToColor(int tempC) {
if (tempC <= 0) return tft.color565(0, 100, 255); // blue
if (tempC <= 10) return tft.color565(0, 200, 255);
if (tempC <= 20) return tft.color565(0, 255, 150);
if (tempC <= 30) return tft.color565(255, 200, 0);
return tft.color565(255, 0, 0); // red
}

// Function for making the eyes blink
void eyesBlink() {
Serial.println("blinkin");
tft.fillScreen(TFT_BLACK);
for(int i = 0; i < 2; i++) {
if(eyeState == BLINK_SHRINK) {
if(eyes[i].h > 10) {
eyes[i].h -= 5;
} else {
eyeState = BLINK_GROW;
eyes[i].h += 5;
}
} else if(eyeState == BLINK_GROW) {
if(eyes[i].h < EYE_HEIGHT) {
eyes[i].h += 5;
} else {
eyeState = IDLE;
eyes[i].reset(i);
lastBlink = currentMillis;
}
}
tft.fillRoundRect(eyes[i].x, eyes[i].y-3*(60-eyes[i].h), eyes[i].w, eyes[i].h, 10, eyeColor);
}
}

// Function for moving the eyes from side to side
void eyesMove() {
bool stillMoving = false;
tft.fillScreen(TFT_BLACK);
for (int i = 0; i < 2; i++) {
int dx = eyes[i].targetX - eyes[i].x;
int dy = eyes[i].targetY - eyes[i].y;
// Smooth easing
eyes[i].x += dx * 0.2;
eyes[i].y += dy * 0.2;
// Check if more movement is needed
if (abs(dx) > 1 || abs(dy) > 1) stillMoving = true;
tft.fillRoundRect(eyes[i].x, eyes[i].y, eyes[i].w, eyes[i].h, 10, eyeColor);
}
if (!stillMoving) {
for (int i = 0; i < 2; i++) eyes[i].reset(i);
eyeState = IDLE;
lastMove = currentMillis;
}
}

// Drawing out the main screen
void drawMainScreen() {
Serial.println("Main screen");
tft.fillScreen(TFT_BLACK);
// Color of eye
eyeColor = tempToColor(forecast[0].temp);
// Eyes
for (int i = 0; i < 2; i++) {
eyes[i].reset(i);
}
tft.fillRoundRect(176, 78, 108, 98, 10, 0x77E0);
tft.fillEllipse(230, 126, 9, 41, 0x0);
tft.fillRoundRect(46, 78, 108, 98, 10, 0x77E0);
tft.fillEllipse(107, 126, 9, 41, 0x0);
}

// Drawing the second screen which shows the detailed forecast
void drawDetails() {
tft.fillScreen(TFT_BLACK);
tft.setTextSize(2);
tft.setTextColor(TFT_WHITE);
for (int i = 0; i < MAX_FORECAST; i++) {
int x = 10;
int y = 10 + i * 55;
tft.setCursor(x, y);
tft.print(forecast[i].time);
tft.setCursor(x + 100, y);
tft.print(forecast[i].temp);
tft.print(" C");
drawWeatherIcon(forecast[i].condition, x + 200, y);
}
}

// Drawing the individual icons for the forecast
void drawWeatherIcon(String condition, int x, int y) {
if (condition == "Clear") {
tft.fillCircle(x, y, 10, TFT_YELLOW);
} else if (condition == "Clouds") {
tft.fillRoundRect(x - 10, y - 5, 20, 10, 3, TFT_LIGHTGREY);
} else if (condition == "Rain") {
tft.fillRoundRect(x - 10, y - 5, 20, 10, 3, TFT_LIGHTGREY);
tft.fillCircle(x - 5, y + 8, 2, TFT_BLUE);
tft.fillCircle(x + 5, y + 8, 2, TFT_BLUE);
} else {
tft.drawXBitmap(x, y, rain_bits, 16, 16, TFT_WHITE); // fallback
}
}

// Getting a random number from two disjoint sets
int createRandom(int min, int max) {
int rand = random(min, max);
if(rand < (max/2) && rand > (min/2)) {
return createRandom(min, max);
} else {
Serial.print("Random number: ");
Serial.println(rand);
return rand;
}
}

// For clamping the random values
int clamp(int value, int minVal, int maxVal) {
return max(minVal, min(value, maxVal));
}


Setup and loop

Great work! You have all of the components we need for the app to function. Now let's piece them together inside the setup and loop function to bring our friend truly to life.

void setup() {
Serial.begin(115200);
Serial.println("Starting app");
// Setting up pin for LCD backlight
pinMode(16, OUTPUT);
// Mounting SPIFFS
if(!SPIFFS.begin(true)){
Serial.println("An Error has occurred while mounting SPIFFS");
return;
}
Serial.println("SPIFFS mounted");
// Initializing the TFT display
tft.init();
tft.setRotation(3); // adjust if needed
tft.fillScreen(TFT_BLACK);
tft.setTextFont(2);
digitalWrite(16, HIGH); // turning on backlight
Serial.println("TFT initialized");
touch_calibrate();
Serial.println("Touch calibrated");
tft.begin();
// Seed randomness
randomSeed(analogRead(0));
// Calling functions for starting the GUI
connectWiFi();
fetchWeather();
drawMainScreen();
}

void loop() {
// Setting current time
currentMillis = millis();

// Initializing variables for touch detection
uint16_t x, y;
// Switch screen in case user taps the display
if (tft.getTouch(&x, &y)) {
if (currentMillis - lastTouchTime > 800) {
showDetails = !showDetails;
if(showDetails) {
eyeState = IDLE;
drawDetails();
} else {
drawMainScreen();
}
lastTouchTime = currentMillis;
}
}
if (!showDetails && currentMillis - lastFrame < 0) {
if(currentMillis-lastBlink > blinkingPeriod && eyeState == IDLE) {
eyeState = BLINK_SHRINK;
} else if (currentMillis-lastMove > random(2000, 3000) && eyeState == IDLE) {
int randomX = createRandom(-40, 40);
int randomY = createRandom(-20, 20);
// Set new target, but clamp it to screen bounds
for (int i = 0; i < 2; i++) {
eyes[i].targetX = clamp(eyes[i].x + randomX, 0, SCREEN_WIDTH - EYE_WIDTH);
eyes[i].targetY = clamp(eyes[i].y + randomY, 0, SCREEN_HEIGHT - EYE_HEIGHT);
}
eyeState = MOVE;
}
switch (eyeState){
case BLINK_SHRINK:
case BLINK_GROW:
eyesBlink();
break;
case MOVE:
eyesMove();
break;
default:
break;
}
lastFrame = currentMillis;
//Serial.println(String(eyes));
}
// update information every 10 minutes
if(currentMillis - lastAPICall > (10*60*1000) && eyeState == IDLE) {
fetchWeather();
showDetails ? drawDetails() : drawMainScreen();
lastAPICall = currentMillis;
}
}



Enjoy the Companion

If you’ve made it this far — congrats, and thank you! I really hope you’ve enjoyed building this little companion as much as I did putting it together. Whether it’s now sitting on your desk making funny faces, keeping you company while you work, or just staring blankly into the void (relatable) — I think that counts as a win.

This was a passion project for me, and I’ve learned a lot along the way. If it helped you learn something new, sparked an idea, or just gave you a reason to dig out your 3D printer again, then I’m genuinely happy.

Feel free to remix it, tweak it, make it better (especially the eyes 👀) — and please do share your version! I'd love to see how others bring their own spin to it.

Thanks again for following along