/*
- 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);
}
Comments