Compare commits

...

10 Commits

26 changed files with 2013 additions and 575 deletions

70
.github/workflows/esp32c3-arduino.yaml vendored Normal file
View File

@@ -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

69
.github/workflows/esp8266-arduino.yaml vendored Executable file
View File

@@ -0,0 +1,69 @@
---
name: esp8266-arduino
on:
push:
tags:
- 'v*.*.*'
jobs:
build:
permissions: write-all
strategy:
matrix:
arduino-platform: ["esp8266:esp8266"]
include:
- arduino-platform: "esp8266:esp8266"
fqbn: "esp8266:esp8266:generic"
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 core install
--additional-urls=http://arduino.esp8266.com/stable/package_esp8266com_index.json
${{ matrix.arduino-platform }}
- name: Install time lib
run: arduino-cli lib install time wifimanager ezTime
- name: Make timezones
run: python firmware/cities.py > firmware/esp8266-arduino/cities.h
- name: Compile Sketch
run: >
arduino-cli compile --fqbn ${{ matrix.fqbn }} -e
firmware/esp8266
- 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/esp8266-arduino/build/esp8266.esp8266.generic/esp32c3-arduino.ino.bin
asset_name: nixiesp12-esp8266-arduino.bin
asset_content_type: application/bin

5
.gitignore vendored
View File

@@ -1,11 +1,12 @@
bin
_autosave*
*.bak
export
*.kicad_pcb-bak
*rescue.lib
firmware/*.bin
*.drl
*.g*
*.gcode
*.ps
*.zip
fp-info-cache
*.stl

View File

@@ -6,10 +6,10 @@ Yet another Nixie clock project!
Some highlights:
* K155ID1 driven IN-12A or IN-12B tubes for hour, minute, seconds
* BS108 driven IN-6 or similar neon bulbs for digit separation
* K155ID1 driven IN-12A or IN-12B tubes for hour, minute, seconds. Use 36k resistors on these tubes.
* BS108 driven IN-6 or [similar 4*10mm neon bulbs](https://www.aliexpress.com/item/32344955038.html?spm=a2g0s.9042311.0.0.6ab14c4dCxpPZ0) for digit separation. Use 270k resistors on these bulbs.
* 74HC595 for serial to parallel conversion
* ESP8266 for time synchronization
* ESP8266 for time synchronization. Use 1k resistors for pull-up.
* LM1117-3.3V and LM1117-5V regulators for logic power supply
* No tube step up converter on-board, pin headers for connecting
<a href="http://www.ebay.com/itm/DC-5V-12V-to-170V-DC-High-Voltage-NIXIE-Power-Supply-Module-PSU-NIXIE-TUBE-ERA-/322511957768?hash=item4b1735ef08">third party power supply</a>

20
colon-stand.scad Normal file
View File

@@ -0,0 +1,20 @@
H = 21;
Q = 1.2;
D = 1;
module j() {
cylinder(h=100, d=D, $fn=20);
translate([0,0,H-Q]) cylinder(5,0,5,$fn=20);
cylinder(Q,Q,0,$fn=20);
}
difference() {
hull() {
translate([6.5, 0]) cylinder(h=H, d=4, $fn=50);
translate([-6.5, 0]) cylinder(h=H, d=4, $fn=50);
}
translate([6.5, 0]) j();
translate([-6.5, 0]) j();
translate([3.5, 0]) j();
translate([-3.5, 0]) j();
}

BIN
config/.prometheus.yml.swp Normal file

Binary file not shown.

13
config/prometheus.yml Normal file
View File

@@ -0,0 +1,13 @@
global:
scrape_interval: 2s
evaluation_interval: 2s
scrape_timeout: 1s
scrape_configs:
- job_name: nixie
static_configs:
- targets:
- 192.168.88.16
- 192.168.88.17
- 192.168.88.18
- 192.168.88.20

10
docker-compose.yml Normal file
View File

@@ -0,0 +1,10 @@
version: '3.7'
services:
prometheus:
network_mode: "host"
image: prom/prometheus:latest
command:
- --config.file=/config/prometheus.yml
volumes:
- ./config:/config:ro

2
firmware/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
build
cities.h

View File

@@ -1,23 +0,0 @@
NAME=esp8266-1m-20210418-v1.15.bin
flash:
wget -c http://micropython.org/resources/firmware/${NAME}
esptool.py -p /dev/ttyUSB0 write_flash --flash_size=1MB 0 ${NAME}
erase:
esptool.py -p /dev/ttyUSB0 erase_flash
upload:
ampy -p /dev/ttyUSB0 put picoweb.py
ampy -p /dev/ttyUSB0 put timezone.py
ampy -p /dev/ttyUSB0 put main.py
console:
echo "Ctrl-A + Ctrl-Q to close Picocom"
picocom -b115200 /dev/ttyUSB0
clone_read:
esptool.py -p /dev/ttyUSB0 read_flash 0 0x100000 clone.bin
clone_write:
esptool.py -p /dev/ttyUSB0 write_flash --flash_size=1MB 0 clone.bin

42
firmware/cities.py Normal file
View File

@@ -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"(
<br/>
<label for="city">Nearest city</label>
<select name="city" id="city" onchange="
document.getElementById('timezone').value = this.value;
document.getElementById('long').value = this.options[this.selectedIndex].dataset.long;
document.getElementById('lat').value = this.options[this.selectedIndex].dataset.lat;
">""")
for name, code in cr:
if name.startswith("Etc/"):
continue
if not name.startswith("Europe/"):
continue
longlat = coords.get(name)
selected = name == "Europe/Tallinn"
print("""<option value="%s" data-long="%s" data-lat="%s"%s>%s</option>""" % (
code,
int(longlat[0]) if longlat else "",
int(longlat[1]) if longlat else "",
" selected" if selected else "",
name))
print("""</select>
)";
""")

1
firmware/esp32c3-arduino/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
cities.h

View File

@@ -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)

View File

@@ -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.

View File

@@ -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"(
<br/>
<label for="city">Nearest city</label>
<select name="city" id="city" onchange="
document.getElementById('timezone').value = this.value;
document.getElementById('long').value = this.options[this.selectedIndex].dataset.long;
document.getElementById('lat').value = this.options[this.selectedIndex].dataset.lat;
">""")
for name, code in cr:
if name.startswith("Etc/"):
continue
if not name.startswith("Europe/"):
continue
longlat = coords.get(name)
selected = name == "Europe/Tallinn"
print("""<option value="%s" data-long="%s" data-lat="%s"%s>%s</option>""" % (
code,
int(longlat[0]) if longlat else "",
int(longlat[1]) if longlat else "",
" selected" if selected else "",
name))
print("""</select>
)";
""")

View File

@@ -0,0 +1,802 @@
#include <Arduino.h>
#include <WiFiManager.h>
#include <WiFi.h>
#include "esp_sntp.h"
#include <time.h>
#include <math.h>
#include <driver/rmt.h>
#include "cities.h"
#include <LittleFS.h>
// --- 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"(
<br/>
<label for="displayModeCombobox">Clock display format</label>
<select name="timeDisplay" id="displayModeCombobox" onchange="document.getElementById('displayMode').value = this.value">
<option value="1">Time</option>
<option value="2">Date</option>
<option value="3">Time and Date</option>
</select>
<script>
document.getElementById("displayModeCombobox").value = document.getElementById("displayMode").value;
document.querySelector("[for='displayMode']").hidden = true;
document.getElementById("displayMode").hidden = true;
</script>)";
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"(
<br/><label for='dimming_duty_cycle_slider'>Night time dimming duty cycle (%)</label>
<input type="range" min="500" max="32767" value="1000" class="slider" id="dimming_duty_cycle_slider" onchange="document.getElementById('dimming_duty_cycle').value = this.value">
<script>
document.getElementById('dimming_duty_cycle').hidden = true;
</script>
)";
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"(
<br/><label for='blending_duration_slider'>Blending duration (0 .. 400ms)</label>
<input type='range' min='0' max='400' value='200' class='slider' id='blending_duration_slider' onchange="document.getElementById('blending_duration').value = this.value">
<script>
document.getElementById('blending_duration').hidden = true;
</script>
)";
WiFiManagerParameter paramBlendingDuration("blending_duration", "", "200", 4);
WiFiManagerParameter paramBlendingDurationSlider(blendingSliderSnippet);
const char dimmingStartCombobox[] = R"(
<br/>
<label for='dimming_start_hour'>Night time dimming start time</label>
<select name='dimming_start' id='dimming_start_hour' onchange="document.getElementById('dimming_start').value = this.value">
<option value='13'>13:00</option>
<option value='14'>14:00</option>
<option value='15'>15:00</option>
<option value='16'>16:00</option>
<option value='17'>17:00</option>
<option value='18'>18:00</option>
<option value='19'>19:00</option>
<option value='20'>20:00</option>
<option value='21'>21:00</option>
<option value='22'>22:00</option>
<option value='23'>23:00</option>
</select>
<script>
document.getElementById('dimming_start_hour').value = "22";
document.getElementById('dimming_start').hidden = true;
</script>
)";
const char dimmingEndCombobox[] = R"(
<br/>
<label for='dimming_end_hour'>Night time dimming end time</label>
<select name='dimming_end' id='dimming_end_hour' onchange="document.getElementById('dimming_end').value = this.value">
<option value='4'>4:00</option>
<option value='5'>5:00</option>
<option value='6'>6:00</option>
<option value='7'>7:00</option>
<option value='8'>8:00</option>
<option value='9'>9:00</option>
<option value='10'>10:00</option>
<option value='11'>11:00</option>
<option value='12'>12:00</option>
</select>
<script>
document.getElementById('dimming_end_hour').value = "6";
document.getElementById('dimming_end').hidden = true;
</script>
)";
const char hourFormatCombobox[] = R"(
<br/>
<label for="hourFormatCombobox">Hour format</label>
<select name="hour_format" id="hourFormatCombobox" onchange="document.getElementById('hour_format').value = this.value">
<option value="24">24-hour</option>
<option value="12">12-hour</option>
</select>
<script>
document.getElementById("hourFormatCombobox").value = "12";
document.getElementById("hour_format").hidden = true;
</script>
)";
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;
if (configHourFormat == 12 && h == 0) {
h = 12; // TODO: Parameter to configure 12-hour format
}
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_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_bytes ";
buf += uxTaskGetStackHighWaterMark(NULL);
buf += "\n";
buf += "nixie_display_task_iterations_count ";
buf += displayTaskIterationsCount;
buf += "\n";
if (bootTimestamp > 0) {
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(&paramNetworkTimeServer);
wm.addParameter(&paramCity);
wm.addParameter(&paramTimezone);
wm.addParameter(&paramDisplayMode);
wm.addParameter(&paramDisplayModeCombobox);
wm.addParameter(&paramDimmingDutyCycle);
wm.addParameter(&paramDimmingDutyCycleSlider);
wm.addParameter(&paramDimmingStart);
wm.addParameter(&paramDimmingStartCombobox);
wm.addParameter(&paramDimmingEnd);
wm.addParameter(&paramDimmingEndCombobox);
wm.addParameter(&paramBlendingDuration);
wm.addParameter(&paramBlendingDurationSlider);
wm.addParameter(&paramHourFormat);
wm.addParameter(&paramHourFormatCombobox);
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
}

View File

@@ -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

View File

@@ -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)

1
firmware/esp8266-arduino/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
cities.h

View File

@@ -0,0 +1,22 @@
SKETCH_FOLDER := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
UPLOAD_PORT ?= /dev/ttyUSB0
all: $(SKETCH_FOLDER)/build/esp8266-arduino.ino.bin
$(SKETCH_FOLDER)/cities.h: ../cities.py
python3 ../cities.py > $(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)/esp8266-arduino.ino.bin
arduino-cli upload -b esp8266:esp8266:generic -p $(UPLOAD_PORT) $(SKETCH_FOLDER)
console:
picocom -b 9600 $(UPLOAD_PORT)

View File

@@ -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

View File

@@ -0,0 +1,511 @@
/*
https://github.com/laurivosandi/nixiesp12/blob/master/firmware/main.py
https://randomnerdtutorials.com/wifimanager-with-esp8266-autoconnect-custom-parameter-and-manage-your-ssid-and-password/
*/
#include <LittleFS.h>
#include <WiFiManager.h>
#include <ezTime.h>
#include "cities.h"
// #define DEBUG 1
// #define DIMMING_ENABLED 1
// #define TEST_SEQUENCE 1
#define PIN_CLOCK 3
#define PIN_DATA 2
#define PIN_LATCH 0
#define SUNRISE 6
#define SUNSET 22
// ezTime structs
tmElements_t tm;
Timezone local;
int configDimmingDutyCycle = 0;
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"(
<br/>
<label for="displayModeCombobox">Clock display format</label>
<select name="timeDisplay" id="displayModeCombobox" onchange="document.getElementById('displayMode').value = this.value">
<option value="1">Time</option>
<option value="2">Date</option>
<option value="3">Time and Date</option>
</select>
<script>
document.getElementById("displayModeCombobox").value = document.getElementById("displayMode").value;
document.querySelector("[for='displayMode']").hidden = true;
document.getElementById("displayMode").hidden = true;
</script>)";
#ifdef DIMMING_ENABLED
const char dimmerSliderSnippet[] = R"(
<br/><label for='dimming_duty_cycle_slider'>Night time dimming</label>
<input type="range" min="1" max="3840" value="1000" class="slider" id="dimming_duty_cycle_slider" onchange="document.getElementById('dimming_duty_cycle').value = this.value">
<script>
document.getElementById('dimming_duty_cycle').hidden = true;
</script>
)";
#endif
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);
//WiFiManagerParameter paramLong("long", "Longitude", "26", 10);
//WiFiManagerParameter paramLat("lat", "Latitude", "59", 10);
#ifdef DIMMING_ENABLED
WiFiManagerParameter paramDimmingDutyCycle("dimming_duty_cycle", "", "1000", 4);
WiFiManagerParameter paramDimmingDutyCycleSlider(dimmerSliderSnippet);
// Dimmer settings
volatile long displayInterruptCount = 0;
#endif
volatile bool blink = true;
int lookup[] = {11, 9, 12, 8, 0, 4, 1, 3, 2, 10};
void ICACHE_RAM_ATTR bitbang_bit(int value){
if(value & 1){
digitalWrite(PIN_DATA, HIGH);
}
else{
digitalWrite(PIN_DATA, LOW);
}
digitalWrite(PIN_CLOCK, HIGH);
digitalWrite(PIN_CLOCK, LOW);
}
void ICACHE_RAM_ATTR bitbang_digit(int digit){
int i = 0;
if (!blink && timeStatus() != timeSet) {
for(i=0;i<4;i++){
bitbang_bit(1);
}
} else {
for(i=0;i<4;i++){
bitbang_bit(lookup[digit] << i >> 3);
}
}
}
void ICACHE_RAM_ATTR renderTest(int j) {
for(int i=0; i<6; i++){
bitbang_bit(0);
bitbang_digit(j);
bitbang_bit(0);
bitbang_bit(0);
bitbang_bit(0);
}
digitalWrite(PIN_LATCH, HIGH);
digitalWrite(PIN_LATCH, LOW);
}
void ICACHE_RAM_ATTR renderTime(){
int hour = tm.Hour;
int minute = tm.Minute;
int second = tm.Second;
bitbang_bit(0);
bitbang_digit(hour / 10);
bitbang_bit(0);
bitbang_bit(0);
bitbang_bit(0);
bitbang_bit(0);
bitbang_digit(hour % 10);
bitbang_bit(blink);
bitbang_bit(blink);
bitbang_bit(blink);
bitbang_bit(blink);
bitbang_digit(minute / 10);
bitbang_bit(0);
bitbang_bit(0);
bitbang_bit(0);
bitbang_bit(0);
bitbang_digit(minute % 10);
bitbang_bit(blink);
bitbang_bit(blink);
bitbang_bit(blink);
bitbang_bit(blink);
bitbang_digit(second / 10);
bitbang_bit(0);
bitbang_bit(0);
bitbang_bit(0);
bitbang_bit(0);
bitbang_digit(second % 10);
bitbang_bit(0);
bitbang_bit(0);
bitbang_bit(0);
}
void ICACHE_RAM_ATTR renderDate(){
int day = tm.Day;
int month = tm.Month;
int year = tm.Year-30;
bitbang_bit(0);
bitbang_digit((year) / 10);
bitbang_bit(0);
bitbang_bit(0);
bitbang_bit(0);
bitbang_bit(0);
bitbang_digit((year) % 10);
bitbang_bit(0);
bitbang_bit(1);
bitbang_bit(0);
bitbang_bit(0);
bitbang_digit(month/ 10);
bitbang_bit(0);
bitbang_bit(0);
bitbang_bit(0);
bitbang_bit(0);
bitbang_digit(month % 10);
bitbang_bit(0);
bitbang_bit(1);
bitbang_bit(0);
bitbang_bit(0);
bitbang_digit(day / 10);
bitbang_bit(0);
bitbang_bit(0);
bitbang_bit(0);
bitbang_bit(0);
bitbang_digit(day % 10);
bitbang_bit(0);
bitbang_bit(0);
bitbang_bit(0);
}
void ICACHE_RAM_ATTR renderDisplay() {
switch (configDisplayModesEnabled) {
case 1:
renderTime();
break;
case 2:
renderDate();
break;
case 3:
if (millis() % 30000 < 15000) {
renderTime();
} else {
renderDate();
}
break;
}
digitalWrite(PIN_LATCH, HIGH);
digitalWrite(PIN_LATCH, LOW);
}
void ICACHE_RAM_ATTR clearDisplay() {
for(int i=0; i<6; i++){
bitbang_bit(1);
bitbang_bit(0);
bitbang_bit(0);
bitbang_bit(0);
bitbang_bit(0);
bitbang_bit(1);
bitbang_bit(1);
bitbang_bit(1);
}
digitalWrite(PIN_LATCH, HIGH);
digitalWrite(PIN_LATCH, LOW);
}
int counter = 0;
struct timeval tv;
#ifdef DIMMING_ENABLED
void ICACHE_RAM_ATTR dimmerTimerCallback() {
noInterrupts();
displayInterruptCount++;
gettimeofday(&tv, NULL);
blink = tv.tv_usec < 500000;
int j = current_dimming_duty_cycle;
if (j < configDimmingDutyCycle) {
j = configDimmingDutyCycle;
}
// if (counter == 0) {
renderDisplay();
timer1_write(j+1);
// counter = 1;
// } else if (counter == 1) {
// clearDisplay();
// timer1_write(3840-j+1);
// counter = 0;
// }
interrupts();
}
#endif
int loadClockConfig() {
unsigned char timezone[30] = {'\0'};
File file = LittleFS.open("/timezone", "r");
if (!file) { return 1; }
if (!file.read(timezone, sizeof(timezone))) { return 2; }
Serial.print("Using timezone: ");
Serial.println((const char*)timezone);
unsigned char timeserver[63] = {'\0'};
file = LittleFS.open("/timeserver", "r");
if (!file) { return 3; }
if (!file.read(timeserver, sizeof(timeserver))) { return 4; }
Serial.print("Using time server: ");
Serial.println((const char*)timeserver);
setServer((const char*)timeserver);
local.setPosix((const char*)timezone);
unsigned char modes[1] = {'\0'};
file = LittleFS.open("/modes", "r");
if (!file) { return 5; }
if (!file.read(modes, sizeof(modes))) { return 6; }
configDisplayModesEnabled = atoi((const char*)modes);
Serial.print("Enabled display modes:");
if (configDisplayModesEnabled & DISPLAY_MODE_DATE) {
Serial.print(" DATE");
}
if (configDisplayModesEnabled & DISPLAY_MODE_TIME) {
Serial.print(" TIME");
}
Serial.println();
#ifdef DIMMING_ENABLED
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);
Serial.print("Night time dimming duty cycle: ");
Serial.println((const char*)bufDimming);
#endif
return 0;
}
void saveParamsCallback() {
File file = LittleFS.open("/timeserver", "w");
file.print(paramNetworkTimeServer.getValue());
file.close();
file = LittleFS.open("/timezone", "w");
file.print(paramTimezone.getValue());
file.close();
#ifdef DIMMING_ENABLED
file = LittleFS.open("/dimming", "w");
file.print(paramDimmingDutyCycle.getValue());
file.close();
#endif
file = LittleFS.open("/modes", "w");
file.print(paramDisplayMode.getValue());
file.close();
loadClockConfig();
}
void handleMetrics(){
char tbuf[30];
String buf = "";
buf += "nixie_sketch_size_bytes ";
buf += ESP.getSketchSize();
buf += "\n";
buf += "nixie_flash_space_bytes ";
buf += ESP.getFlashChipRealSize();
buf += "\n";
buf += "nixie_free_heap_bytes ";
buf += ESP.getFreeHeap();
buf += "\n";
#ifdef DIMMING_ENABLED
buf += "nixie_display_interrupt_count ";
buf += displayInterruptCount;
buf += "\n";
#endif
buf += "nixie_ntp_last_sync_timestamp_seconds ";
buf += lastNtpUpdateTime();
buf += "\n";
wm.server->send(200, "text/plain", buf);
}
void initializePins() {
pinMode(PIN_CLOCK, OUTPUT);
pinMode(PIN_LATCH, OUTPUT);
pinMode(PIN_DATA, OUTPUT);
digitalWrite(PIN_CLOCK, LOW);
digitalWrite(PIN_LATCH, LOW);
digitalWrite(PIN_DATA, LOW);
clearDisplay();
}
void setup() {
Serial.begin(9600);
Serial.println("Nixie clock booting up");
initializePins();
#ifdef DEBUG
setDebug(INFO);
#endif
#ifdef TEST_SEQUENCE
for(int i = 0; i < 10; i++) {
renderTest(i);
delay(3000);
}
#endif
wm.setDebugOutput(true);
wm.setMinimumSignalQuality(50);
if (!LittleFS.begin()) {
Serial.println("LittleFS mount failed");
} else {
if(loadClockConfig() != 0) {
Serial.println("Failed to load clock configuration from LittleFS");
} else {
Serial.println("Configuration loaded");
}
}
wm.addParameter(&paramNetworkTimeServer);
wm.addParameter(&paramCity);
wm.addParameter(&paramTimezone);
//wm.addParameter(&paramLong);
//wm.addParameter(&paramLat);
wm.addParameter(&paramDisplayMode);
wm.addParameter(&paramDisplayModeCombobox);
#ifdef DIMMING_ENABLED
wm.addParameter(&paramDimmingDutyCycle);
wm.addParameter(&paramDimmingDutyCycleSlider);
#endif
wm.setSaveParamsCallback(saveParamsCallback);
wm.setShowInfoUpdate(false); // https://github.com/tzapu/WiFiManager/issues/1262
wm.setShowInfoErase(false);
wm.setConfigPortalBlocking(false);
Serial.println("Autostarting wireless");
wm.autoConnect();
Serial.println("Starting config portal");
wm.startConfigPortal();
#ifdef DEBUG
wm.server->on("/metrics", handleMetrics);
#else
wm.setDebugOutput(false);
#endif
renderNormal();
}
void renderNormal() {
gettimeofday(&tv, NULL);
blink = tv.tv_usec < 500000;
breakTime(local.now(), tm);
renderDisplay();
delay((500 - (local.ms(LAST_READ) % 500)) + 1);
#ifdef DEBUG
Serial.print(1970+tm.Year);
Serial.print("-");
Serial.print(tm.Month);
Serial.print("-");
Serial.print(tm.Day);
Serial.print(" ");
Serial.print(tm.Hour);
Serial.print(":");
Serial.print(tm.Minute);
Serial.print(":");
Serial.print(tm.Second);
Serial.print(".");
Serial.println(local.ms(LAST_READ));
#endif
}
void loop() {
wm.process();
events(); // this invokes yield()
#ifdef DIMMING_ENABLED
switch (operationModeCurrent) {
case OPERATION_MODE_NORMAL:
if ((tm.Hour < SUNRISE || tm.Hour > SUNSET) && timeStatus() == timeSet) {
operationModeCurrent = OPERATION_MODE_DIMMED;
#ifdef DEBUG
Serial.println("Clock synchronized, disabling wireless, enabling dimming");
#endif
WiFi.disconnect();
WiFi.mode(WIFI_OFF);
timer1_attachInterrupt(dimmerTimerCallback);
timer1_isr_init();
timer1_enable(TIM_DIV256, TIM_EDGE, TIM_SINGLE);
timer1_write(100);
} else {
renderNormal();
}
break;
case OPERATION_MODE_DIMMED:
if (tm.Hour > SUNRISE && tm.Hour < SUNSET) {
operationModeCurrent = OPERATION_MODE_NORMAL;
timer1_detachInterrupt();
timer1_disable();
#ifdef DEBUG
Serial.println("Disabling dimming");
#endif
WiFi.mode(WIFI_STA);
} else if (tm.Hour == SUNRISE) {
current_dimming_duty_cycle = tm.Minute << 6;
} else if (tm.Hour == SUNSET) {
current_dimming_duty_cycle = (61 - tm.Minute) << 6;
}
break;
}
#else
renderNormal();
#endif
}

View File

@@ -1,187 +0,0 @@
import gc
import network
import picoweb
import json
from time import sleep_ms
from timezone import TIMEZONES
app = picoweb.WebApp(__name__)
ap_if = network.WLAN(network.AP_IF)
sta_if = network.WLAN(network.STA_IF)
sta_if.active(True)
nets = sta_if.scan()
config = dict()
try:
with open("config.json") as fh:
config = json.loads(fh.read())
sta_if.connect(config.get("ssid"), config.get("password"))
except OSError:
pass
print("Scanning for wireless networks...")
@app.route("/connect")
def index(req, resp):
if req.method == "POST":
yield from req.read_form_data()
else:
req.parse_qs()
yield from picoweb.start_response(resp)
with open("config.json", "w") as fh:
fh.write(json.dumps(req.form))
yield from resp.awrite("Setting saved please power cycle device")
@app.route("/")
def index(req, resp):
print("Serving index")
yield from picoweb.start_response(resp)
yield from resp.awrite("<html>")
yield from resp.awrite("<head>")
yield from resp.awrite("<meta name='viewport' content='width=device-width, initial-scale=1'>")
yield from resp.awrite("</head>")
yield from resp.awrite("<body>")
yield from resp.awrite("<h>Welcome to NixiESP12 configuration wizard</h>")
yield from resp.awrite("<p>Detected wireless networks:</p>")
yield from resp.awrite("<form action='/connect' method='post'>")
yield from resp.awrite("<p>Select wireless network:</p>")
yield from resp.awrite("<select name='ssid'>")
for ssid, _, _, snr, crypto, _ in nets:
ssid = ssid.decode("utf-8")
yield from resp.awrite("<option>" + ssid + "</option>")
yield from resp.awrite("</select>")
yield from resp.awrite("<p>Wireless password is applicable:</p>")
yield from resp.awrite("<input type='password' name='password'/>")
yield from resp.awrite("<p>Timezone:</p>")
yield from resp.awrite("<select name='timezone'>")
for index, (dst, offset, title) in enumerate(TIMEZONES):
yield from resp.awrite("<option value='%d'>%s</option>" % (index, title))
yield from resp.awrite("</select>")
yield from resp.awrite("<p>NTP resynchronization interval:</p>")
yield from resp.awrite("<select name='interval'>")
for j in range(0, 73):
yield from resp.awrite("<option value='%d'>%d hours</option>" % (j*3600, j))
yield from resp.awrite("</select>")
yield from resp.awrite("<p>&nbsp;</p>")
yield from resp.awrite("<input type='submit'/>")
yield from resp.awrite("</form>")
yield from resp.awrite("</body>")
yield from resp.awrite("<html>")
timed_out = True
if config:
print("Connecting to", config.get("ssid"))
for j in range(0,30):
if sta_if.isconnected():
ap_if.active(False)
timed_out = False
break
sleep_ms(200)
if timed_out:
ap_if.active(True)
print("Starting setup wizard on", ap_if.ifconfig())
app.run()
TIMEZONE = TIMEZONES[int(config.get("timezone", 30))][1]
print("Using timezone", TIMEZONES[int(config.get("timezone", 30))])
RESYNC = int(config.get("interval")) # Resync once in 8 hours
print("NTP resynchronization interval", RESYNC, "seconds")
DEBUG = False
print("Press Ctrl-C now to abort main.py execution and retain keyboard input")
sleep_ms(2000)
import time
import ntptime
from machine import Pin, Timer
# Note that keyboard input is lost beyond this point!
clock = Pin(3, mode=Pin.OUT)
latch = Pin(0, mode=Pin.OUT)
data = Pin(2, mode=Pin.OUT)
blink = False
lookup = 11, 9, 12, 8, 0, 4, 1, 3, 2, 10
countdown = 0
def bitbang_bit(value):
if value & 1:
data.on()
else:
data.off()
clock.on()
clock.off()
def bitbang_digit(digit):
bitbang_bit(blink)
for i in range(0,4):
bitbang_bit(lookup[digit] << i >> 3)
bitbang_bit(blink)
bitbang_bit(blink)
bitbang_bit(blink)
def dst_offset(month, day, dow):
if month < 3 or month > 10:
return 0
if month > 3 and month < 10:
return 1
previous_sunday = day - dow
if month == 3:
return int(previous_sunday >= 25)
return int(previous_sunday < 25)
def dump_time(year, month, day, hour, minute, second, dow):
offset = dst_offset(month, day, dow)
if DEBUG:
print("Time is %02d:%02d:%02d, dst offset %d" % (hour, minute, second, offset))
hour = (hour + TIMEZONE + offset) % 24
bitbang_digit(hour // 10)
bitbang_digit(hour % 10)
bitbang_digit(minute // 10)
bitbang_digit(minute % 10)
bitbang_digit(second // 10)
bitbang_digit(second % 10)
# RTC accuracy is still garbage, time.ticks_ms() which is bound to CPU ticks seems to be more accurate
# https://forum.micropython.org/viewtopic.php?t=3251#p19092
# Boot up test sequence
for j in range(0, 10):
for i in range(0, 6):
bitbang_digit(j)
latch.on()
latch.off()
sleep_ms(500)
while True:
if countdown <= 0:
try:
ticks_then, time_then = time.ticks_ms(), ntptime.time()
except OSError:
sleep_ms(500)
print("Resync failed")
continue
else:
countdown = RESYNC
print("Resync done")
else:
year, month, day, hour, minute, second, dow, _ = time.localtime(time_then + (time.ticks_ms() - ticks_then) // 1000)
sleep_ms(500-(time.ticks_ms() - ticks_then) % 1000)
blink = True
dump_time(year, month, day, hour, minute, second, dow)
latch.on()
latch.off()
countdown -= 1
year, month, day, hour, minute, second, dow, _ = time.localtime(time_then + (time.ticks_ms() - ticks_then) // 1000)
sleep_ms(1001-(time.ticks_ms() - ticks_then) % 1000)
blink = False
dump_time(year, month, day, hour, minute, second, dow)
latch.on()
latch.off()
main()

View File

@@ -1,279 +0,0 @@
# Picoweb web pico-framework for MicroPython
# Copyright (c) 2014-2018 Paul Sokolovsky
# SPDX-License-Identifier: MIT
import sys
import gc
import micropython
import utime
import uio
import ure as re
import uerrno
import uasyncio as asyncio
def unquote_plus(s):
# TODO: optimize
s = s.replace("+", " ")
arr = s.split("%")
arr2 = [chr(int(x[:2], 16)) + x[2:] for x in arr[1:]]
return arr[0] + "".join(arr2)
def parse_qs(s):
res = {}
if s:
pairs = s.split("&")
for p in pairs:
vals = [unquote_plus(x) for x in p.split("=", 1)]
if len(vals) == 1:
vals.append(True)
old = res.get(vals[0])
if old is not None:
if not isinstance(old, list):
old = [old]
res[vals[0]] = old
old.append(vals[1])
else:
res[vals[0]] = vals[1]
return res
def get_mime_type(fname):
# Provide minimal detection of important file
# types to keep browsers happy
if fname.endswith(".html"):
return "text/html"
if fname.endswith(".css"):
return "text/css"
if fname.endswith(".png") or fname.endswith(".jpg"):
return "image"
return "text/plain"
def sendstream(writer, f):
buf = bytearray(64)
while True:
l = f.readinto(buf)
if not l:
break
yield from writer.awrite(buf, 0, l)
def jsonify(writer, dict):
import ujson
yield from start_response(writer, "application/json")
yield from writer.awrite(ujson.dumps(dict))
def start_response(writer, content_type="text/html", status="200", headers=None):
yield from writer.awrite("HTTP/1.0 %s NA\r\n" % status)
yield from writer.awrite("Content-Type: ")
yield from writer.awrite(content_type)
if not headers:
yield from writer.awrite("\r\n\r\n")
return
yield from writer.awrite("\r\n")
if isinstance(headers, bytes) or isinstance(headers, str):
yield from writer.awrite(headers)
else:
for k, v in headers.items():
yield from writer.awrite(k)
yield from writer.awrite(": ")
yield from writer.awrite(v)
yield from writer.awrite("\r\n")
yield from writer.awrite("\r\n")
def http_error(writer, status):
yield from start_response(writer, status=status)
yield from writer.awrite(status)
class HTTPRequest:
def __init__(self):
pass
def read_form_data(self):
size = int(self.headers[b"Content-Length"])
data = yield from self.reader.read(size)
form = parse_qs(data.decode())
self.form = form
def parse_qs(self):
form = parse_qs(self.qs)
self.form = form
class WebApp:
def __init__(self, pkg, routes=None):
if routes:
self.url_map = routes
else:
self.url_map = []
if pkg and pkg != "__main__":
self.pkg = pkg.split(".", 1)[0]
else:
self.pkg = None
self.mounts = []
self.inited = False
# Instantiated lazily
self.template_loader = None
self.headers_mode = "parse"
def parse_headers(self, reader):
headers = {}
while True:
l = yield from reader.readline()
if l == b"\r\n":
break
k, v = l.split(b":", 1)
headers[k] = v.strip()
return headers
def _handle(self, reader, writer):
close = True
try:
request_line = yield from reader.readline()
if request_line == b"":
yield from writer.aclose()
return
req = HTTPRequest()
# TODO: bytes vs str
request_line = request_line.decode()
method, path, proto = request_line.split()
path = path.split("?", 1)
qs = ""
if len(path) > 1:
qs = path[1]
path = path[0]
#print("================")
#print(req, writer)
#print(req, (method, path, qs, proto), req.headers)
# Find which mounted subapp (if any) should handle this request
app = self
while True:
found = False
for subapp in app.mounts:
root = subapp.url
#print(path, "vs", root)
if path[:len(root)] == root:
app = subapp
found = True
path = path[len(root):]
if not path.startswith("/"):
path = "/" + path
break
if not found:
break
# We initialize apps on demand, when they really get requests
if not app.inited:
app.init()
# Find handler to serve this request in app's url_map
found = False
for e in app.url_map:
pattern = e[0]
handler = e[1]
extra = {}
if len(e) > 2:
extra = e[2]
if path == pattern:
found = True
break
elif not isinstance(pattern, str):
# Anything which is non-string assumed to be a ducktype
# pattern matcher, whose .match() method is called. (Note:
# Django uses .search() instead, but .match() is more
# efficient and we're not exactly compatible with Django
# URL matching anyway.)
m = pattern.match(path)
if m:
req.url_match = m
found = True
break
if not found:
headers_mode = "skip"
else:
headers_mode = extra.get("headers", self.headers_mode)
if headers_mode == "skip":
while True:
l = yield from reader.readline()
if l == b"\r\n":
break
elif headers_mode == "parse":
req.headers = yield from self.parse_headers(reader)
else:
assert headers_mode == "leave"
if found:
req.method = method
req.path = path
req.qs = qs
req.reader = reader
close = yield from handler(req, writer)
else:
yield from start_response(writer, status="404")
yield from writer.awrite("404\r\n")
#print(req, "After response write")
except Exception as e:
pass
if close is not False:
yield from writer.aclose()
def mount(self, url, app):
"Mount a sub-app at the url of current app."
# Inspired by Bottle. It might seem that dispatching to
# subapps would rather be handled by normal routes, but
# arguably, that's less efficient. Taking into account
# that paradigmatically there's difference between handing
# an action and delegating responisibilities to another
# app, Bottle's way was followed.
app.url = url
self.mounts.append(app)
def route(self, url, **kwargs):
def _route(f):
self.url_map.append((url, f, kwargs))
return f
return _route
def add_url_rule(self, url, func, **kwargs):
# Note: this method skips Flask's "endpoint" argument,
# because it's alleged bloat.
self.url_map.append((url, func, kwargs))
def _load_template(self, tmpl_name):
if self.template_loader is None:
import utemplate.source
self.template_loader = utemplate.source.Loader(self.pkg, "templates")
return self.template_loader.load(tmpl_name)
def render_template(self, writer, tmpl_name, args=()):
tmpl = self._load_template(tmpl_name)
for s in tmpl(*args):
yield from writer.awrite(s)
def render_str(self, tmpl_name, args=()):
#TODO: bloat
tmpl = self._load_template(tmpl_name)
return ''.join(tmpl(*args))
def init(self):
"""Initialize a web application. This is for overriding by subclasses.
This is good place to connect to/initialize a database, for example."""
self.inited = True
def run(self, host="0.0.0.0", port=80, lazy_init=False):
gc.collect()
self.init()
if not lazy_init:
for app in self.mounts:
app.init()
loop = asyncio.get_event_loop()
loop.create_task(asyncio.start_server(self._handle, host, port))
loop.run_forever()
loop.close()

View File

@@ -1,75 +0,0 @@
TIMEZONES = (
(0, -12, "(GMT-12:00) International Date Line West"),
(0, -11, "(GMT-11:00) Midway Island, Samoa"),
(0, -10, "(GMT-10:00) Hawaii"),
(1, -9, "(GMT-09:00) Alaska"),
(1, -8, "(GMT-08:00) Pacific Time (US & Canada)"),
(1, -8, "(GMT-08:00) Tijuana, Baja California"),
(0, -7, "(GMT-07:00) Arizona"),
(1, -7, "(GMT-07:00) Chihuahua, La Paz, Mazatlan"),
(1, -7, "(GMT-07:00) Mountain Time (US & Canada)"),
(0, -6, "(GMT-06:00) Central America"),
(1, -6, "(GMT-06:00) Central Time (US & Canada)"),
(1, -6, "(GMT-06:00) Guadalajara, Mexico City, Monterrey"),
(0, -6, "(GMT-06:00) Saskatchewan"),
(0, -5, "(GMT-05:00) Bogota, Lima, Quito, Rio Branco"),
(1, -5, "(GMT-05:00) Eastern Time (US & Canada)"),
(1, -5, "(GMT-05:00) Indiana (East)"),
(1, -4, "(GMT-04:00) Atlantic Time (Canada)"),
(0, -4, "(GMT-04:00) Caracas, La Paz"),
(0, -4, "(GMT-04:00) Manaus"),
(1, -4, "(GMT-04:00) Santiago"),
(1, -3, "(GMT-03:00) Brasilia"),
(0, -3, "(GMT-03:00) Buenos Aires, Georgetown"),
(1, -3, "(GMT-03:00) Greenland"),
(1, -3, "(GMT-03:00) Montevideo"),
(1, -2, "(GMT-02:00) Mid-Atlantic"),
(0, -1, "(GMT-01:00) Cape Verde Is."),
(1, -1, "(GMT-01:00) Azores"),
(0, 0, "(GMT+00:00) Casablanca, Monrovia, Reykjavik"),
(1, 0, "(GMT+00:00) Greenwich Mean Time : Dublin, Edinburgh, Lisbon, London"),
(1, 1, "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna"),
(1, 1, "(GMT+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague"),
(1, 1, "(GMT+01:00) Brussels, Copenhagen, Madrid, Paris"),
(1, 1, "(GMT+01:00) Sarajevo, Skopje, Warsaw, Zagreb"),
(1, 1, "(GMT+01:00) West Central Africa"),
(1, 2, "(GMT+02:00) Amman"),
(1, 2, "(GMT+02:00) Athens, Bucharest, Istanbul"),
(1, 2, "(GMT+02:00) Beirut"),
(1, 2, "(GMT+02:00) Cairo"),
(0, 2, "(GMT+02:00) Harare, Pretoria"),
(1, 2, "(GMT+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius"),
(1, 2, "(GMT+02:00) Jerusalem"),
(1, 2, "(GMT+02:00) Minsk"),
(1, 2, "(GMT+02:00) Windhoek"),
(0, 3, "(GMT+03:00) Kuwait, Riyadh, Baghdad"),
(1, 3, "(GMT+03:00) Moscow, St. Petersburg, Volgograd"),
(0, 3, "(GMT+03:00) Nairobi"),
(0, 3, "(GMT+03:00) Tbilisi"),
(0, 4, "(GMT+04:00) Abu Dhabi, Muscat"),
(1, 4, "(GMT+04:00) Baku"),
(1, 4, "(GMT+04:00) Yerevan"),
(1, 5, "(GMT+05:00) Yekaterinburg"),
(0, 5, "(GMT+05:00) Islamabad, Karachi, Tashkent"),
(1, 6, "(GMT+06:00) Almaty, Novosibirsk"),
(0, 6, "(GMT+06:00) Astana, Dhaka"),
(0, 7, "(GMT+07:00) Bangkok, Hanoi, Jakarta"),
(1, 7, "(GMT+07:00) Krasnoyarsk"),
(0, 8, "(GMT+08:00) Beijing, Chongqing, Hong Kong, Urumqi"),
(0, 8, "(GMT+08:00) Kuala Lumpur, Singapore"),
(0, 8, "(GMT+08:00) Irkutsk, Ulaan Bataar"),
(0, 8, "(GMT+08:00) Perth"),
(0, 8, "(GMT+08:00) Taipei"),
(0, 9, "(GMT+09:00) Osaka, Sapporo, Tokyo"),
(0, 9, "(GMT+09:00) Seoul"),
(1, 9, "(GMT+09:00) Yakutsk"),
(0, 10, "(GMT+10:00) Brisbane"),
(1, 10, "(GMT+10:00) Canberra, Melbourne, Sydney"),
(1, 10, "(GMT+10:00) Hobart"),
(0, 10, "(GMT+10:00) Guam, Port Moresby"),
(1, 10, "(GMT+10:00) Vladivostok"),
(1, 11, "(GMT+11:00) Magadan, Solomon Is., New Caledonia"),
(1, 12, "(GMT+12:00) Auckland, Wellington"),
(0, 12, "(GMT+12:00) Fiji, Kamchatka, Marshall Is."),
(0, 13, "(GMT+13:00) Nuku'alofa")
)

View File

@@ -1,4 +1,4 @@
PCB_THICKNESS = 1.6;
PCB_THICKNESS = 1.7;
EDGE_LENGTH = 54;
WRAPAROUND = 2;
@@ -9,17 +9,17 @@ module thingie() {
EDGE_LENGTH + 2 * WRAPAROUND,
EDGE_LENGTH + 2 * WRAPAROUND*2 + PCB_THICKNESS,
9]);
translate([EDGE_LENGTH+17+5+2, EDGE_LENGTH+5+6]) {
cylinder(10, EDGE_LENGTH+5, EDGE_LENGTH+5, $fn=100);
translate([EDGE_LENGTH+17+5+3, EDGE_LENGTH+5+5]) {
cylinder(10, EDGE_LENGTH+5, EDGE_LENGTH+5, $fn=500);
};
translate([-142,71]) {
cylinder(10, 155, 155, $fn=100);
translate([-141,73]) {
cylinder(10, 155, 155, $fn=500);
};
translate([WRAPAROUND, WRAPAROUND*3,1]) {
translate([13, PCB_THICKNESS,0]) {
translate([14, PCB_THICKNESS,0]) {
cube([PCB_THICKNESS,EDGE_LENGTH,20]);
}
cube([EDGE_LENGTH,PCB_THICKNESS,20]);