Ramazan Eren Arslan
Published © GPL3+

Lovlet - Desktop Pixel Image Displayer For Your Partner

A tiny desktop screen that lights up with drawing sent from the other partner. Similar to those Widget-Apps but more personal :)

IntermediateShowcase (no instructions)5 hours53
Lovlet - Desktop Pixel Image Displayer For Your Partner

Things used in this project

Hardware components

0.96" OLED 64x128 Display Module
ElectroPeak 0.96" OLED 64x128 Display Module
×1
Esp32 C3 mini
×1
4,2v Lipo Battery
×1
LiPo Charger Circuit
×1
Gravity:Digital Push Button (Yellow)
DFRobot Gravity:Digital Push Button (Yellow)
×1

Software apps and online services

Arduino IDE
Arduino IDE
GitHub

Hand tools and fabrication machines

Soldering iron (generic)
Soldering iron (generic)

Story

Read more

Code

Arduino Code

Arduino
/*

  - Fetches pixel art from remote gist every 30 min
  - If new image is available:
      -> cache it but don't show immediately
      -> LED flashes twice (with 1 min gap) repeatedly as a notification
  - When button is pressed:
      -> stop LED notifications
      -> show new image on OLED for 30 sec
  - OLED and Wi-Fi stay off during notification blinks to save power
*/

#include <Arduino.h>
#include <Wire.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <Preferences.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include "esp_sleep.h"

// ---------------- Hardware / pins ----------------
#define SDA_PIN      8
#define SCL_PIN      10
#define BUTTON_PIN   1    // use a safe GPIO instead of 1 (TX pin)
#define LED_PIN      7    // Blink/notify LED

// ---------------- Display ----------------
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_ADDR    0x3C
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);

// ---------------- WiFi / Gist ----------------
const char* WIFI_SSID = "****";
const char* WIFI_PASS = "****";
const char* MSG_URL   = "****";

// ---------------- Timing / behavior ----------------
#define WIFI_CONNECT_TIMEOUT_MS 15000UL
#define SLEEP_MINUTES           1UL   // every 30 min check

// ---------------- Caching / prefs ----------------
Preferences prefs;
const char* PREF_NS        = "desklocket";
const char* KEY_LAST_IMG   = "last_img"; // binary packed bitmap (1024 bytes)
const char* KEY_LAST_W     = "last_w";
const char* KEY_LAST_H     = "last_h";
const char* KEY_PENDING    = "img_pending"; // bool flag for new image waiting

// ---------------- Derived ----------------
const size_t PACKED_SIZE = ((size_t)SCREEN_WIDTH * SCREEN_HEIGHT + 7) / 8; // 1024

// ---------------- State ----------------
bool newImagePending = false;

// ---------------- Utilities ----------------
void oledInit() {
  Wire.begin(SDA_PIN, SCL_PIN);
  if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
    Serial.println("ERROR: OLED init failed");
    while (1) delay(1000);
  }
  display.clearDisplay();
  display.display();
}

bool waitForWiFi(uint32_t timeoutMs) {
  uint32_t start = millis();
  while (WiFi.status() != WL_CONNECTED && (millis() - start) < timeoutMs) {
    delay(50);
  }
  return (WiFi.status() == WL_CONNECTED);
}

void wifiConnect() {
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
}

void wifiDisconnectAll() {
  WiFi.disconnect(true, false); // keep AP config, just turn radio off
  WiFi.mode(WIFI_OFF);
}

// set bit in packed buffer
static inline void packedSetBit(uint8_t *buf, int x, int y) {
  size_t idx = (size_t)y * SCREEN_WIDTH + x;
  size_t byteIndex = idx >> 3;
  uint8_t bit = idx & 7;
  buf[byteIndex] |= (1u << bit);
}
static inline void packedClear(uint8_t *buf) { memset(buf, 0, PACKED_SIZE); }

void drawFromPacked(const uint8_t *buf) {
  display.clearDisplay();
  for (int y = 0; y < SCREEN_HEIGHT; ++y) {
    for (int x = 0; x < SCREEN_WIDTH; ++x) {
      size_t idx = (size_t)y * SCREEN_WIDTH + x;
      uint8_t byte = buf[idx >> 3];
      uint8_t mask = (1u << (idx & 7));
      if (byte & mask) display.drawPixel(x, y, SSD1306_WHITE);
    }
  }
  display.display();
}

bool httpFetch(String &out) {
  String url = String(MSG_URL) + "?cb=" + String(millis());
  WiFiClientSecure client;
  client.setInsecure(); 
  HTTPClient http;
  if (!http.begin(client, url)) return false;
  http.setTimeout(10000);
  int code = http.GET();
  if (code != HTTP_CODE_OK) {
    http.end();
    return false;
  }
  out = http.getString();
  http.end();
  return true;
}

void parseDrawPixelCommandsToPacked(const String &commands, uint8_t *packedBuf) {
  packedClear(packedBuf);
  int pos = 0;
  while (true) {
    int dp = commands.indexOf("drawPixel", pos);
    if (dp < 0) break;
    int popen = commands.indexOf('(', dp);
    if (popen < 0) break;
    int pclose = commands.indexOf(')', popen);
    if (pclose < 0) break;

    String inside = commands.substring(popen + 1, pclose);
    inside.trim();
    if (inside.length() == 0) { pos = pclose + 1; continue; }

    int comma1 = inside.indexOf(',');
    if (comma1 < 0) { pos = pclose + 1; continue; }
    String xs = inside.substring(0, comma1); xs.trim();

    String after = inside.substring(comma1 + 1);
    int comma2 = after.indexOf(',');
    String ys = (comma2 < 0) ? after : after.substring(0, comma2);
    ys.trim();

    int x = xs.toInt();
    int y = ys.toInt();
    if (x >= 0 && x < SCREEN_WIDTH && y >= 0 && y < SCREEN_HEIGHT) {
      packedSetBit(packedBuf, x, y);
    }
    pos = pclose + 1;
  }
}

bool parsePayloadToPacked(const String &payload, uint8_t *packedBuf) {
  String p = payload; p.trim();
  if (p.length() > 0 && p.charAt(0) == '{') {
    size_t cap = p.length() + 1024;
    DynamicJsonDocument doc(cap);
    if (!deserializeJson(doc, p)) {
      if (doc.containsKey("pixels")) {
        parseDrawPixelCommandsToPacked(doc["pixels"].as<const char*>(), packedBuf);
        return true;
      }
      if (doc.containsKey("points")) {
        packedClear(packedBuf);
        for (JsonVariant v : doc["points"].as<JsonArray>()) {
          if (v.is<JsonArray>()) {
            int x = v[0].as<int>();
            int y = v[1].as<int>();
            if (x >= 0 && x < SCREEN_WIDTH && y >= 0 && y < SCREEN_HEIGHT)
              packedSetBit(packedBuf, x, y);
          }
        }
        return true;
      }
      if (doc.containsKey("bytes")) {
        JsonArray arr = doc["bytes"].as<JsonArray>();
        if (arr.size() >= (int)PACKED_SIZE) {
          for (size_t i=0;i<PACKED_SIZE;i++) packedBuf[i] = (uint8_t)arr[i].as<int>();
          return true;
        }
      }
    }
  }
  parseDrawPixelCommandsToPacked(payload, packedBuf);
  return true;
}

void savePackedToPrefs(const uint8_t *packedBuf, size_t len) {
  prefs.putInt(KEY_LAST_W, SCREEN_WIDTH);
  prefs.putInt(KEY_LAST_H, SCREEN_HEIGHT);
  prefs.putBytes(KEY_LAST_IMG, packedBuf, len);
}

size_t loadPackedFromPrefs(uint8_t *outBuf, size_t maxLen) {
  size_t storedLen = prefs.getBytesLength(KEY_LAST_IMG);
  if (storedLen == 0 || storedLen > maxLen) return 0;
  prefs.getBytes(KEY_LAST_IMG, outBuf, storedLen);
  return storedLen;
}


// Just check if new image is available, don't draw yet
void checkForNewImage() {
  wifiConnect();
  if (!waitForWiFi(WIFI_CONNECT_TIMEOUT_MS)) return;
  String payload;
  if (!httpFetch(payload)) return;

  static uint8_t newPacked[PACKED_SIZE];
  parsePayloadToPacked(payload, newPacked);

  uint8_t oldPacked[PACKED_SIZE];
  size_t oldLen = loadPackedFromPrefs(oldPacked, PACKED_SIZE);

  bool isNew = (oldLen != PACKED_SIZE || memcmp(oldPacked, newPacked, PACKED_SIZE) != 0);
  if (isNew) {
    savePackedToPrefs(newPacked, PACKED_SIZE);
    newImagePending = true;
    prefs.putBool(KEY_PENDING, true);
  }
  wifiDisconnectAll();
}

void notificationLoop() {
  while (newImagePending) {
    for (int i = 0; i < 2; i++) {
      neopixelWrite(LED_PIN, 32, 0, 0); // red
      delay(160);
      neopixelWrite(LED_PIN, 32, 0, 0); // red
      delay(160);

      // Prepare for light sleep 1 min or button
      pinMode(BUTTON_PIN, INPUT_PULLUP);
      wifiDisconnectAll();
      display.ssd1306_command(SSD1306_DISPLAYOFF);

      gpio_wakeup_enable((gpio_num_t)BUTTON_PIN, GPIO_INTR_LOW_LEVEL);
      esp_sleep_enable_gpio_wakeup();
      esp_sleep_enable_timer_wakeup(60ULL * 1000000ULL);

      esp_light_sleep_start();

      display.ssd1306_command(SSD1306_DISPLAYON);

      // if button pressed, show new image
      if (digitalRead(BUTTON_PIN) == LOW) {
        uint8_t buf[PACKED_SIZE];
        if (loadPackedFromPrefs(buf, PACKED_SIZE) == PACKED_SIZE) {
          drawFromPacked(buf);
        }
        delay(30000); // show for 30s
        newImagePending = false;
        prefs.putBool(KEY_PENDING, false);
        return;
      }
    }
  }
}

void goLightSleep(uint32_t minutes) {
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  wifiDisconnectAll();
  display.ssd1306_command(SSD1306_DISPLAYOFF);
  display.clearDisplay();
  display.display();

  gpio_wakeup_enable((gpio_num_t)BUTTON_PIN, GPIO_INTR_LOW_LEVEL);
  esp_sleep_enable_gpio_wakeup();
  esp_sleep_enable_timer_wakeup((uint64_t)minutes * 60ULL * 1000000ULL);

  esp_light_sleep_start();
  delay(20);
  display.ssd1306_command(SSD1306_DISPLAYON);
}

// ---------------- Main ----------------
void setup() {
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);

  Serial.begin(115200);
  delay(200);

  prefs.begin(PREF_NS, false);
  oledInit();

  newImagePending = prefs.getBool(KEY_PENDING, false);

  if (newImagePending) {
    notificationLoop();
  } else {
    checkForNewImage();
    if (newImagePending) notificationLoop();
  }

  goLightSleep(SLEEP_MINUTES);
}

void loop() {
  oledInit();
  newImagePending = prefs.getBool(KEY_PENDING, false);

  if (newImagePending) {
    notificationLoop();
  } else {
    checkForNewImage();
    if (newImagePending) notificationLoop();
  }

  goLightSleep(SLEEP_MINUTES);
}

Credits

Ramazan Eren Arslan
4 projects • 1 follower
Electrical Engineering Student @ THWS

Comments