esp8266-remote-timer

ESP8266 based timer w/ remote control and NTP sync
Index Commits Files Refs README LICENSE
commit 88e2ad719e07c7c655d491b9cf1f1d0c69f3f99e
Author: Martin Kloeckner <mjkloeckner@gmail.com>
Date:   Mon, 24 Mar 2025 19:09:36 -0300

first commit!

Diffstat:
A.gitignore | 1+
Adata/index.html | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adata/main.js | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adata/style.css | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aplatformio.ini | 23+++++++++++++++++++++++
Asrc/main.cpp | 268+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 556 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1 @@
+.pio
diff --git a/data/index.html b/data/index.html
@@ -0,0 +1,87 @@
+<!DOCTYPE html>
+<html>
+    <head>
+        <title>ESP8266 Web Server</title>
+        <meta name="viewport" content="width=device-width, initial-scale=1.0">
+        <link rel="icon" href="data:,">
+        <link rel="stylesheet" type="text/css" href="style.css">
+        <script type="text/javascript" src="main.js"></script>
+    </head>
+    <body>
+        <div id="main-div">
+            <h1 id="title">ESP8266 Web Server</h1>
+            <div id="buttons-div">
+                <div id="main-toggle-div" onclick="handle_click('main-toggle')">
+                    <table>
+                        <tr style="vertical-align: bottom;">
+                            <td id="label-box">
+                                Main output enable
+                            </td>
+                            <td>
+                                <input type="checkbox" id="main-output-checkbox" name="main-output-checkbox"/>
+                            </td>
+                        </tr>
+                    </table>
+                </div>
+
+                <!-- <div id="timer-div" onclick="handle_click('timer-toggle)"> -->
+                <!--     <table> -->
+                <!--         <tr style="vertical-align: bottom;"> -->
+                <!--             <td id="label-box"> -->
+                <!--                 Timer enabled -->
+                <!--             </td> -->
+                <!--             <td> -->
+                <!--                 <input type="checkbox" id="timer-checkbox" name="timer-checkbox"/> -->
+                <!--             </td> -->
+                <!--         </tr> -->
+                <!--     </table> -->
+                <!-- </div> -->
+            </div>
+
+            <div id="info-div">
+                <table style="width: 100%; padding: 1em;">
+                    <tr>
+                        <td style="text-align: left;">
+                            WiFi SSID
+                        </td>
+                        <td style="text-align: right;">
+                            <div id="wifi-ssid" style="font-family: monospace;"></div>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td style="text-align: left;">
+                            WiFi Strength
+                        </td>
+                        <td style="text-align: right;">
+                            <div id="wifi-rssi" style="font-family: monospace;"></div>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td style="text-align: left;">
+                            Local domain
+                        </td>
+                        <td style="text-align: right;">
+                            <a id="system-local-domain" style="font-family: monospace;"></a>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td style="text-align: left;">
+                            Local IP address
+                        </td>
+                        <td style="text-align: right;">
+                            <a id="system-ip-addr" style="font-family: monospace;"></a>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td style="text-align: left;">
+                            System time
+                        </td>
+                        <td style="text-align: right;">
+                            <div id="system-time" style="font-family: monospace;"></div>
+                        </td>
+                    </tr>
+                </table>
+            </div>
+        </div>
+    </body>
+</html>
diff --git a/data/main.js b/data/main.js
@@ -0,0 +1,82 @@
+var received_data;
+var time_div, main_output_checkbox, system_local_domain, system_ip_addr, system_time, wifi_ssid, wifi_rssi;
+var system_time_since_epoch;
+
+// Convert to GMT-3 using Intl.DateTimeFormat
+const time_formatter = new Intl.DateTimeFormat('en-GB', {
+    timeZone: 'America/Argentina/Buenos_Aires',
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit',
+    hour12: false
+});
+
+function rssi_to_percentage(rssi) {
+    var quality = 0; 
+
+    if (rssi <= -100) { 
+        quality = 0; 
+    } else if (rssi >= -50) { 
+        quality = 100; 
+    } else { 
+        quality = 2 * (rssi + 100); 
+    } 
+    return quality; 
+}
+
+function socket_onmessage_handler(event) {
+    // console.log(event.data);
+    received_data = JSON.parse(event.data);
+
+    main_output_checkbox.checked = received_data["main-output-enabled"] == "1" ? true : false;
+
+    system_local_domain.href = "http://" + received_data["system-local-domain"] + ".local";
+    system_local_domain.innerHTML = "http://" + received_data["system-local-domain"] + ".local";
+
+    system_ip_addr.href = received_data["system-ip-addr"];
+    system_ip_addr.innerHTML = received_data["system-ip-addr"];
+
+    system_time_since_epoch = received_data["system-time"];
+    const tmp_system_time = new Date(system_time_since_epoch*1000);
+    const formatted_date = time_formatter.format(tmp_system_time);
+    system_time.innerHTML = formatted_date + " GMT-3";
+
+    wifi_ssid.innerHTML = received_data["wifi-ssid"];
+    const rssi = received_data["wifi-rssi"];
+    wifi_rssi.innerHTML = rssi + "dBm (" + rssi_to_percentage(rssi) + "%)";
+}
+
+function socket_onopen_handler(event) {
+    console.log("[SOCKET] Connection established")
+    socket.send("main-output-status");
+}
+
+function handle_click(cb) {
+    if(cb == 'main-toggle') {
+        socket.send("main-output-toggle");
+    } else {
+        console.log("not implemented");
+    }
+}
+
+function update_system_time() {
+    socket.send("main-output-status");
+}
+
+function query_data() {
+    main_output_checkbox = document.getElementById("main-output-checkbox");
+    system_local_domain = document.getElementById("system-local-domain");
+    system_ip_addr = document.getElementById("system-ip-addr");
+    system_time = document.getElementById("system-time");
+    wifi_ssid = document.getElementById("wifi-ssid");
+    wifi_rssi = document.getElementById("wifi-rssi");
+
+    setInterval(update_system_time, 30*1000); // update time every 30 secs
+}
+
+const socket = new WebSocket("ws:/" + "/" + location.host + ":81");
+socket.onmessage = socket_onmessage_handler;
+socket.onopen = socket_onopen_handler;
+window.onload = query_data;
diff --git a/data/style.css b/data/style.css
@@ -0,0 +1,95 @@
+body {
+    color: black;
+    background-color: white;
+    margin: 3em auto 3em auto;
+    text-align: center;
+    font-family: "Nimbus Sans", "Nimbus Sans L", "Helvetica", Inter;
+    font-size: 18px;
+}
+
+#title {
+    padding: 0em 0.5em 0em 0.5em;
+}
+
+#main-div {
+    max-width: 25em;
+    min-height: 85vh;
+    margin: auto auto;
+    border-radius: 10px;
+}
+
+#main-toggle-div {
+    cursor: pointer;
+    user-select: none;
+    /* padding: 1em 0em 1em 0em; */
+    padding: 1.20em 1.70em 1em 1.70em;
+}
+
+#buttons-div {
+    text-align: left;
+    margin-left: auto;
+    margin-right: auto;
+    border-radius: 14px;
+    border: 1px solid #BCCCDC;
+}
+
+#label-box {
+    width: 100%;
+    font-size: 20px;
+}
+
+#main-output-checkbox, #timer-checkbox {
+    width: 20px;
+    height: 20px;
+    cursor: pointer;
+    margin: 0;
+    padding: 0;
+}
+
+#timer-div {
+    padding-top: 0.5em;
+    padding: 0.5em 1.70em 1em 1.70em;
+    cursor: pointer;
+    user-select: none;
+}
+
+#info-div {
+    padding-top: .5em;
+    font-size: 16px;
+}
+
+a, a:visited {
+    color: #00E;
+}
+
+@media (prefers-color-scheme: dark) {
+    body {
+        color: #E2E2E2;
+        background-color: #202020;
+    }
+    a, a:visited {
+        color: #56C8FF;
+    }
+    #main-toggle-div {
+        border-color: #E2E2E2;
+    }
+    #main-output-checkbox, #timer-checkbox {
+        appearance: none;
+        background-color: #E2E2E2;
+        border-radius: 2px;
+        margin-bottom: -1px;
+        /* margin: 0; */
+        /* padding: 0; */
+        /* width: 20px; */
+        /* height: 20px; */
+    }
+
+    #main-output-checkbox:checked, #timer-checkbox:checked {
+        appearance: revert;
+        accent-color: #56C8FF;
+        /* margin-bottom: 1px; */
+        /* padding: 0; */
+        /* width: 20px; */
+        /* height: 20px; */
+    }
+}
diff --git a/platformio.ini b/platformio.ini
@@ -0,0 +1,23 @@
+; PlatformIO Project Configuration File
+;
+;   Build options: build flags, source filter
+;   Upload options: custom upload port, speed and extra flags
+;   Library options: dependencies, extra library storages
+;   Advanced options: extra scripting
+;
+; Please visit documentation for the other options and examples
+; https://docs.platformio.org/page/projectconf.html
+
+[env]
+lib_deps =
+    links2004/WebSockets
+    arduino-libraries/Arduino_JSON
+    arduino-libraries/NTPClient
+
+[env:nodemcuv2]
+platform = espressif8266
+board = nodemcuv2
+framework = arduino
+build_flags = -Wno-switch
+upload_speed = 921600
+board_build.filesystem = littlefs
diff --git a/src/main.cpp b/src/main.cpp
@@ -0,0 +1,268 @@
+#include <ESP8266WiFi.h>
+#include <ESP8266WebServer.h>
+#include <WebSocketsServer.h>
+#include <ESP8266mDNS.h>
+#include <LittleFS.h>
+#include <Arduino_JSON.h>
+#include <NTPClient.h>
+#include <WiFiUdp.h>
+
+uint8_t remote_devices = 0, main_output_enabled = 0;
+uint32_t t = 0, dt = 0, main_output_dt = 0;
+
+ESP8266WebServer server(80);
+WebSocketsServer web_socket = WebSocketsServer(81);
+JSONVar data, system_time_data;
+WiFiUDP ntpUDP;
+
+NTPClient system_time(ntpUDP, "pool.ntp.org", 0, 3600); // UTC Time
+
+#define REMOTE_LED_PIN LED_BUILTIN
+#define MAIN_OUTPUT_PIN 5
+#define MAIN_SWITCH_INPUT_PIN 4
+#define SETUP_COMPLETE_OUTPUT_PIN 14
+
+#define WIFI_SSID "hello-world"
+#define WIFI_PASSWD "12345678"
+#define MDNS_DOMAIN "esp8266"
+
+#define MAIN_SWITCH_REPEAT_TIME 200
+
+void webp_socket_event(uint8_t, WStype_t, uint8_t *, size_t);
+void webp_handler();
+
+void main_output_toggle();
+void update_all_socket_clients();
+
+void setup_wifi();
+void setup_websocket();
+void setup_webserver();
+void setup_mdns();
+void setup_fs();
+
+void setup() {
+    pinMode(REMOTE_LED_PIN, OUTPUT);
+    pinMode(MAIN_OUTPUT_PIN, OUTPUT);
+    pinMode(MAIN_SWITCH_INPUT_PIN, INPUT);
+    pinMode(SETUP_COMPLETE_OUTPUT_PIN, OUTPUT);
+
+    digitalWrite(REMOTE_LED_PIN, HIGH); // led builtin uses inverted logic
+    digitalWrite(MAIN_OUTPUT_PIN, LOW);
+    digitalWrite(SETUP_COMPLETE_OUTPUT_PIN, LOW);
+
+    // Serial.begin(921600);
+    Serial.begin(115200);
+    Serial.setDebugOutput(false);
+    Serial.printf("\n\n\n");
+    delay(1000);
+
+    Serial.printf("[SETUP] Booting");
+    for(uint8_t t = 20; t > 0; t--) {
+        Serial.print(".");
+        Serial.flush();
+        delay(150);
+    }
+    Serial.print("\n");
+
+    setup_wifi();
+    setup_fs();
+    setup_websocket();
+    setup_mdns();
+    setup_webserver();
+    system_time.begin();
+    digitalWrite(SETUP_COMPLETE_OUTPUT_PIN, HIGH);
+}
+
+void loop() {
+    t = millis();
+
+    if(digitalRead(MAIN_SWITCH_INPUT_PIN)) {
+        if((t - dt) > MAIN_SWITCH_REPEAT_TIME) {
+            dt = millis();
+            main_output_toggle();
+            update_all_socket_clients();
+        }
+    }
+
+    // if((t - dt) > MAIN_OUTPUT_PERIOD) {
+    //     dt = millis();
+    //     digitalWrite(MAIN_OUTPUT_PIN, HIGH);
+    //     main_output_dt = millis();
+    // }
+
+    // if((t - main_output_dt) > MAIN_OUTPUT_ON_TIME) {
+    //     digitalWrite(MAIN_OUTPUT_PIN, LOW);
+    // }
+
+    MDNS.update();
+    web_socket.loop();
+    server.handleClient();
+    system_time.update();
+}
+
+void setup_wifi() {
+    WiFi.begin(WIFI_SSID, WIFI_PASSWD);
+
+    Serial.printf("[SETUP] Connecting to WiFi");
+    while(WiFi.status() != WL_CONNECTED) {
+        delay(200);
+        Serial.print(".");
+    }
+    Serial.printf("\n[SETUP] Connected to '%s' IP address ", WIFI_SSID);
+    Serial.println(WiFi.localIP());
+}
+
+void setup_mdns() {
+    if (!MDNS.begin(MDNS_DOMAIN)) {
+        Serial.println("[SETUP] Error setting up MDNS responder!");
+        while(1) { delay(100); }
+    }
+
+    MDNS.addService("http", "tcp", 80);
+    Serial.printf("[SETUP] mDNS started domain '%s.local'\n", MDNS_DOMAIN);
+}
+
+void main_output_toggle() {
+    if(main_output_enabled) {
+        // GPOC = (1 << MAIN_OUTPUT_PIN); // low
+        digitalWrite(MAIN_OUTPUT_PIN, LOW);
+    } else {
+        // GPOS = (1 << MAIN_OUTPUT_PIN);  // high
+        digitalWrite(MAIN_OUTPUT_PIN, HIGH);
+    }
+    main_output_enabled = !main_output_enabled;
+}
+
+void query_system_time() {
+    system_time_data["system-time"] = system_time.getEpochTime();
+}
+
+void update_data() {
+    data["main-output-enabled"] = String(main_output_enabled);
+    data["system-local-domain"] = String(MDNS_DOMAIN);
+    data["system-ip-addr"] = WiFi.localIP().toString();
+    data["system-time"] = system_time.getEpochTime();
+    data["wifi-ssid"] = WIFI_SSID;
+    data["wifi-rssi"] = WiFi.RSSI();
+}
+
+void update_all_socket_clients() {
+    update_data();
+    String data_as_json = JSON.stringify(data);
+    web_socket.broadcastTXT(data_as_json);
+}
+
+void webp_socket_event(uint8_t num, WStype_t type, uint8_t *payload, size_t len) {
+    switch(type) {
+    case WStype_DISCONNECTED:
+        Serial.printf("[SOCKET] %d: Disconnected\n", num);
+        remote_devices -= 1;
+        digitalWrite(REMOTE_LED_PIN, remote_devices ? LOW : HIGH);
+        break;
+    case WStype_CONNECTED: {
+            IPAddress ip = web_socket.remoteIP(num);
+            Serial.printf("[SOCKET] %u: Connected from %d.%d.%d.%d URL %s\n",
+                    num, ip[0], ip[1], ip[2], ip[3], payload);
+            remote_devices += 1;
+            digitalWrite(REMOTE_LED_PIN, remote_devices ? LOW : HIGH);
+        }
+        break;
+    case WStype_TEXT: {
+            // Serial.printf("[SOCKET](%u) got text: %s\n", num, payload);
+
+            if(!strcmp((char *)payload, "main-output-toggle")) {
+                main_output_toggle();
+                update_all_socket_clients();
+            }
+
+            if(!strcmp((char *)payload, "main-output-status")) {
+                update_data();
+                String data_as_json = JSON.stringify(data);
+                web_socket.sendTXT(num, data_as_json);
+            }
+
+            if(!strcmp((char *)payload, "system-time")) {
+                String system_time_data_as_json = JSON.stringify(system_time_data);
+                web_socket.sendTXT(num, system_time_data_as_json);
+            }
+        }
+        break;
+    case WStype_BIN:
+        Serial.printf("[SOCKET][%u] get binary length: %u\n", num, len);
+        hexdump(payload, len);
+        // web_socket.sendBIN(num, payload, len);
+        break;
+    }
+}
+
+void setup_websocket() {
+    web_socket.begin();
+    web_socket.onEvent(webp_socket_event);
+}
+
+String webserver_get_file(String path) {
+    String return_page;
+
+    if(LittleFS.exists(path)) {
+        Serial.printf("[SERVER] Serving file '%s'\n", path.c_str());
+        File file = LittleFS.open(path.c_str(), "r");
+        while(file.available()) {
+            return_page += (char)file.read();
+        }
+        file.close();
+    } else {
+        Serial.printf("[SERVER] '%s' File Not Found\n", path.c_str());
+        return_page = R"==(<!DOCTYPE html>
+        <html>
+          <head>
+              <title>ERROR 404: File Not found!!</title>
+              <meta name="viewport" content="width=device-width, initial-scale=1.0">
+          </head>
+          <body>
+              <h>ERROR 404: File Not Found!</h1>
+              <p>file '/index.html' not found</p>
+          </body>
+        </html>)==";
+    }
+    return return_page;
+}
+
+String webserver_file_content_type(String path) {
+  if (path.endsWith(".html")) return "text/html";
+  else if (path.endsWith(".css")) return "text/css";
+  else if (path.endsWith(".js")) return "application/javascript";
+  else if (path.endsWith(".ico")) return "image/x-icon";
+  else if (path.endsWith(".gz")) return "application/x-gzip";
+  return "text/plain";
+}
+
+void webserver_file_handler() {
+    String path = server.uri();
+    server.send(200, webserver_file_content_type(path), webserver_get_file(path));
+}
+
+void webserver_handle_root() {
+    String webp_index = webserver_get_file("index.html");
+    server.send(200, "text/html", webp_index.c_str());
+}
+
+void setup_webserver() {
+    Serial.println("[SETUP] loading server response from file 'index.html'");
+
+    server.on("/", webserver_handle_root);
+    server.onNotFound(webserver_file_handler);
+
+    server.begin();
+}
+
+void setup_fs() {
+    if(LittleFS.begin() == 0) {
+        Serial.println("[SETUP] Error couldn't begin filesystem!");
+    }
+
+    FSInfo fs_info;
+    LittleFS.info(fs_info);
+
+    uint8_t percentage_usad = (fs_info.usedBytes/fs_info.totalBytes)*100;
+    Serial.printf("[SETUP] LittleFS started: spaced used %d%%\n", percentage_usad);
+}