Created
March 11, 2026 09:00
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #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); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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