diff --git a/.github/workflows/esp32c3-arduino.yaml b/.github/workflows/esp32c3-arduino.yaml
new file mode 100644
index 0000000..8f89585
--- /dev/null
+++ b/.github/workflows/esp32c3-arduino.yaml
@@ -0,0 +1,70 @@
+---
+name: esp32c3-arduino
+
+on:
+ push:
+ tags:
+ - 'v*.*.*'
+
+jobs:
+
+ build:
+ permissions: write-all
+
+ strategy:
+ matrix:
+ arduino-platform: ["esp32:esp32"]
+ include:
+ - arduino-platform: "esp32:esp32"
+ fqbn: "esp32:esp32:esp32c3"
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+ with:
+ submodules: recursive
+
+ - name: Setup Arduino CLI
+ uses: arduino/setup-arduino-cli@v1
+
+ - name: Install platform
+ run: >
+
+ arduino-cli config set network.connection_timeout 600s
+ arduino-cli core install
+ ${{ matrix.arduino-platform }}
+
+ - name: Install time lib
+ run: arduino-cli lib install time wifimanager ezTime
+
+ - name: Make timezones
+ run: python firmware/cities.py > firmware/esp32c3-arduino/cities.h
+
+ - name: Compile Sketch
+ run: >
+ arduino-cli compile --fqbn ${{ matrix.fqbn }} -e
+ firmware/esp32c3-arduino
+
+ - name: Create Release
+ id: create_release
+ uses: actions/create-release@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ tag_name: ${{ github.ref }}
+ release_name: Release ${{ github.ref }}
+ draft: false
+ prerelease: false
+
+ - name: Upload Release Asset
+ id: upload-release-asset
+ uses: actions/upload-release-asset@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ upload_url: ${{ steps.create_release.outputs.upload_url }}
+ asset_path: ./firmware/esp32c3-arduino/build/esp32.esp32.esp32c3/esp32c3-arduino.ino.bin
+ asset_name: nixiesp12-esp32c3-arduino.bin
+ asset_content_type: application/bin
diff --git a/.github/workflows/arduino.yaml b/.github/workflows/esp8266-arduino.yaml
similarity index 84%
rename from .github/workflows/arduino.yaml
rename to .github/workflows/esp8266-arduino.yaml
index a4ebb00..0c8a682 100755
--- a/.github/workflows/arduino.yaml
+++ b/.github/workflows/esp8266-arduino.yaml
@@ -1,5 +1,5 @@
---
-name: arduino
+name: esp8266-arduino
on:
push:
@@ -39,12 +39,12 @@ jobs:
run: arduino-cli lib install time wifimanager ezTime
- name: Make timezones
- run: python firmware/cities.py > firmware/cities.h
+ run: python firmware/cities.py > firmware/esp8266-arduino/cities.h
- name: Compile Sketch
run: >
arduino-cli compile --fqbn ${{ matrix.fqbn }} -e
- firmware
+ firmware/esp8266
- name: Create Release
id: create_release
@@ -64,6 +64,6 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
- asset_path: ./firmware/build/esp8266.esp8266.generic/firmware.ino.bin
- asset_name: nixiesp12.bin
+ asset_path: ./firmware/esp8266-arduino/build/esp8266.esp8266.generic/esp32c3-arduino.ino.bin
+ asset_name: nixiesp12-esp8266-arduino.bin
asset_content_type: application/bin
diff --git a/.gitignore b/.gitignore
index 3510d95..003ecd7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+bin
_autosave*
*.bak
export
diff --git a/firmware/esp32c3-arduino/.gitignore b/firmware/esp32c3-arduino/.gitignore
new file mode 100644
index 0000000..798d116
--- /dev/null
+++ b/firmware/esp32c3-arduino/.gitignore
@@ -0,0 +1 @@
+cities.h
diff --git a/firmware/esp32c3-arduino/Makefile b/firmware/esp32c3-arduino/Makefile
new file mode 100644
index 0000000..de066d1
--- /dev/null
+++ b/firmware/esp32c3-arduino/Makefile
@@ -0,0 +1,34 @@
+SKETCH_FOLDER := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
+#UPLOAD_PORT ?= /dev/ttyACM0
+UPLOAD_PORT ?= /dev/ttyUSB0
+
+all: $(SKETCH_FOLDER)/build/esp32c3-arduino.ino.bin
+
+$(SKETCH_FOLDER)/cities.h: ../cities.py
+ python3 ../cities.py > $(SKETCH_FOLDER)/cities.h
+
+$(SKETCH_FOLDER)/build/esp32c3-arduino.ino.bin: $(SKETCH_FOLDER)/esp32c3-arduino.ino $(SKETCH_FOLDER)/cities.h
+ arduino-cli compile -e -b esp32:esp32:esp32c3 $(SKETCH_FOLDER)
+
+deps:
+ arduino-cli config set network.connection_timeout 600s
+ arduino-cli core install esp32:esp32
+ arduino-cli lib install wifimanager
+
+erase:
+ esptool.py --chip esp32c3 --port $(UPLOAD_PORT) erase_flash
+
+flash-dev:
+# arduino-cli upload -b esp32:esp32:esp32c3:CDCOnBoot=cdc -p $(UPLOAD_PORT) $(SKETCH_FOLDER)
+ arduino-cli compile --fqbn esp32:esp32:esp32c3:CDCOnBoot=cdc $(SKETCH_FOLDER) --upload -p /dev/ttyACM0
+# arduino-cli compile --fqbn esp32:esp32:esp32c3 $(SKETCH_FOLDER) --upload -p $(UPLOAD_PORT) --build-property build.extra_flags=-Os
+
+flash:
+ arduino-cli compile --fqbn esp32:esp32:esp32c3 $(SKETCH_FOLDER) --upload -p $(UPLOAD_PORT)
+
+
+console-dev:
+ picocom -b 115200 /dev/ttyACM0
+
+console:
+ picocom -b 115200 $(UPLOAD_PORT)
diff --git a/firmware/esp32c3-arduino/README.md b/firmware/esp32c3-arduino/README.md
new file mode 100644
index 0000000..6601c71
--- /dev/null
+++ b/firmware/esp32c3-arduino/README.md
@@ -0,0 +1,56 @@
+# Arduino variant for ESP32C3
+
+This firmware is designed to run on the [LilyGO T-01C3](https://lilygo.cc/products/t-01c3?srsltid=AfmBOopddAvO0gTIFNYZyTcoF1PxQjxZ0YAxuhoQviz9i5j4B0WOdg4l) board and serves as a drop-in replacement for the original ESP8266-based Nixie clock firmware.
+
+**Compared to the ESP8266, the ESP32C3 provides much better clock accuracy and reliability, thanks to its advanced hardware and improved timekeeping features.**
+If you received a Nixie clock from me, you are eligible for a free hardware upgrade - just contact me!
+
+This firmware features a high refresh rate display with advanced dimming and blending effects, leveraging the ESP32's RMT (Remote Control) peripheral for precise timing and smooth transitions.
+
+## Highlights
+
+- **High Refresh Rate:** Achieves smooth digit transitions and flicker-free display using RMT hardware
+- **Dimming & Blending:** Configurable dimming and blending for night mode and digit transitions
+- **WiFiManager Configuration:** Easily configure all clock options via a web portal
+- **Prometheus Metrics:** Exposes runtime and system metrics for monitoring
+
+## Configuration Options via WiFiManager
+
+- **NTP Time Server:** Set the network time server for clock synchronization
+- **City:** Select your city for timezone presets
+- **Timezone:** Manually specify the POSIX timezone string
+- **Display Mode:** Choose between time, date, or alternating time/date display
+- **Dimming Duty Cycle:** Adjust brightness for night mode
+- **Dimming Start/End:** Set hours for automatic dimming
+- **Blending Duration:** Control the duration of digit blending transitions
+- **Hour Format:** Select 12-hour or 24-hour display
+
+## Prometheus Metrics
+
+The firmware exposes the following metrics for monitoring:
+
+- `nixie_boot_timestamp_seconds`: Boot time of the device
+- `nixie_last_ntp_sync_timestamp_seconds`: Last successful NTP synchronization
+- `nixie_littlefs_total_bytes`: Total LittleFS storage size
+- `nixie_littlefs_used_bytes`: Used LittleFS storage
+- `nixie_uptime_seconds`: Device uptime
+- `nixie_display_task_iterations_total`: Number of display task iterations
+- `nixie_ntp_sync_count_total`: Number of NTP sync events
+- `nixie_wifi_manager_process_count_total`: Number of WiFiManager process events
+
+## Advanced Features
+
+- **Automatic NTP Sync:** Periodically synchronizes time with the configured NTP server
+- **Stale NTP Detection:** Blinks all digits and colons if NTP sync is stale
+- **Critical Section Display Updates:** Uses FreeRTOS and portMUX for safe, high-speed display updates
+
+## Getting Started
+
+1. Flash the firmware to your ESP32-C3
+2. Connect to the WiFiManager AP (SSID is based on device MAC)
+3. Configure your clock settings via the web portal
+4. Monitor metrics via Prometheus-compatible tools
+
+---
+
+For more details, see the source code and comments.
diff --git a/firmware/esp32c3-arduino/cities.py b/firmware/esp32c3-arduino/cities.py
new file mode 100644
index 0000000..3ff5413
--- /dev/null
+++ b/firmware/esp32c3-arduino/cities.py
@@ -0,0 +1,42 @@
+import requests
+import csv
+
+# Looks like ESP-IDF pulls in whole POSIX stack
+# Some forums mention ESP-IDF uses this CSV
+
+coords = {}
+
+r = requests.get("https://gist.githubusercontent.com/erdem/8c7d26765831d0f9a8c62f02782ae00d/raw/248037cd701af0a4957cce340dabb0fd04e38f4c/countries.json")
+data = r.json()
+for j in data:
+ for tz in j["timezones"]:
+ coords[tz] = j["latlng"]
+
+
+r = requests.get("https://raw.githubusercontent.com/nayarsystems/posix_tz_db/master/zones.csv")
+cr = csv.reader(r.text.splitlines(), delimiter=',', quotechar='"')
+
+print("""const char cities[] = R"(
+
+
+
+)";
+""")
diff --git a/firmware/esp32c3-arduino/esp32c3-arduino.ino b/firmware/esp32c3-arduino/esp32c3-arduino.ino
new file mode 100644
index 0000000..b37ca3f
--- /dev/null
+++ b/firmware/esp32c3-arduino/esp32c3-arduino.ino
@@ -0,0 +1,801 @@
+#include
+#include
+#include
+#include "esp_sntp.h"
+#include
+#include
+#include
+#include "cities.h"
+#include
+
+// --- Configuration ---
+#define CONFIG_CPU_FREQUENCY 80000000
+#define CONFIG_RMT_DIVISOR 50
+#define CONFIG_REFRESH_RATE 50
+#define REFRESH_CYCLE CONFIG_CPU_FREQUENCY / CONFIG_RMT_DIVISOR / CONFIG_REFRESH_RATE - 100
+
+static_assert(REFRESH_CYCLE > 0, "REFRESH_CYCLE must be > 0");
+static_assert(REFRESH_CYCLE < 32768, "REFRESH_CYCLE must be < 32768");
+
+
+// --- Pin Definitions ---
+#define PIN_CLOCK 20
+#define PIN_LATCH 9
+#define PIN_DATA 2
+#define PIN_UNUSED 11
+
+// --- Colon and Symbol Definitions ---
+#define COLON_LEFT_BOTTOM (1ULL << (17 + 16))
+#define COLON_LEFT_TOP (1ULL << (18 + 16))
+#define COLON_RIGHT_BOTTOM (1ULL << 17)
+#define COLON_RIGHT_TOP (1ULL << 18)
+
+#define COLON_LEFT_BOTH (COLON_LEFT_TOP | COLON_LEFT_BOTTOM)
+#define COLON_RIGHT_BOTH (COLON_RIGHT_TOP | COLON_RIGHT_BOTTOM)
+#define COLON_BOTTOM_BOTH (COLON_LEFT_BOTTOM | COLON_RIGHT_BOTTOM)
+#define COLON_TOP_BOTH (COLON_LEFT_TOP | COLON_RIGHT_TOP)
+#define COLON_ALL (COLON_LEFT_BOTH | COLON_RIGHT_BOTH)
+
+#define IN15A_MICRO 0
+#define IN15A_PERCENT 2
+#define IN15A_PETA 3
+#define IN15A_KILO 4
+#define IN15A_MEGA 5
+#define IN15A_MILLI 6
+#define IN15A_PLUS 7
+#define IN15A_MINUS 8
+#define IN15A_PICO 9
+
+
+#define NTP_SYNC_TIMEOUT_SECONDS 8*3600
+
+int configDimmingDutyCycle = 0;
+int configDimmingStart = 0;
+int configDimmingEnd = 0;
+int configHourFormat = 12;
+
+int current_dimming_duty_cycle;
+enum typeOperationMode {
+ OPERATION_MODE_NORMAL,
+ OPERATION_MODE_DIMMED,
+} operationModeCurrent = OPERATION_MODE_NORMAL;
+
+#define DISPLAY_MODE_TIME 1
+#define DISPLAY_MODE_DATE 2
+#define DISPLAY_MODE_DATETIME 3
+
+int configDisplayModesEnabled = 1;
+int displayModeCurrent = 1;
+
+WiFiManager wm;
+
+const char displayModesCombobox[] = R"(
+
+
+
+)";
+
+
+WiFiManagerParameter paramNetworkTimeServer("networkTimeServer", "Network time server", "ee.pool.ntp.org", 63);
+WiFiManagerParameter paramDisplayMode("displayMode", "Will be hidden", "1", 2);
+WiFiManagerParameter paramDisplayModeCombobox(displayModesCombobox);
+WiFiManagerParameter paramCity(cities);
+WiFiManagerParameter paramTimezone("timezone", "Timezone encoding", "EET-2EEST,M3.5.0/3,M10.5.0/4", 30);
+#define CONFIG_TIMEZONE "EET-2EEST,M3.5.0/3,M10.5.0/4" // Estonia, adjust as needed
+
+const char dimmerSliderSnippet[] = R"(
+
+
+
+)";
+WiFiManagerParameter paramDimmingDutyCycle("dimming_duty_cycle", "", "1000", 6);
+WiFiManagerParameter paramDimmingDutyCycleSlider(dimmerSliderSnippet);
+
+time_t bootTimestamp = 0;
+time_t lastNtpSyncTimestamp = 0;
+
+
+// Dimmer settings
+volatile long displayInterruptCount = 0;
+
+const char blendingSliderSnippet[] = R"(
+
+
+
+)";
+WiFiManagerParameter paramBlendingDuration("blending_duration", "", "200", 4);
+WiFiManagerParameter paramBlendingDurationSlider(blendingSliderSnippet);
+
+
+const char dimmingStartCombobox[] = R"(
+
+
+
+
+)";
+
+const char dimmingEndCombobox[] = R"(
+
+
+
+
+)";
+
+const char hourFormatCombobox[] = R"(
+
+
+
+
+)";
+
+WiFiManagerParameter paramHourFormat("hour_format", "", "12", 3);
+WiFiManagerParameter paramHourFormatCombobox(hourFormatCombobox);
+WiFiManagerParameter paramDimmingStart("dimming_start", "", "22", 3);
+WiFiManagerParameter paramDimmingStartCombobox(dimmingStartCombobox);
+WiFiManagerParameter paramDimmingEnd("dimming_end", "", "6", 3);
+WiFiManagerParameter paramDimmingEndCombobox(dimmingEndCombobox);
+
+// --- NTP Sync Counter ---
+volatile unsigned long displayTaskIterationsCount = 0;
+volatile unsigned long configBlendingDuration = 0;
+volatile unsigned long ntpSyncCount = 0;
+volatile unsigned long wifiManagerProcessCount = 0;
+
+void IRAM_ATTR timeavailable(struct timeval *t) {
+ ntpSyncCount++;
+ if (bootTimestamp == 0 && t) {
+ bootTimestamp = t->tv_sec;
+ }
+ if (t) {
+ lastNtpSyncTimestamp = t->tv_sec;
+ }
+}
+
+#define RMT_TX_GPIO 9
+
+
+// --- State ---
+uint64_t STATE_PREVIOUS = 0x787878787878ULL;
+int STATE_REFRESH_MODE = 0;
+
+// --- Helper Functions ---
+void spi_write_bytes(const uint8_t* data, size_t len) {
+ for (size_t i = 0; i < len; i++) {
+ uint8_t b = data[i];
+ for (int bit = 7; bit >= 0; bit--) {
+ digitalWrite(PIN_CLOCK, LOW);
+ digitalWrite(PIN_DATA, (b >> bit) & 1);
+ digitalWrite(PIN_CLOCK, HIGH);
+ }
+ }
+}
+
+int clamp(int v, int lower = -99, int upper = 99) {
+ if (v > upper) return upper;
+ if (v < lower) return lower;
+ return v;
+}
+
+uint64_t render_digit(int j, int position = 0) {
+ static const int table[11] = {11, 9, 12, 8, 0, 4, 1, 3, 2, 10, 15};
+ if (j < -1 || j > 9) j = 10;
+ if (j == -1) j = 10;
+ return ((uint64_t)table[j] << 3) << (position * 8);
+}
+
+uint64_t render_digits(int d5, int d4, int d3, int d2, int d1, int d0) {
+ uint64_t z = 0;
+ int vals[6] = {d0, d1, d2, d3, d4, d5};
+ for (int position = 0; position < 6; position++) {
+ z |= render_digit(vals[position], position);
+ }
+ return z;
+}
+
+uint64_t render_time(bool colons = true) {
+ struct tm timeinfo;
+ time_t now = time(nullptr);
+ localtime_r(&now, &timeinfo);
+ int h = timeinfo.tm_hour % configHourFormat;
+ int m = timeinfo.tm_min;
+ int s = timeinfo.tm_sec;
+ return render_digits(h / 10, h % 10, m / 10, m % 10, s / 10, s % 10) | (colons ? COLON_ALL : 0);
+}
+
+uint64_t render_date(bool colons = true) {
+ struct tm timeinfo;
+ time_t now = time(nullptr);
+ localtime_r(&now, &timeinfo);
+ int y = timeinfo.tm_year % 100;
+ int m = timeinfo.tm_mon + 1;
+ int d = timeinfo.tm_mday;
+ return render_digits(y / 10, y % 10, m / 10, m % 10, d / 10, d % 10) | (colons ? COLON_BOTTOM_BOTH : 0);
+}
+
+uint64_t render_temperature(int t) {
+ int val = abs(clamp(t));
+ return render_digits(-1, t < 0 ? IN15A_MINUS : -1, val / 10, val % 10, -1, -1) | COLON_RIGHT_TOP;
+}
+
+void rmt_pulse(uint32_t duration) {
+ rmt_data_t items[3];
+ items[0].level0 = 1; items[0].duration0 = 1; // latch immediately
+ items[0].level1 = 0; items[0].duration1 = duration;
+ items[1].level0 = 1; items[1].duration0 = 1;
+ items[1].level1 = 0; items[1].duration1 = 1;
+ rmtWriteAsync(RMT_TX_GPIO, items, duration == 0 ? 1 : 2);
+}
+
+void display_static(uint64_t value) {
+ uint8_t bytes[6];
+ for (int i = 0; i < 6; i++) bytes[i] = (value >> (8 * (5 - i))) & 0xFF;
+ spi_write_bytes(bytes, 6);
+ rmt_data_t items[1];
+ items[0].level0 = 1; items[0].duration0 = 1;
+ items[0].level1 = 0; items[0].duration1 = 1;
+ rmtWrite(RMT_TX_GPIO, items, 1, RMT_WAIT_FOR_EVER);
+}
+
+void display_dimmed(uint64_t value, int duty) {
+ uint8_t bytes[6];
+ for (int i = 0; i < 6; i++) bytes[i] = (value >> (8 * (5 - i))) & 0xFF;
+ if (duty >= 32767) {
+ display_static(value);
+ } else {
+ spi_write_bytes(bytes, 6);
+ rmt_pulse(duty);
+ uint8_t blank[6] = {0x78, 0x78, 0x78, 0x78, 0x78, 0x78};
+ spi_write_bytes(blank, 6);
+ }
+}
+
+void display_blended(uint64_t value, uint64_t prev, float progression) {
+ int duty = 32767 * progression;
+ if (duty < 500) { duty = 500; }
+ uint8_t bytes[6], prev_bytes[6];
+ for (int i = 0; i < 6; i++) {
+ bytes[i] = (value >> (8 * (5 - i))) & 0xFF;
+ prev_bytes[i] = (prev >> (8 * (5 - i))) & 0xFF;
+ }
+ if (progression >= 1.0) {
+ display_static(value);
+ } else {
+ spi_write_bytes(bytes, 6);
+ rmt_pulse(duty);
+ spi_write_bytes(prev_bytes, 6);
+ }
+}
+
+bool isNtpStale() {
+ time_t now = time(nullptr);
+ return (lastNtpSyncTimestamp == 0) || (now - lastNtpSyncTimestamp > NTP_SYNC_TIMEOUT_SECONDS);
+}
+
+void handleMetrics(){
+ String buf = "";
+
+
+ buf += "nixie_sketch_size_bytes ";
+ buf += ESP.getSketchSize();
+ buf += "\n";
+
+ buf += "nixie_flash_space_bytes ";
+ buf += ESP.getFlashChipSize();
+ buf += "\n";
+
+ buf += "nixie_free_heap_bytes ";
+ buf += ESP.getFreeHeap();
+ buf += "\n";
+
+ buf += "nixie_min_free_heap_bytes ";
+ buf += ESP.getMinFreeHeap();
+ buf += "\n";
+
+ buf += "nixie_cpu_frequency_mhz ";
+ buf += getCpuFrequencyMhz();
+ buf += "\n";
+
+ buf += "nixie_uptime_seconds ";
+ buf += millis() / 1000;
+ buf += "\n";
+
+ buf += "nixie_task_count ";
+ buf += uxTaskGetNumberOfTasks();
+ buf += "\n";
+
+ buf += "nixie_wifi_rssi_dbm ";
+ buf += WiFi.RSSI();
+ buf += "\n";
+
+ buf += "nixie_wifi_channel ";
+ buf += WiFi.channel();
+ buf += "\n";
+
+ int wifiStatus = WiFi.status();
+ const char* wifiStatusStr = "UNKNOWN";
+ switch (wifiStatus) {
+ case WL_IDLE_STATUS: wifiStatusStr = "IDLE"; break;
+ case WL_NO_SSID_AVAIL: wifiStatusStr = "NO_SSID_AVAIL"; break;
+ case WL_SCAN_COMPLETED: wifiStatusStr = "SCAN_COMPLETED"; break;
+ case WL_CONNECTED: wifiStatusStr = "CONNECTED"; break;
+ case WL_CONNECT_FAILED: wifiStatusStr = "CONNECT_FAILED"; break;
+ case WL_CONNECTION_LOST: wifiStatusStr = "CONNECTION_LOST"; break;
+ case WL_DISCONNECTED: wifiStatusStr = "DISCONNECTED"; break;
+ }
+ buf += "nixie_wifi_status_info{status=\"";
+ buf += wifiStatusStr;
+ buf += "\"} ";
+ buf += wifiStatus;
+ buf += "\n";
+
+ buf += "nixie_wifi_info{ssid=\"";
+ buf += WiFi.SSID();
+ buf += "\",bssid=\"";
+ buf += WiFi.BSSIDstr();
+ buf += "\",ip=\"";
+ buf += WiFi.localIP().toString();
+ buf += "\",gateway=\"";
+ buf += WiFi.gatewayIP().toString();
+ buf += "\",dns=\"";
+ buf += WiFi.dnsIP().toString();
+ buf += "\"} 1\n";
+
+ buf += "nixie_ntp_sync_count ";
+ buf += ntpSyncCount;
+ buf += "\n";
+
+ buf += "nixie_wifi_manager_process_count ";
+ buf += wifiManagerProcessCount;
+ buf += "\n";
+
+ buf += "nixie_task_stack_high_water_mark ";
+ buf += uxTaskGetStackHighWaterMark(NULL);
+ buf += "\n";
+
+ buf += "nixie_display_task_iterations_count";
+ buf += displayTaskIterationsCount;
+ buf += "\n";
+
+ buf += "nixie_boot_timestamp_seconds ";
+ buf += bootTimestamp;
+ buf += "\n";
+
+ buf += "nixie_last_ntp_sync_timestamp_seconds ";
+ buf += lastNtpSyncTimestamp;
+ buf += "\n";
+
+ buf += "nixie_littlefs_total_bytes ";
+ buf += LittleFS.totalBytes();
+ buf += "\n";
+
+ buf += "nixie_littlefs_used_bytes ";
+ buf += LittleFS.usedBytes();
+ buf += "\n";
+
+ wm.server->send(200, "text/plain", buf);
+}
+
+unsigned char timeserver[63] = {'\0'};
+unsigned char timezone[30] = {'\0'};
+bool ntp_started = false;
+
+int loadClockConfig() {
+ File file = LittleFS.open("/timezone", "r");
+ if (!file) { return 1; }
+ if (!file.read(timezone, sizeof(timezone))) { return 2; }
+
+ file = LittleFS.open("/timeserver", "r");
+ if (!file) { return 3; }
+ if (!file.read(timeserver, sizeof(timeserver))) { return 4; }
+
+ if (!ntp_started) {
+ Serial.print("Using time server: ");
+ Serial.println((const char*)timeserver);
+ configTime(0, 0, (const char*)timeserver);
+ ntp_started = true;
+ } else {
+ Serial.print("NTP already started, reset to apply new time server");
+
+ }
+
+ Serial.print("Using timezone: ");
+ Serial.println((const char*)timezone);
+ setenv("TZ", (const char*)timezone, 1);
+ tzset();
+
+
+ unsigned char modes[1] = {'\0'};
+ file = LittleFS.open("/modes", "r");
+ if (!file) { return 5; }
+ if (!file.read(modes, sizeof(modes))) { return 6; }
+ displayModeCurrent = atoi((const char*)modes);
+ Serial.print("Enabled display modes:");
+ if (displayModeCurrent & DISPLAY_MODE_DATE) {
+ Serial.print(" DATE");
+ }
+ if (displayModeCurrent & DISPLAY_MODE_TIME) {
+ Serial.print(" TIME");
+ }
+ Serial.println();
+
+ unsigned char bufDimming[10] = {'\0'};
+ file = LittleFS.open("/dimming", "r");
+ if (!file) { return 7; }
+ if (!file.read(bufDimming, sizeof(bufDimming))) { return 8; }
+ configDimmingDutyCycle = atoi((const char*)bufDimming); // ensure integer
+ Serial.print("Night time dimming duty cycle: ");
+ Serial.println(configDimmingDutyCycle);
+
+ unsigned char bufBlending[10] = {'\0'};
+ file = LittleFS.open("/blending_duration", "r");
+ if (file && file.read(bufBlending, sizeof(bufBlending))) {
+ configBlendingDuration = atoi((const char*)bufBlending);
+ Serial.print("Blending duration (ms): ");
+ Serial.println(configBlendingDuration);
+ }
+
+ unsigned char bufDimmingStart[4] = {'\0'};
+ file = LittleFS.open("/dimming_start", "r");
+ if (file && file.read(bufDimmingStart, sizeof(bufDimmingStart))) {
+ configDimmingStart = atoi((const char*)bufDimmingStart);
+ Serial.print("Dimming start hour: ");
+ Serial.println(configDimmingStart);
+ }
+
+ unsigned char bufDimmingEnd[4] = {'\0'};
+ file = LittleFS.open("/dimming_end", "r");
+ if (file && file.read(bufDimmingEnd, sizeof(bufDimmingEnd))) {
+ configDimmingEnd = atoi((const char*)bufDimmingEnd);
+ Serial.print("Dimming end hour: ");
+ Serial.println(configDimmingEnd);
+ }
+
+ unsigned char bufHourFormat[4] = {'\0'};
+ file = LittleFS.open("/hour_format", "r");
+ if (file && file.read(bufHourFormat, sizeof(bufHourFormat))) {
+ configHourFormat = atoi((const char*)bufHourFormat); // 12 or 24
+ Serial.print("Hour format: ");
+ Serial.println(configHourFormat);
+ } else {
+ configHourFormat = 12; // default
+ }
+
+ return 0;
+}
+
+void saveParamsCallback() {
+ File file = LittleFS.open("/timezone", "w");
+ Serial.println("Saving timezone: " + String(paramTimezone.getValue()));
+ file.print(paramTimezone.getValue());
+ file.close();
+
+ file = LittleFS.open("/dimming", "w");
+ Serial.println("Saving dimming duty cycle: " + String(paramDimmingDutyCycle.getValue()));
+ file.print(paramDimmingDutyCycle.getValue()); // save as integer string
+ file.close();
+ configDimmingDutyCycle = atoi(paramDimmingDutyCycle.getValue()); // update runtime value
+
+ file = LittleFS.open("/modes", "w");
+ file.print(paramDisplayMode.getValue());
+ file.close();
+
+ file = LittleFS.open("/blending_duration", "w");
+ file.print(paramBlendingDuration.getValue());
+ file.close();
+ configBlendingDuration = atoi(paramBlendingDuration.getValue());
+
+ file = LittleFS.open("/dimming_start", "w");
+ file.print(paramDimmingStart.getValue());
+ file.close();
+ configDimmingStart = atoi(paramDimmingStart.getValue());
+
+ file = LittleFS.open("/dimming_end", "w");
+ file.print(paramDimmingEnd.getValue());
+ file.close();
+ configDimmingEnd = atoi(paramDimmingEnd.getValue());
+
+ file = LittleFS.open("/timeserver", "w");
+ Serial.println("Saving timeserver: " + String(paramNetworkTimeServer.getValue()));
+ file.print(paramNetworkTimeServer.getValue());
+ file.close();
+
+ file = LittleFS.open("/hour_format", "w");
+ Serial.println("Saving hour format: " + String(paramHourFormat.getValue()));
+ file.print(paramHourFormat.getValue());
+ file.close();
+ configHourFormat = atoi(paramHourFormat.getValue());
+
+ loadClockConfig();
+}
+
+
+
+portMUX_TYPE displayMux = portMUX_INITIALIZER_UNLOCKED;
+int calibration = 0;
+int ps = 0;
+
+void DisplayTask(void *pvParameters) {
+ const TickType_t interval = pdMS_TO_TICKS(1000 / CONFIG_REFRESH_RATE);
+ TickType_t xLastWakeTime = xTaskGetTickCount();
+ for (;;) {
+ struct tm timeinfo;
+ time_t now = time(nullptr);
+ localtime_r(&now, &timeinfo);
+
+ int h = timeinfo.tm_hour;
+ int m = timeinfo.tm_min;
+ int s = timeinfo.tm_sec;
+
+ int subsec = millis() % 1000;
+
+ // At second change calibrate CPU milliseconds offset
+ if (ps != 0 && s != ps) {
+ calibration = subsec;
+
+ // Print date, time, seconds since last NTP sync, display mode, rendering mode
+ struct tm timeinfo;
+ time_t now = time(nullptr);
+ localtime_r(&now, &timeinfo);
+
+ Serial.printf("%04d-%02d-%02d ", timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday);
+ Serial.printf("%02d:%02d:%02d ", timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec);
+
+ Serial.print("Seconds since last NTP sync: ");
+ Serial.print(now - lastNtpSyncTimestamp); Serial.print(" ");
+
+ Serial.print("Display mode: ");
+ bool showing_date = false;
+ if (displayModeCurrent == DISPLAY_MODE_DATE) {
+ Serial.print("DATE ");
+ showing_date = true;
+ } else if (displayModeCurrent == DISPLAY_MODE_DATETIME) {
+ if ((now % 20) >= 10) {
+ Serial.print("DATE ");
+ showing_date = true;
+ } else {
+ Serial.print("TIME ");
+ }
+ } else {
+ Serial.print("TIME ");
+ }
+
+ Serial.print("Rendering mode: ");
+ if (configDimmingDutyCycle < 32767 && (timeinfo.tm_hour >= configDimmingStart || timeinfo.tm_hour < configDimmingEnd)) {
+ Serial.print("DIMMING");
+ } else if (configBlendingDuration > 0) {
+ Serial.print("BLENDING");
+ } else {
+ Serial.print("STATIC");
+ }
+ Serial.println();
+ }
+ ps = s;
+
+ subsec = (1000 + subsec - calibration) % 1000;
+
+ uint64_t current = 0;
+ uint64_t prev = STATE_PREVIOUS;
+
+ // Select display mode
+ switch (displayModeCurrent) {
+ case DISPLAY_MODE_DATE:
+ current = render_date();
+ break;
+ case DISPLAY_MODE_DATETIME: {
+ // Alternate every 10 seconds between time and date
+ time_t now = time(nullptr);
+ if ((now % 20) < 10) {
+ current = render_time();
+ } else {
+ current = render_date();
+ }
+ break;
+ }
+ case DISPLAY_MODE_TIME:
+ default:
+ current = render_time();
+ break;
+ }
+
+ // If NTP is stale, blink all digits and colons
+ if (isNtpStale()) {
+ if (subsec >= 500) {
+ current = 0x787878787878ULL;
+ }
+ prev = current;
+ } else {
+ // Determine if date is currently shown
+ bool showing_date = false;
+ if (displayModeCurrent == DISPLAY_MODE_DATE) {
+ showing_date = true;
+ } else if (displayModeCurrent == DISPLAY_MODE_DATETIME) {
+ time_t now = time(nullptr);
+ if ((now % 20) >= 10) {
+ showing_date = true;
+ }
+ }
+
+ // Use ternary to select colon mask
+ uint64_t colon_mask = showing_date ? COLON_BOTTOM_BOTH : COLON_ALL;
+
+ // Show colons if date is shown OR subsec < 500
+ if (showing_date || subsec < 500) {
+ current |= colon_mask;
+ prev |= colon_mask;
+ } else {
+ current &= ~colon_mask;
+ prev &= ~colon_mask;
+ }
+ }
+
+ if (configDimmingDutyCycle < 32767 && (h >= configDimmingStart || h < configDimmingEnd)) {
+ if (STATE_REFRESH_MODE != 1) {
+ Serial.println("Switching to dimming mode");
+ STATE_REFRESH_MODE = 1;
+ }
+ portENTER_CRITICAL(&displayMux);
+ display_dimmed(current, configDimmingDutyCycle); // Use duty cycle as brightness
+ portEXIT_CRITICAL(&displayMux);
+ } else if (configBlendingDuration > 0) {
+ if (STATE_REFRESH_MODE != 2) {
+ Serial.println("Switching to blending mode");
+ STATE_REFRESH_MODE = 2;
+ }
+ if (subsec == 0) {
+
+ portENTER_CRITICAL(&displayMux);
+ display_static(prev);
+ portEXIT_CRITICAL(&displayMux);
+ }
+ else if (subsec <= configBlendingDuration) {
+ if (current != prev) {
+ portENTER_CRITICAL(&displayMux);
+ display_blended(current, prev, (float)subsec / configBlendingDuration);
+ portEXIT_CRITICAL(&displayMux);
+
+ }
+ } else {
+ portENTER_CRITICAL(&displayMux);
+ display_static(current);
+ portEXIT_CRITICAL(&displayMux);
+
+ STATE_PREVIOUS = current;
+
+ }
+ } else if (current != prev) {
+ portENTER_CRITICAL(&displayMux);
+ display_static(current);
+ portEXIT_CRITICAL(&displayMux);
+ }
+ displayTaskIterationsCount++;
+ vTaskDelayUntil(&xLastWakeTime, interval);
+ }
+}
+
+
+void setup() {
+ Serial.println("Initalizing clock, data pins");
+ pinMode(PIN_CLOCK, OUTPUT);
+ pinMode(PIN_DATA, OUTPUT);
+ if (!rmtInit(RMT_TX_GPIO, RMT_TX_MODE, RMT_MEM_NUM_BLOCKS_1, CONFIG_CPU_FREQUENCY / CONFIG_RMT_DIVISOR)) {
+ Serial.println("RMT initialization failed\n");
+ }
+ display_static(0x787878787878ULL);
+
+ Serial.begin(115200);
+ setCpuFrequencyMhz(CONFIG_CPU_FREQUENCY / 1000000);
+
+ // Setup WifiManager
+ uint8_t mac[6];
+ WiFi.macAddress(mac);
+ char hostname[20];
+ snprintf(hostname, sizeof(hostname), "Nixie%02X%02X%02X", mac[3], mac[4], mac[5]);
+ wm.setHostname(hostname);
+ wm.setTitle("Nixie");
+ wm.setShowInfoUpdate(false); // https://github.com/tzapu/WiFiManager/issues/1262
+ wm.setShowInfoErase(false);
+ wm.setConfigPortalBlocking(false);
+ wm.setMinimumSignalQuality(50);
+ wm.setShowInfoUpdate(false);
+ wm.addParameter(¶mNetworkTimeServer);
+ wm.addParameter(¶mCity);
+ wm.addParameter(¶mTimezone);
+ wm.addParameter(¶mDisplayMode);
+ wm.addParameter(¶mDisplayModeCombobox);
+ wm.addParameter(¶mDimmingDutyCycle);
+ wm.addParameter(¶mDimmingDutyCycleSlider);
+ wm.addParameter(¶mDimmingStart);
+ wm.addParameter(¶mDimmingStartCombobox);
+ wm.addParameter(¶mDimmingEnd);
+ wm.addParameter(¶mDimmingEndCombobox);
+ wm.addParameter(¶mBlendingDuration);
+ wm.addParameter(¶mBlendingDurationSlider);
+ wm.addParameter(¶mHourFormat);
+ wm.addParameter(¶mHourFormatCombobox);
+ wm.setSaveParamsCallback(saveParamsCallback);
+ wm.setConfigPortalTimeout(0);
+ wm.startConfigPortal(hostname); // Set AP SSID to hostname
+ wm.server->on("/metrics", handleMetrics);
+
+ Serial.println("Autostarting wireless");
+ if (!wm.autoConnect(hostname)) { // Set AP SSID to hostname
+ Serial.println("Failed to connect or start config portal");
+ } else {
+ Serial.println("WiFi connected or config portal completed");
+ WiFi.softAPdisconnect(true);
+ }
+
+
+ if (!LittleFS.begin(true)) {
+ Serial.println("LittleFS mount failed");
+ } else {
+ if (loadClockConfig() != 0) {
+ Serial.println("Failed to load clock configuration from LittleFS, restoring defaults");
+ saveParamsCallback();
+ if (loadClockConfig() != 0) {
+ Serial.println("Failed to reset LittleFS defaults");
+ }
+ } else {
+ Serial.println("Configuration loaded");
+ }
+ }
+
+ // Register SNTP time sync notification callback
+ sntp_set_time_sync_notification_cb(timeavailable);
+
+ // Start FreeRTOS display refresh task
+ Serial.println("Starting display refresh task");
+ xTaskCreatePinnedToCore(DisplayTask, "DisplayTask", 8192, NULL, 24, NULL, 0);
+}
+
+void loop() {
+ wm.process();
+ wifiManagerProcessCount++;
+ vTaskDelay(pdMS_TO_TICKS(10)); // Keep loop responsive, but display is handled by DisplayTask
+}
\ No newline at end of file
diff --git a/firmware/esp32c3-python/README.md b/firmware/esp32c3-python/README.md
new file mode 100644
index 0000000..caa7400
--- /dev/null
+++ b/firmware/esp32c3-python/README.md
@@ -0,0 +1,52 @@
+# MicroPython variant for ESP32C3
+
+This firmware is designed to run on ESP32-C3 boards using MicroPython. It drives a Nixie tube clock with high refresh rate, advanced dimming, and blending effects, leveraging the ESP32's RMT peripheral for precise timing
+
+## Highlights
+
+- **High Refresh Rate:** Smooth, flicker-free display using RMT hardware
+- **Dimming & Blending:** Configurable night mode brightness and digit blending transitions
+- **Configurable via `config.py`:** All major settings can be overridden by uploading a `config.py` file
+- **NTP Synchronization:** Periodically syncs time with NTP servers
+- **Timezone & DST Support:** Adjustable timezone and daylight saving settings
+
+## Configuration Options
+
+You can override any of these defaults by uploading a `config.py` file:
+
+- `CONFIG_TIMEZONE`: Timezone offset from UTC (default: 2)
+- `CONFIG_DAYLIGHT_SAVING_ENABLED`: Enable/disable DST (default: True)
+- `CONFIG_NTP_SYNC_INTERVAL_HOURS`: NTP sync interval (default: 24)
+- `CONFIG_DIMMING_ENABLED`: Enable night dimming (default: True)
+- `CONFIG_DIMMING_BRIGHTNESS`: Night brightness (default: 0.4)
+- `CONFIG_DIMMING_START`: Dimming start hour (default: 15)
+- `CONFIG_DIMMING_END`: Dimming end hour (default: 6)
+- `CONFIG_DIMMING_GAMMA`: Dimming gamma correction (default: 2.2)
+- `CONFIG_BLENDING_ENABLED`: Enable digit blending (default: True)
+- `CONFIG_BLENDING_DURATION`: Blending duration in ms (default: 150)
+- `CONFIG_NETWORKS`: Dictionary of SSIDs and passwords for auto-connect
+
+## Features
+
+- **WiFi Auto-Connect:** Scans and connects to known networks
+- **NTP Time Sync:** Sets system time via NTP after WiFi connection
+- **Display Modes:** Time, date, and temperature rendering functions
+- **RMT-Based Display:** Uses ESP32 RMT for precise display timing
+- **Night Mode:** Automatic dimming based on configured hours
+- **Blending:** Smooth digit transitions for improved aesthetics
+
+## Usage
+
+1. Flash MicroPython to your ESP32-C3 board
+2. Upload `main.py` and (optionally) `config.py` to the device
+3. Reboot the board. The clock will auto-connect to WiFi, sync time, and start displaying
+
+## Notes
+
+- The firmware is intended for advanced users familiar with MicroPython and ESP32 hardware
+- For custom settings, create and upload a `config.py` file with your overrides
+- RMT and SPI pin assignments are set for typical Nixie clock hardware; adjust as needed for your build
+
+---
+
+For more details, see the source code and
diff --git a/firmware/esp32c3-python/main.py b/firmware/esp32c3-python/main.py
new file mode 100644
index 0000000..8feb70c
--- /dev/null
+++ b/firmware/esp32c3-python/main.py
@@ -0,0 +1,239 @@
+import network
+import json
+import os
+import ntptime
+import machine
+from time import ticks_ms, localtime, sleep_ms
+from esp32 import RMT
+from time import sleep
+from machine import SPI, SoftSPI, Timer, Pin
+from time import sleep_ms
+
+# Timezone settings
+CONFIG_TIMEZONE = 2
+CONFIG_DAYLIGHT_SAVING_ENABLED = True
+CONFIG_NTP_SYNC_INTERVAL_HOURS = 24
+
+# General animation settings
+CONFIG_CPU_FREQUENCY = 80000000
+CONFIG_RMT_DIVISOR = 255
+CONFIG_REFRESH_RATE = 100
+
+# Night time dimming configuration
+CONFIG_DIMMING_ENABLED = True
+CONFIG_DIMMING_BRIGHTNESS = 0.4
+CONFIG_DIMMING_START = 15
+CONFIG_DIMMING_END = 6
+CONFIG_DIMMING_GAMMA = 2.2
+
+# Daytime frame blending configuration
+CONFIG_BLENDING_ENABLED = True
+CONFIG_BLENDING_DURATION = 150
+
+# Wireless networks
+CONFIG_NETWORKS = {
+ "k-space.ee legacy": "",
+}
+
+try:
+ from config import *
+except ImportError:
+ print("Upload config.py to override configuration")
+except:
+ print("Failed to load config.py")
+
+assert CONFIG_TIMEZONE >= -12
+assert CONFIG_TIMEZONE < 14
+assert CONFIG_DAYLIGHT_SAVING_ENABLED in (True, False)
+assert CONFIG_CPU_FREQUENCY in (80000000, 160000000, 240000000)
+assert CONFIG_DIMMING_BRIGHTNESS >= 0
+assert CONFIG_DIMMING_BRIGHTNESS <= 1
+assert CONFIG_DIMMING_START >= 12
+assert CONFIG_DIMMING_START <= 23
+assert CONFIG_DIMMING_END >= 1
+assert CONFIG_DIMMING_END < 12
+assert CONFIG_DIMMING_GAMMA >= 1
+assert CONFIG_BLENDING_DURATION < 500
+assert CONFIG_BLENDING_DURATION >= 0
+assert CONFIG_RMT_DIVISOR >= 1
+assert CONFIG_RMT_DIVISOR <= 255
+
+print("Setting CPU frequency to", CONFIG_CPU_FREQUENCY // 1000000, "MHz")
+machine.freq(CONFIG_CPU_FREQUENCY)
+
+sta_if = network.WLAN(network.STA_IF)
+sta_if.active(True)
+for jssid, _, _, _, _, _ in sta_if.scan():
+ ssid = jssid.decode("utf-8")
+ if ssid in CONFIG_NETWORKS:
+ sta_if.connect(ssid, CONFIG_NETWORKS[ssid])
+ print("Connecting to", ssid, "...")
+ while not sta_if.isconnected():
+ pass
+ print("Obtained DHCP lease for", sta_if.ifconfig()[0])
+ ntptime.settime()
+ sta_if.active(False)
+ break
+else:
+ print("No configured wireless network found")
+
+print("Press Ctrl-C now to abort main.py execution and retain keyboard input")
+sleep_ms(2000)
+
+clock = Pin(20, mode=Pin.OUT)
+latch = Pin(9, mode=Pin.OUT)
+data = Pin(2, mode=Pin.OUT)
+unused = Pin(11)
+spi = SoftSPI(baudrate=3000000, sck=clock, mosi=data, miso=unused)
+
+COLON_LEFT_BOTTOM = 1 << 17 << 16
+COLON_LEFT_TOP = 1 << 18 << 16
+COLON_RIGHT_BOTTOM = 1 << 17
+COLON_RIGHT_TOP = 1 << 18
+
+COLON_LEFT_BOTH = COLON_LEFT_TOP | COLON_LEFT_BOTTOM
+COLON_RIGHT_BOTH = COLON_RIGHT_TOP | COLON_RIGHT_BOTTOM
+COLON_BOTTOM_BOTH = COLON_LEFT_BOTTOM | COLON_RIGHT_BOTTOM
+COLON_TOP_BOTH = COLON_LEFT_TOP | COLON_RIGHT_TOP
+COLON_ALL = COLON_LEFT_BOTH | COLON_RIGHT_BOTH
+
+IN15A_MICRO = 0
+IN15A_PERCENT = 2
+IN15A_PETA = 3
+IN15A_KILO = 4
+IN15A_MEGA = 5
+IN15A_MILLI = 6
+IN15A_PLUS = 7
+IN15A_MINUS = 8
+IN15A_PICO = 9
+
+def clamp(v, lower=-99, upper=99):
+ if v > upper:
+ return upper
+ elif v < lower:
+ return lower
+
+def render_digit(j, position=0):
+ assert j >= -1 and j <= 9
+ if j == -1: j = 10
+ return [11, 9, 12, 8, 0, 4, 1, 3, 2, 10, 15][j] << 3 << (position << 3)
+
+def render_digits(*args):
+ z = 0
+ for position, value in enumerate(reversed(args)):
+ z |= render_digit(value, position)
+ return z
+
+def render_time(colons=True):
+ _, _, _, h, m, s, _, _ = localtime()
+ return render_digits(h // 10, h % 10, m // 10, m % 10, s // 10, s % 10) | (colons and COLON_ALL)
+
+def render_date(colons=True):
+ y, m, d, _, _, _, _, _ = localtime()
+ return render_digits(y // 10, y % 10, m // 10, m % 10, d // 10, d % 10) | (colons and COLON_BOTTOM_BOTH)
+
+def render_temperature(t):
+ val = abs(clamp(t))
+ return render_digits(-1, IN15A_MINUS if t < 0 else -1, val // 10, val % 10, -1, -1) | COLON_RIGHT_TOP
+
+rmt = RMT(0, pin=latch, clock_div=CONFIG_RMT_DIVISOR, idle_level=False)
+tim = Timer(0, mode=Timer.PERIODIC)
+
+RMT_DURATION = CONFIG_CPU_FREQUENCY // CONFIG_RMT_DIVISOR // CONFIG_REFRESH_RATE
+assert RMT_DURATION <= 32767, "RMT duration %d overflows 32767" % RMT_DURATION
+
+print("Refresh rate: %d Hz" % CONFIG_REFRESH_RATE)
+print("PWM period: %d RMT pulses" % RMT_DURATION)
+
+i = 0
+d = 0
+
+from time import time, ticks_ms, localtime
+z = time()
+calibration = 0
+while z == time():
+ calibration = ticks_ms() % 1000
+
+def is_dst(y, mo, d, h, m):
+ if mo < 3 or mo > 11:
+ return False
+ if 3 < mo < 11:
+ return True
+ if mo == 3:
+ return d >= 8 # Approximation
+ if mo == 11:
+ return d < 7 # Approximation
+ return False
+
+def get_time():
+ subsec = (ticks_ms() - calibration) % 1000
+ now = time() + CONFIG_TIMEZONE * 3600
+ y, mo, d, h, m, s, _, _ = localtime(now)
+ if CONFIG_DAYLIGHT_SAVING_ENABLED and is_dst(y, mo, d, h, m):
+ now += 3600 # Add 1 hour
+ y, mo, d, h, m, s, _, _ = localtime(now)
+ return y, mo, d, h, m, s, subsec
+
+
+def display_static(j):
+ spi.write(j.to_bytes(6))
+ rmt.write_pulses((1,1), 0)
+
+def display_dimmed(j, brightness=0.5):
+ duty = brightness ** CONFIG_DIMMING_GAMMA
+ assert brightness >= 0
+ assert brightness <= 1
+ assert duty >= 0
+ assert duty <= 1
+ pulses = (1, 100, 1 + int(RMT_DURATION * duty), 1)
+ if brightness == 1.0:
+ display_static(j)
+ else:
+ spi.write(j.to_bytes(6))
+ rmt.write_pulses(pulses, 0)
+ spi.write('\x78\x78\x78\x78\x78\x78')
+
+def display_blended(j, i, progression=0.5):
+ assert progression >= 0
+ assert progression <= 1
+ duty = progression
+ pulses = (1, 100, 1 + int(RMT_DURATION * duty), 1)
+ if progression == 1.0:
+ display_static(j)
+ else:
+ spi.write(j.to_bytes(6))
+ rmt.write_pulses(pulses, 0)
+ spi.write(i.to_bytes(6))
+
+STATE_PREVIOUS = 0x787878787878
+STATE_REFRESH_MODE = 0
+
+def run_clock(*args):
+ global STATE_PREVIOUS
+ global STATE_REFRESH_MODE
+ y, mo, d, h, m, s, subsec = get_time()
+ current = render_digits(h // 10, h % 10, m // 10, m % 10, s // 10, s % 10)
+ prev = STATE_PREVIOUS
+ if subsec <= 500:
+ current |= COLON_ALL
+ prev |= COLON_ALL
+ if CONFIG_DIMMING_ENABLED and (h >= CONFIG_DIMMING_START or h < CONFIG_DIMMING_END):
+ if STATE_REFRESH_MODE != 1:
+ print("Switching to dimming mode, because", CONFIG_DIMMING_START, "<=", h, "<=", CONFIG_DIMMING_END)
+ STATE_REFRESH_MODE = 1
+ display_dimmed(current, CONFIG_DIMMING_BRIGHTNESS)
+ elif CONFIG_BLENDING_ENABLED:
+ if STATE_REFRESH_MODE != 2:
+ print("Switching to blending mode")
+ STATE_REFRESH_MODE = 2
+ if subsec <= CONFIG_BLENDING_DURATION:
+ if current != prev:
+ display_blended(current, prev, progression=subsec / CONFIG_BLENDING_DURATION)
+ else:
+ display_static(current)
+ STATE_PREVIOUS = current
+ else:
+ display_static(current)
+
+print("Setting up periodic timer at %d Hz (%d ms)" % (CONFIG_REFRESH_RATE, 1000 // CONFIG_REFRESH_RATE))
+tim.init(mode=Timer.PERIODIC, period=1000 // CONFIG_REFRESH_RATE, callback=run_clock)
diff --git a/firmware/esp8266-arduino/.gitignore b/firmware/esp8266-arduino/.gitignore
new file mode 100644
index 0000000..798d116
--- /dev/null
+++ b/firmware/esp8266-arduino/.gitignore
@@ -0,0 +1 @@
+cities.h
diff --git a/firmware/Makefile b/firmware/esp8266-arduino/Makefile
similarity index 58%
rename from firmware/Makefile
rename to firmware/esp8266-arduino/Makefile
index 6b95be6..8afbcd8 100644
--- a/firmware/Makefile
+++ b/firmware/esp8266-arduino/Makefile
@@ -1,20 +1,21 @@
SKETCH_FOLDER := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
UPLOAD_PORT ?= /dev/ttyUSB0
-all: $(SKETCH_FOLDER)/build/firmware.ino.bin
+all: $(SKETCH_FOLDER)/build/esp8266-arduino.ino.bin
-$(SKETCH_FOLDER)/cities.h: cities.py
- python3 cities.py > $(SKETCH_FOLDER)/cities.h
+$(SKETCH_FOLDER)/cities.h: ../cities.py
+ python3 ../cities.py > $(SKETCH_FOLDER)/cities.h
-$(SKETCH_FOLDER)/build/firmware.ino.bin: $(SKETCH_FOLDER)/firmware.ino $(SKETCH_FOLDER)/cities.h
+$(SKETCH_FOLDER)/build/esp8266-arduino.ino.bin: $(SKETCH_FOLDER)/esp8266-arduino.ino $(SKETCH_FOLDER)/cities.h
arduino-cli compile -e -b esp8266:esp8266:generic $(SKETCH_FOLDER)
deps:
+ arduino-cli config set network.connection_timeout 600s
arduino-cli core install \
--additional-urls=http://arduino.esp8266.com/stable/package_esp8266com_index.json esp8266:esp8266
arduino-cli lib install wifimanager ESP8266TimerInterrupt ezTime
-flash: $(SKETCH_FOLDER)/firmware.ino.bin
+flash: $(SKETCH_FOLDER)/esp8266-arduino.ino.bin
arduino-cli upload -b esp8266:esp8266:generic -p $(UPLOAD_PORT) $(SKETCH_FOLDER)
console:
diff --git a/firmware/esp8266-arduino/README.md b/firmware/esp8266-arduino/README.md
new file mode 100644
index 0000000..9b44eca
--- /dev/null
+++ b/firmware/esp8266-arduino/README.md
@@ -0,0 +1,15 @@
+# Arduino variant for ESP8266
+
+This firmware is designed for Nixie tube clocks using the ESP8266 microcontroller.
+
+## Features
+
+- WiFi configuration via captive portal (WiFiManager)
+- NTP time synchronization
+- Time and date display
+- Timezone and NTP server configuration
+- Clock drift mitigation using the ezTime library
+
+## Note
+
+The main issue with ESP8266-based clocks is clock drift. This firmware uses the ezTime library to reduce drift, but accuracy is still limited compared to ESP32-based
\ No newline at end of file
diff --git a/firmware/firmware.ino b/firmware/esp8266-arduino/esp8266-arduino.ino
similarity index 100%
rename from firmware/firmware.ino
rename to firmware/esp8266-arduino/esp8266-arduino.ino