Skip to content

Instantly share code, notes, and snippets.

@OzTamir
Created March 11, 2026 09:00
Show Gist options
  • Select an option

  • Save OzTamir/afeafbc16398c14d5b75010584a5219a to your computer and use it in GitHub Desktop.

Select an option

Save OzTamir/afeafbc16398c14d5b75010584a5219a to your computer and use it in GitHub Desktop.
Set LED Filament to glow whenever a message is sent on a telegram channel
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <math.h>
// ---- User settings ----
const char* WIFI_SSID = "WIFI_SSID";
const char* WIFI_PASSWORD = "WIFI_PASSWORD";
const char* CHANNEL_USERNAME = "YOUR_TELEGRAM_CHANNEL";
// M5 ATOM Echo: use a Grove pin (G26 recommended, G32 also possible).
const uint8_t FILAMENT_PIN = 26; // GPIO that drives your MOSFET/transistor gate/base
// Poll telegram every 5 seconds.
const unsigned long POLL_INTERVAL_MS = 5UL * 1000UL;
// Keep filament ON for 10 minutes after each detected message.
const unsigned long LIGHT_DURATION_MS = 10UL * 60UL * 1000UL;
// PWM glow effect while active.
const uint16_t PWM_FREQ_HZ = 5000;
const uint8_t PWM_RES_BITS = 8;
const uint8_t GLOW_MIN_DUTY = 26; // ~10% of 255
const uint8_t GLOW_MAX_DUTY = 102; // ~40% of 255
const unsigned long GLOW_CYCLE_MS = 2800;
const unsigned long HEARTBEAT_INTERVAL_MS = 5000;
// ---- Internal state ----
unsigned long filamentOffAt = 0;
int lastSeenMessageId = -1;
portMUX_TYPE filamentMux = portMUX_INITIALIZER_UNLOCKED;
bool pollInLoopFallback = false;
unsigned long lastFallbackPollAt = 0;
unsigned long lastHeartbeatAt = 0;
String channelUrl() {
return String("https://t.me/s/") + CHANNEL_USERNAME;
}
const char* wifiStatusText(wl_status_t status) {
switch (status) {
case WL_CONNECTED: return "CONNECTED";
case WL_DISCONNECTED: return "DISCONNECTED";
case WL_IDLE_STATUS: return "IDLE";
case WL_NO_SSID_AVAIL: return "NO_SSID";
case WL_CONNECT_FAILED: return "CONNECT_FAILED";
case WL_CONNECTION_LOST: return "CONNECTION_LOST";
default: return "UNKNOWN";
}
}
void ensureWifiConnected() {
if (WiFi.status() == WL_CONNECTED) {
return;
}
Serial.print("Connecting to WiFi");
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
const unsigned long start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < 20000) {
delay(400);
Serial.print(".");
}
Serial.println();
if (WiFi.status() == WL_CONNECTED) {
Serial.print("WiFi connected, IP: ");
Serial.println(WiFi.localIP());
} else {
Serial.println("WiFi connect timeout");
}
}
int fetchLatestMessageId() {
WiFiClientSecure client;
client.setInsecure(); // Simpler TLS setup on ESP32; replace with CA pinning for production.
HTTPClient https;
https.setTimeout(10000);
const String url = channelUrl();
if (!https.begin(client, url)) {
Serial.println("HTTPS begin failed");
return -1;
}
const int code = https.GET();
if (code != HTTP_CODE_OK) {
Serial.print("HTTPS GET failed, code=");
Serial.println(code);
https.end();
return -1;
}
// Parse message IDs from the stream to avoid allocating the whole HTML page.
WiFiClient* stream = https.getStreamPtr();
const String needle = String("data-post=\"") + CHANNEL_USERNAME + "/";
const size_t needleLen = needle.length();
int maxId = -1;
int currentId = 0;
size_t matched = 0;
bool readingId = false;
unsigned long lastByteAt = millis();
const unsigned long readTimeoutMs = 10000;
while (https.connected() && (stream->available() > 0 || millis() - lastByteAt < readTimeoutMs)) {
while (stream->available() > 0) {
char c = (char)stream->read();
lastByteAt = millis();
if (readingId) {
if (isDigit(c)) {
currentId = currentId * 10 + (c - '0');
continue;
}
if (currentId > maxId) {
maxId = currentId;
}
readingId = false;
currentId = 0;
}
if (c == needle[matched]) {
matched++;
if (matched == needleLen) {
readingId = true;
currentId = 0;
matched = 0;
}
} else {
matched = (c == needle[0]) ? 1 : 0;
}
}
delay(1);
}
if (readingId && currentId > maxId) {
maxId = currentId;
}
https.end();
return maxId;
}
bool detectNewMessage() {
int latest = fetchLatestMessageId();
if (latest <= 0) {
Serial.println("Could not parse latest message id");
return false;
}
Serial.print("Latest message id: ");
Serial.println(latest);
if (lastSeenMessageId < 0) {
lastSeenMessageId = latest;
Serial.println("Primed initial message id (no trigger)");
return false;
}
if (latest > lastSeenMessageId) {
lastSeenMessageId = latest;
return true;
}
return false;
}
void setFilament(bool on) {
ledcWrite(FILAMENT_PIN, on ? GLOW_MAX_DUTY : 0);
}
unsigned long getFilamentOffAt() {
unsigned long value = 0;
portENTER_CRITICAL(&filamentMux);
value = filamentOffAt;
portEXIT_CRITICAL(&filamentMux);
return value;
}
void extendFilamentWindow(unsigned long newOffAt) {
portENTER_CRITICAL(&filamentMux);
if ((long)(newOffAt - filamentOffAt) > 0) {
filamentOffAt = newOffAt;
}
portEXIT_CRITICAL(&filamentMux);
}
uint8_t computeGlowDuty(unsigned long nowMs) {
if (GLOW_CYCLE_MS == 0 || GLOW_MAX_DUTY <= GLOW_MIN_DUTY) {
return GLOW_MIN_DUTY;
}
// Smooth "breathe" waveform: min -> max -> min.
const float phase = (nowMs % GLOW_CYCLE_MS) / (float)GLOW_CYCLE_MS; // 0..1
const float wave = 0.5f - 0.5f * cosf(6.2831853f * phase); // 0..1..0
const float shaped = wave * wave; // Perceptually softer ramp.
const float duty = GLOW_MIN_DUTY + shaped * (GLOW_MAX_DUTY - GLOW_MIN_DUTY);
return (uint8_t)roundf(duty);
}
void updateFilament() {
const unsigned long now = millis();
const unsigned long offAt = getFilamentOffAt();
const bool shouldGlow = ((long)(offAt - now) > 0);
const uint8_t duty = shouldGlow ? computeGlowDuty(now) : 0;
ledcWrite(FILAMENT_PIN, duty);
}
void printHeartbeat(const char* source) {
const unsigned long now = millis();
if (now - lastHeartbeatAt < HEARTBEAT_INTERVAL_MS) {
return;
}
lastHeartbeatAt = now;
const unsigned long offAt = getFilamentOffAt();
const long remainMs = (long)(offAt - now);
Serial.print("[");
Serial.print(source);
Serial.print("] up=");
Serial.print(now / 1000);
Serial.print("s wifi=");
Serial.print(wifiStatusText(WiFi.status()));
Serial.print(" latest=");
Serial.print(lastSeenMessageId);
Serial.print(" glow=");
Serial.print(remainMs > 0 ? "ON" : "OFF");
if (remainMs > 0) {
Serial.print(" remain=");
Serial.print(remainMs / 1000);
Serial.print("s");
}
Serial.println();
}
void telegramPollTask(void* /*param*/) {
unsigned long lastPollAt = 0;
Serial.println("telegramPollTask started");
for (;;) {
const unsigned long now = millis();
if (now - lastPollAt >= POLL_INTERVAL_MS) {
lastPollAt = now;
ensureWifiConnected();
Serial.println("Polling Telegram...");
if (detectNewMessage()) {
extendFilamentWindow(now + LIGHT_DURATION_MS);
Serial.println("New message detected -> glowing filament for 10 minutes");
}
}
vTaskDelay(pdMS_TO_TICKS(50));
}
}
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println();
Serial.println("Booting telegram filament sketch...");
if (!ledcAttach(FILAMENT_PIN, PWM_FREQ_HZ, PWM_RES_BITS)) {
Serial.println("LEDC attach failed");
while (true) {
delay(1000);
}
}
setFilament(false);
Serial.print("Monitoring channel: ");
Serial.println(CHANNEL_USERNAME);
// Keep network polling on another task so the glow PWM stays smooth.
BaseType_t rc = xTaskCreatePinnedToCore(
telegramPollTask,
"telegramPoll",
16384,
nullptr,
1,
nullptr,
0
);
if (rc != pdPASS) {
pollInLoopFallback = true;
Serial.println("Failed to create polling task, using loop fallback");
} else {
Serial.println("Polling task created");
}
}
void loop() {
updateFilament();
if (pollInLoopFallback) {
const unsigned long now = millis();
if (now - lastFallbackPollAt >= POLL_INTERVAL_MS) {
lastFallbackPollAt = now;
ensureWifiConnected();
Serial.println("Polling Telegram (loop fallback)...");
if (detectNewMessage()) {
extendFilamentWindow(now + LIGHT_DURATION_MS);
Serial.println("New message detected -> glowing filament for 10 minutes");
}
}
}
printHeartbeat("loop");
delay(10);
}
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_DIR="$SCRIPT_DIR/.arduino-cli"
FQBN="esp32:esp32:m5stack_atom"
SKETCH_FILE="$SCRIPT_DIR/esp32_telegram_filament.ino"
TMP_SKETCH_DIR="$SCRIPT_DIR/.tmp-sketch/esp32_telegram_filament"
BUILD_DIR="$SCRIPT_DIR/.arduino-build-m5atom-telegram"
usage() {
cat <<USAGE
Usage: $0 [--port /dev/cu.usbserial-XXXX]
Flashes esp32_telegram_filament.ino to an M5 Atom target.
USAGE
}
detect_port() {
local port
port="$(arduino-cli --config-dir "$CONFIG_DIR" board list | awk 'NR>1 && $1 ~ /\/dev\/cu\.(usbserial|usbmodem)/ { print $1; exit }')"
if [[ -z "$port" ]]; then
for cand in /dev/cu.usbserial* /dev/cu.usbmodem*; do
if [[ -e "$cand" ]]; then
port="$cand"
break
fi
done
fi
echo "$port"
}
PORT=""
while [[ $# -gt 0 ]]; do
case "$1" in
--port)
[[ $# -ge 2 ]] || { echo "Missing value for --port"; exit 1; }
PORT="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1"
usage
exit 1
;;
esac
done
if [[ -z "$PORT" ]]; then
PORT="$(detect_port)"
fi
if [[ -z "$PORT" ]]; then
echo "No serial port detected. Connect board or pass --port." >&2
exit 1
fi
mkdir -p "$TMP_SKETCH_DIR"
cp "$SKETCH_FILE" "$TMP_SKETCH_DIR/esp32_telegram_filament.ino"
echo "Using port: $PORT"
echo "Compiling: $TMP_SKETCH_DIR"
arduino-cli --config-dir "$CONFIG_DIR" compile --fqbn "$FQBN" --build-path "$BUILD_DIR" "$TMP_SKETCH_DIR"
echo "Uploading..."
arduino-cli --config-dir "$CONFIG_DIR" upload --fqbn "$FQBN" -p "$PORT" --input-dir "$BUILD_DIR" "$TMP_SKETCH_DIR"
echo "Done."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment