refactor: split monolithic .ino into modular structure + improve boot UX
## Code Structure - Split 2172-line .ino into 7 modules: - config.h - structs, enums, constants - globals.h - extern declarations, function prototypes - display.cpp - OLED display functions, Thanos dissolve effect - ntp_client.cpp - NTP sync, DST calculation - wifi_manager.cpp - WiFi connection management - weather.cpp - Open-Meteo API (async) - web_server.cpp - Web interface handlers - clock_ntp_ota_v1.9.ino - setup/loop, EEPROM, OTA (153 lines) ## Boot UX Improvements - Remove meaningless "READY" screen - Replace cryptic counter (0-7) with "WiFi..." + animated dots - Show SSID + IP on connected screen - Use yellow zone (Y 48-63) for status text - Fix text centering on all boot screens - Initialize lastBlinkTime/lastModeSwitch to prevent first-frame glitch ## Layout (dual-color display) - Blue zone (Y 0-47): main content - Yellow zone (Y 48-63): status/version - Centering formula: width = n×5 + (n-1)×1 for size 1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
co-authored by
Claude Opus 4.5
부모
c3bfaa26bc
커밋
4a7e889052
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Diff 로드
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* config.h - Configuration structures and constants
|
||||
* TJ-56-654 Weather Clock v1.9.2
|
||||
*/
|
||||
|
||||
#ifndef CONFIG_H
|
||||
#define CONFIG_H
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
// Firmware version
|
||||
#define FIRMWARE_VERSION "1.9.2"
|
||||
|
||||
// OLED I2C Configuration
|
||||
#define I2C_SDA 0 // GPIO0 (I2C Data) - SWAPPED!
|
||||
#define I2C_SCL 2 // GPIO2 (I2C Clock) - SWAPPED!
|
||||
#define SCREEN_WIDTH 128
|
||||
#define SCREEN_HEIGHT 64
|
||||
#define OLED_RESET -1 // No reset pin
|
||||
#define OLED_ADDRESS 0x3C
|
||||
|
||||
// Configuration structure with validation
|
||||
#define CONFIG_MAGIC 0xC10CC10C // Magic number to validate EEPROM data
|
||||
|
||||
struct Config {
|
||||
uint32_t magic = CONFIG_MAGIC; // Magic number for validation
|
||||
char ssid[32] = ""; // Empty - configured via WiFiManager captive portal
|
||||
char password[64] = ""; // Empty - configured via WiFiManager captive portal
|
||||
long timezone_offset = 0; // Base UTC offset in seconds (0=Lisbon/London, 3600=Paris/Berlin)
|
||||
bool dst_enabled = true; // Auto DST: +1 hour during summer (European rules: last Sun Mar-Oct)
|
||||
int brightness = 4; // 0-7
|
||||
char ntp_server[64] = "pool.ntp.org";
|
||||
unsigned long ntp_interval = 3600; // NTP update interval in seconds (default: 1 hour)
|
||||
bool hour_format_24 = true; // true=24h, false=12h
|
||||
char hostname[32] = "tj56654-clock";
|
||||
|
||||
// Weather settings
|
||||
float latitude = 37.19; // Portimao, Portugal
|
||||
float longitude = -8.54;
|
||||
char city_name[32] = "Portimao";
|
||||
bool weather_enabled = true;
|
||||
unsigned long weather_interval = 1800; // 30 minutes in seconds
|
||||
|
||||
// Display settings
|
||||
uint8_t display_rotation_sec = 5; // Seconds per screen
|
||||
bool show_weather = true;
|
||||
bool show_sunrise_sunset = true;
|
||||
uint8_t display_orientation = 2; // 0=0°, 1=90°, 2=180°, 3=270°
|
||||
};
|
||||
|
||||
// Exponential backoff retry configuration (for NTP/Weather)
|
||||
struct RetryConfig {
|
||||
uint8_t maxRetries = 3; // Give up after 3 tries
|
||||
uint8_t currentRetry = 0;
|
||||
unsigned long nextRetryTime = 0;
|
||||
unsigned long maxBackoffMs = 8000; // Max backoff 8 seconds
|
||||
|
||||
unsigned long getBackoffDelay() {
|
||||
unsigned long delay = 1000UL * (1UL << currentRetry); // 1s, 2s, 4s, 8s...
|
||||
return (delay > maxBackoffMs) ? maxBackoffMs : delay;
|
||||
}
|
||||
|
||||
void scheduleRetry() {
|
||||
if (currentRetry < maxRetries) {
|
||||
nextRetryTime = millis() + getBackoffDelay();
|
||||
currentRetry++;
|
||||
} else {
|
||||
nextRetryTime = 0; // Max retries reached, stop
|
||||
}
|
||||
}
|
||||
|
||||
bool isRetryTime() {
|
||||
return nextRetryTime > 0 && millis() >= nextRetryTime;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
currentRetry = 0;
|
||||
nextRetryTime = 0;
|
||||
}
|
||||
|
||||
bool maxRetriesReached() {
|
||||
return currentRetry >= maxRetries;
|
||||
}
|
||||
};
|
||||
|
||||
// WiFi retry configuration - infinite retries with longer backoff
|
||||
struct WiFiRetryConfig {
|
||||
uint8_t currentRetry = 0;
|
||||
unsigned long nextRetryTime = 0;
|
||||
static const unsigned long MAX_BACKOFF_MS = 300000; // Max 5 minutes between retries
|
||||
|
||||
unsigned long getBackoffDelay() {
|
||||
// 5s, 10s, 20s, 40s, 80s, 160s, 300s (max)
|
||||
unsigned long delay = 5000UL * (1UL << currentRetry);
|
||||
return (delay > MAX_BACKOFF_MS) ? MAX_BACKOFF_MS : delay;
|
||||
}
|
||||
|
||||
void scheduleRetry() {
|
||||
nextRetryTime = millis() + getBackoffDelay();
|
||||
if (currentRetry < 10) currentRetry++; // Cap at 10 to prevent overflow
|
||||
}
|
||||
|
||||
bool isRetryTime() {
|
||||
return nextRetryTime > 0 && millis() >= nextRetryTime;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
currentRetry = 0;
|
||||
nextRetryTime = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Weather fetch state machine
|
||||
enum WeatherState {
|
||||
WEATHER_IDLE,
|
||||
WEATHER_REQUESTING,
|
||||
WEATHER_SUCCESS,
|
||||
WEATHER_FAILED
|
||||
};
|
||||
|
||||
// Async NTP state machine
|
||||
enum NTPState {
|
||||
NTP_IDLE,
|
||||
NTP_REQUEST_SENT,
|
||||
NTP_WAITING,
|
||||
NTP_SUCCESS,
|
||||
NTP_FAILED
|
||||
};
|
||||
|
||||
// Async WiFi state machine
|
||||
enum WiFiConnectionState {
|
||||
WIFI_CONN_IDLE,
|
||||
WIFI_CONN_CONNECTING,
|
||||
WIFI_CONN_CONNECTED,
|
||||
WIFI_CONN_FAILED,
|
||||
WIFI_CONN_SKIP_ASYNC // Skip async, go straight to WiFiManager
|
||||
};
|
||||
|
||||
// Weather data cache
|
||||
struct WeatherData {
|
||||
float temperature = 0.0;
|
||||
int weathercode = -1; // WMO weather code
|
||||
int humidity = 0;
|
||||
float windspeed = 0.0;
|
||||
unsigned long lastUpdate = 0;
|
||||
bool valid = false;
|
||||
};
|
||||
|
||||
// Sunrise/Sunset cache
|
||||
struct SunTimes {
|
||||
int sunriseMinutes = 0; // Minutes since midnight
|
||||
int sunsetMinutes = 0;
|
||||
int lastDay = -1; // Day of year
|
||||
char sunrise[6] = "--:--"; // HH:MM format
|
||||
char sunset[6] = "--:--";
|
||||
};
|
||||
|
||||
// Dissolve transition constants
|
||||
const unsigned long DISSOLVE_DURATION = 2000; // 2 sec total (1s dissolve out + 1s dissolve in)
|
||||
const unsigned long DISSOLVE_FRAME_INTERVAL = 100; // 100ms per frame
|
||||
|
||||
// NTP timeout
|
||||
const unsigned long NTP_TIMEOUT_MS = 5000; // 5 second timeout
|
||||
|
||||
// WiFi timeout
|
||||
const unsigned long WIFI_TIMEOUT_MS = 10000; // 10 second timeout
|
||||
|
||||
#endif // CONFIG_H
|
||||
@@ -0,0 +1,476 @@
|
||||
/*
|
||||
* display.cpp - Display functions for OLED
|
||||
* TJ-56-654 Weather Clock v1.9.2
|
||||
*/
|
||||
|
||||
#include "globals.h"
|
||||
|
||||
// Update main time display
|
||||
void ICACHE_FLASH_ATTR updateDisplay() {
|
||||
static unsigned long lastUpdate = 0;
|
||||
|
||||
// Update display only every 500ms (not every loop cycle)
|
||||
// BUT skip throttle during transition for smooth animation
|
||||
if (!inTransition && millis() - lastUpdate < 500) return;
|
||||
lastUpdate = millis();
|
||||
|
||||
display.clearDisplay();
|
||||
|
||||
// Check if we have ANY time source (NTPClient or async sync)
|
||||
bool hasTime = timeClient.isTimeSet() || timeIsSynced;
|
||||
|
||||
if (!hasTime) {
|
||||
display.setTextSize(3);
|
||||
display.setTextColor(SSD1306_WHITE);
|
||||
display.setCursor(20, 24);
|
||||
display.println("--:--");
|
||||
|
||||
// Show WiFi status in yellow zone
|
||||
display.setTextSize(1);
|
||||
display.setCursor(25, 52);
|
||||
if (wifiConnState == WIFI_CONN_CONNECTED) {
|
||||
display.print("Syncing NTP...");
|
||||
} else {
|
||||
display.print("No WiFi");
|
||||
}
|
||||
if (!inTransition) {
|
||||
display.display();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get epoch from best available source
|
||||
unsigned long epochTime = timeIsSynced ? getAsyncEpoch() : timeClient.getEpochTime();
|
||||
unsigned long localTime = epochTime + getTotalOffset(epochTime);
|
||||
|
||||
int hours = (localTime / 3600) % 24;
|
||||
int minutes = (localTime / 60) % 60;
|
||||
|
||||
// Convert to 12h format if needed
|
||||
if (!config.hour_format_24) {
|
||||
if (hours == 0) hours = 12;
|
||||
else if (hours > 12) hours -= 12;
|
||||
}
|
||||
|
||||
// === YELLOW ZONE (Y: 48-63): Date (size 2 = 16px height) ===
|
||||
display.setTextSize(2);
|
||||
time_t t = epochTime;
|
||||
struct tm *ptm = gmtime(&t);
|
||||
|
||||
// Format: "Thu 02.01" or "! Thu 02.01" if no WiFi
|
||||
const char* days[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};
|
||||
char dateStr[20];
|
||||
|
||||
if (wifiConnState != WIFI_CONN_CONNECTED) {
|
||||
// Show "!" indicator when WiFi is down
|
||||
sprintf(dateStr, "!%s %02d.%02d", days[ptm->tm_wday], ptm->tm_mday, ptm->tm_mon + 1);
|
||||
} else {
|
||||
sprintf(dateStr, "%s %02d.%02d", days[ptm->tm_wday], ptm->tm_mday, ptm->tm_mon + 1);
|
||||
}
|
||||
|
||||
// Center in yellow zone (Y: 48-63)
|
||||
int dateWidth = strlen(dateStr) * 12; // Size 2 = ~12px per char
|
||||
int dateX = (128 - dateWidth) / 2;
|
||||
display.setCursor(dateX, 48);
|
||||
display.print(dateStr);
|
||||
|
||||
// === BLUE ZONE (Y: 0-47): Large time (size 3 = 24px height) ===
|
||||
display.setTextSize(3);
|
||||
display.setTextColor(SSD1306_WHITE);
|
||||
|
||||
// Calculate center position for HH:MM
|
||||
display.setCursor(10, 12); // Y=12 centers in blue zone (0-47)
|
||||
display.printf("%02d", hours);
|
||||
|
||||
// Blinking colon
|
||||
if (colonBlink) {
|
||||
display.print(":");
|
||||
} else {
|
||||
display.print(" ");
|
||||
}
|
||||
|
||||
display.printf("%02d", minutes);
|
||||
|
||||
// Don't send to screen during transition - crossfade will do it
|
||||
if (!inTransition) {
|
||||
display.display();
|
||||
}
|
||||
}
|
||||
|
||||
// Weather display
|
||||
void ICACHE_FLASH_ATTR displayWeather() {
|
||||
display.clearDisplay();
|
||||
|
||||
if (!weather.valid) {
|
||||
display.setTextSize(3);
|
||||
display.setTextColor(SSD1306_WHITE);
|
||||
display.setCursor(20, 24);
|
||||
display.println("No Data");
|
||||
if (!inTransition) {
|
||||
display.display();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// === YELLOW ZONE (Y: 48-63): City name (size 2 = 16px) ===
|
||||
display.setTextSize(2);
|
||||
int cityWidth = strlen(config.city_name) * 12;
|
||||
int cityX = (128 - cityWidth) / 2;
|
||||
display.setCursor(cityX, 48);
|
||||
display.print(config.city_name);
|
||||
|
||||
// === BLUE ZONE (Y: 0-47): Temperature (size 3 = 24px height) ===
|
||||
display.setTextSize(3);
|
||||
display.setTextColor(SSD1306_WHITE);
|
||||
|
||||
// Format temperature value only
|
||||
char tempStr[16];
|
||||
sprintf(tempStr, "%.1f", weather.temperature);
|
||||
|
||||
// Center temperature + degree symbol
|
||||
int tempValueWidth = strlen(tempStr) * 18; // Size 3 = ~18px per char
|
||||
int degreeSymbolWidth = 6; // Size 1 = 6px per char
|
||||
int totalWidth = tempValueWidth + degreeSymbolWidth + 6;
|
||||
int startX = (128 - totalWidth) / 2;
|
||||
|
||||
// Print temperature value
|
||||
display.setCursor(startX, 12);
|
||||
display.print(tempStr);
|
||||
|
||||
// Print degree symbol and C (small, raised)
|
||||
display.setTextSize(1);
|
||||
int degreeX = startX + tempValueWidth;
|
||||
display.setCursor(degreeX, 12);
|
||||
display.print("\xF8" "c"); // °c
|
||||
|
||||
if (!inTransition) {
|
||||
display.display();
|
||||
}
|
||||
}
|
||||
|
||||
// Sunrise/Sunset display
|
||||
void ICACHE_FLASH_ATTR displaySunTimes() {
|
||||
display.clearDisplay();
|
||||
|
||||
if (sunTimes.lastDay == -1) {
|
||||
display.setTextSize(3);
|
||||
display.setTextColor(SSD1306_WHITE);
|
||||
display.setCursor(30, 24);
|
||||
display.println("----");
|
||||
if (!inTransition) {
|
||||
display.display();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// === YELLOW ZONE (Y: 48-63): Daylight duration ===
|
||||
int daylightMinutes = sunTimes.sunsetMinutes - sunTimes.sunriseMinutes;
|
||||
int daylightHours = daylightMinutes / 60;
|
||||
int daylightMins = daylightMinutes % 60;
|
||||
|
||||
char daylightStr[16];
|
||||
sprintf(daylightStr, "Day %dh %dm", daylightHours, daylightMins);
|
||||
|
||||
display.setTextSize(1);
|
||||
int textWidth = strlen(daylightStr) * 6;
|
||||
int textX = (128 - textWidth) / 2;
|
||||
display.setCursor(textX, 52);
|
||||
display.print(daylightStr);
|
||||
|
||||
// === BLUE ZONE (Y: 0-47): Sunrise and Sunset times ===
|
||||
display.setTextSize(2);
|
||||
display.setTextColor(SSD1306_WHITE);
|
||||
|
||||
// Line 1: Sunrise
|
||||
display.setCursor(5, 4);
|
||||
display.print("\x18 "); // Up arrow
|
||||
display.print(sunTimes.sunrise);
|
||||
|
||||
// Line 2: Sunset
|
||||
display.setCursor(5, 28);
|
||||
display.print("\x19 "); // Down arrow
|
||||
display.print(sunTimes.sunset);
|
||||
|
||||
if (!inTransition) {
|
||||
display.display();
|
||||
}
|
||||
}
|
||||
|
||||
// Apply dissolve effect with optional drift (Thanos-style)
|
||||
void ICACHE_FLASH_ATTR applyDissolveEffect(uint8_t hidePercent, bool withDrift) {
|
||||
// Apply drift effect - shift buffer to the right
|
||||
if (withDrift && hidePercent > 10) {
|
||||
uint8_t* buffer = display.getBuffer();
|
||||
// SSD1306 buffer: 8 pages (8 rows each), 128 bytes per page
|
||||
for (int page = 0; page < 8; page++) {
|
||||
int pageOffset = page * SCREEN_WIDTH;
|
||||
// Shift right by 2 pixels per frame
|
||||
for (int x = SCREEN_WIDTH - 1; x > 1; x--) {
|
||||
buffer[pageOffset + x] = buffer[pageOffset + x - 2];
|
||||
}
|
||||
buffer[pageOffset] = 0;
|
||||
buffer[pageOffset + 1] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Multiply by 4 to compensate for random overlaps
|
||||
uint32_t pixelsToHide = ((uint32_t)SCREEN_WIDTH * SCREEN_HEIGHT * hidePercent * 4) / 100;
|
||||
|
||||
for (uint32_t i = 0; i < pixelsToHide; i++) {
|
||||
uint8_t x = random(SCREEN_WIDTH);
|
||||
uint8_t y = random(SCREEN_HEIGHT);
|
||||
display.drawPixel(x, y, SSD1306_BLACK);
|
||||
}
|
||||
|
||||
display.display();
|
||||
}
|
||||
|
||||
// Display rotation with dissolve transition
|
||||
void ICACHE_FLASH_ATTR updateDisplayRotation() {
|
||||
unsigned long now = millis();
|
||||
unsigned long interval = config.display_rotation_sec * 1000UL;
|
||||
|
||||
// Handle active dissolve transition (two phases)
|
||||
if (inTransition) {
|
||||
unsigned long elapsed = now - transitionStart;
|
||||
|
||||
if (elapsed >= DISSOLVE_DURATION) {
|
||||
displayMode = nextDisplayMode;
|
||||
inTransition = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (now - lastDissolveFrame < DISSOLVE_FRAME_INTERVAL) {
|
||||
return;
|
||||
}
|
||||
lastDissolveFrame = now;
|
||||
|
||||
uint8_t currentMode;
|
||||
uint8_t hidePercent;
|
||||
unsigned long halfDuration = DISSOLVE_DURATION / 2;
|
||||
bool isDriftPhase;
|
||||
|
||||
if (elapsed < halfDuration) {
|
||||
// Phase 1: dissolve OUT old content
|
||||
currentMode = displayMode;
|
||||
hidePercent = (elapsed * 100) / halfDuration;
|
||||
isDriftPhase = true;
|
||||
} else {
|
||||
// Phase 2: dissolve IN new content
|
||||
currentMode = nextDisplayMode;
|
||||
unsigned long phase2Elapsed = elapsed - halfDuration;
|
||||
hidePercent = 100 - (phase2Elapsed * 100) / halfDuration;
|
||||
isDriftPhase = false;
|
||||
}
|
||||
|
||||
switch(currentMode) {
|
||||
case 0: updateDisplay(); break;
|
||||
case 1: displayWeather(); break;
|
||||
case 2: displaySunTimes(); break;
|
||||
}
|
||||
|
||||
applyDissolveEffect(hidePercent, isDriftPhase);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if time to switch modes
|
||||
if (now - lastModeSwitch > interval) {
|
||||
uint8_t attempts = 0;
|
||||
nextDisplayMode = displayMode;
|
||||
do {
|
||||
nextDisplayMode = (nextDisplayMode + 1) % 3;
|
||||
attempts++;
|
||||
if (attempts >= 3) {
|
||||
nextDisplayMode = 0;
|
||||
Serial.println("WARNING: No display mode enabled, forcing time mode");
|
||||
break;
|
||||
}
|
||||
} while (!isModeEnabled(nextDisplayMode));
|
||||
|
||||
inTransition = true;
|
||||
transitionStart = now;
|
||||
lastModeSwitch = now;
|
||||
lastDissolveFrame = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal display update
|
||||
switch(displayMode) {
|
||||
case 0: updateDisplay(); break;
|
||||
case 1: displayWeather(); break;
|
||||
case 2: displaySunTimes(); break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if display mode is enabled
|
||||
bool ICACHE_FLASH_ATTR isModeEnabled(uint8_t mode) {
|
||||
switch(mode) {
|
||||
case 0: return true; // Time always enabled
|
||||
case 1: return config.show_weather && weather.valid;
|
||||
case 2: return config.show_sunrise_sunset && sunTimes.lastDay != -1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clear display
|
||||
void ICACHE_FLASH_ATTR clearDisplay() {
|
||||
display.clearDisplay();
|
||||
display.display();
|
||||
}
|
||||
|
||||
// Show number on OLED
|
||||
void ICACHE_FLASH_ATTR showNumber(int num, bool leadingZeros) {
|
||||
display.clearDisplay();
|
||||
display.setTextSize(3);
|
||||
display.setTextColor(SSD1306_WHITE);
|
||||
display.setCursor(20, 20);
|
||||
|
||||
if (leadingZeros) {
|
||||
display.printf("%04d", num);
|
||||
} else {
|
||||
display.print(num);
|
||||
}
|
||||
|
||||
display.display();
|
||||
}
|
||||
|
||||
// Show "No WiFi" status
|
||||
void ICACHE_FLASH_ATTR showNoWiFi(unsigned long nextRetrySeconds) {
|
||||
display.clearDisplay();
|
||||
|
||||
display.setTextSize(2);
|
||||
display.setTextColor(SSD1306_WHITE);
|
||||
display.setCursor(20, 8);
|
||||
display.println("No WiFi");
|
||||
|
||||
display.setTextSize(1);
|
||||
display.setCursor(10, 52);
|
||||
if (nextRetrySeconds < 60) {
|
||||
display.printf("Retry in %lu sec", nextRetrySeconds);
|
||||
} else {
|
||||
display.printf("Retry in %lu min", nextRetrySeconds / 60);
|
||||
}
|
||||
|
||||
display.display();
|
||||
}
|
||||
|
||||
// Startup animation - just logo and version
|
||||
// Layout: Blue zone (Y 0-47), Yellow zone (Y 48-63)
|
||||
void ICACHE_FLASH_ATTR showStartupAnimation() {
|
||||
Serial.println(" Boot: Show logo");
|
||||
|
||||
display.clearDisplay();
|
||||
display.setTextColor(SSD1306_WHITE);
|
||||
|
||||
// "TJ-56" centered in BLUE zone
|
||||
// 5 chars × 10px + 4 gaps × 2px = 58px → X = (128-58)/2 = 35
|
||||
display.setTextSize(2);
|
||||
display.setCursor(35, 10);
|
||||
display.print("TJ-56");
|
||||
|
||||
// "Weather Clock" centered in BLUE zone
|
||||
// 13 chars × 5px + 12 gaps × 1px = 77px → X = (128-77)/2 = 26
|
||||
display.setTextSize(1);
|
||||
display.setCursor(26, 30);
|
||||
display.print("Weather Clock");
|
||||
|
||||
// Version in YELLOW zone (centered)
|
||||
// 6 chars × 5px + 5 gaps × 1px = 35px → X = (128-35)/2 = 47
|
||||
display.setTextSize(1);
|
||||
display.setCursor(47, 52);
|
||||
display.print("v");
|
||||
display.print(FIRMWARE_VERSION);
|
||||
|
||||
display.display();
|
||||
delay(1500);
|
||||
|
||||
Serial.println(" Boot: Logo done");
|
||||
}
|
||||
|
||||
// Show WiFi connecting animation with dots
|
||||
// Layout: Blue zone (Y 0-47), Yellow zone (Y 48-63)
|
||||
void ICACHE_FLASH_ATTR showWiFiConnecting(int step) {
|
||||
display.clearDisplay();
|
||||
display.setTextColor(SSD1306_WHITE);
|
||||
|
||||
// "WiFi..." in BLUE zone (centered)
|
||||
// 7 chars × 10px + 6 gaps × 2px = 82px → X = 23
|
||||
display.setTextSize(2);
|
||||
display.setCursor(23, 10);
|
||||
display.print("WiFi...");
|
||||
|
||||
// Animated dots in BLUE zone (centered)
|
||||
// "* * * * * * " = 12 chars × 5px + 11 gaps × 1px = 71px → X = 29
|
||||
display.setTextSize(1);
|
||||
display.setCursor(29, 32);
|
||||
|
||||
int dots = (step % 6) + 1;
|
||||
for (int i = 0; i < 6; i++) {
|
||||
if (i < dots) {
|
||||
display.print("* ");
|
||||
} else {
|
||||
display.print(" ");
|
||||
}
|
||||
}
|
||||
|
||||
// "Connecting" in YELLOW zone (centered)
|
||||
// 10 chars × 5px + 9 gaps × 1px = 59px → X = 35
|
||||
display.setTextSize(1);
|
||||
display.setCursor(35, 52);
|
||||
display.print("Connecting");
|
||||
|
||||
display.display();
|
||||
}
|
||||
|
||||
// Show connected status with SSID and IP
|
||||
// Layout: Blue zone (Y 0-47), Yellow zone (Y 48-63)
|
||||
void ICACHE_FLASH_ATTR showConnected() {
|
||||
display.clearDisplay();
|
||||
display.setTextColor(SSD1306_WHITE);
|
||||
|
||||
// SSID in BLUE zone (centered)
|
||||
// Formula: n chars × 5px + (n-1) gaps × 1px
|
||||
display.setTextSize(1);
|
||||
String ssid = WiFi.SSID();
|
||||
int ssidLen = ssid.length();
|
||||
int ssidWidth = ssidLen * 5 + (ssidLen - 1) * 1;
|
||||
int ssidX = (128 - ssidWidth) / 2;
|
||||
display.setCursor(ssidX, 8);
|
||||
display.print(ssid);
|
||||
|
||||
// IP address in BLUE zone
|
||||
IPAddress ip = WiFi.localIP();
|
||||
char ipStr[16];
|
||||
sprintf(ipStr, "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
|
||||
int ipLen = strlen(ipStr);
|
||||
|
||||
// Try size 2 first: n chars × 10px + (n-1) gaps × 2px
|
||||
int ipWidth2 = ipLen * 10 + (ipLen - 1) * 2;
|
||||
|
||||
if (ipWidth2 <= 128) {
|
||||
display.setTextSize(2);
|
||||
int ipX = (128 - ipWidth2) / 2;
|
||||
display.setCursor(ipX, 24);
|
||||
} else {
|
||||
// Fallback to size 1: n chars × 5px + (n-1) gaps × 1px
|
||||
display.setTextSize(1);
|
||||
int ipWidth1 = ipLen * 5 + (ipLen - 1) * 1;
|
||||
int ipX = (128 - ipWidth1) / 2;
|
||||
display.setCursor(ipX, 24);
|
||||
}
|
||||
display.print(ipStr);
|
||||
|
||||
// "OK" in YELLOW zone (centered)
|
||||
// 2 chars × 5px + 1 gap × 1px = 11px → X = 59
|
||||
display.setTextSize(1);
|
||||
display.setCursor(59, 52);
|
||||
display.print("OK");
|
||||
|
||||
display.display();
|
||||
delay(2000);
|
||||
}
|
||||
|
||||
// Show IP address (legacy, for reconnection)
|
||||
void ICACHE_FLASH_ATTR showIP() {
|
||||
showConnected(); // Use new unified function
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* globals.h - Global variables and extern declarations
|
||||
* TJ-56-654 Weather Clock v1.9.2
|
||||
*/
|
||||
|
||||
#ifndef GLOBALS_H
|
||||
#define GLOBALS_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <ESP8266WiFi.h>
|
||||
#include <ESP8266WebServer.h>
|
||||
#include <ESP8266HTTPUpdateServer.h>
|
||||
#include <WiFiUdp.h>
|
||||
#include <NTPClient.h>
|
||||
#include <Adafruit_SSD1306.h>
|
||||
#include "config.h"
|
||||
|
||||
// Configuration
|
||||
extern Config config;
|
||||
|
||||
// OLED Display
|
||||
extern Adafruit_SSD1306 display;
|
||||
|
||||
// NTP Client
|
||||
extern WiFiUDP ntpUDP;
|
||||
extern NTPClient timeClient;
|
||||
|
||||
// Web server
|
||||
extern ESP8266WebServer server;
|
||||
extern ESP8266HTTPUpdateServer httpUpdater;
|
||||
|
||||
// State machines
|
||||
extern WeatherState weatherState;
|
||||
extern NTPState ntpState;
|
||||
extern WiFiConnectionState wifiConnState;
|
||||
|
||||
// Retry configurations
|
||||
extern RetryConfig ntpRetry;
|
||||
extern RetryConfig weatherRetry;
|
||||
extern WiFiRetryConfig wifiRetry;
|
||||
|
||||
// NTP packet buffer and timing
|
||||
extern byte ntpPacketBuffer[48];
|
||||
extern unsigned long ntpRequestTime;
|
||||
|
||||
// Independent epoch tracking for async NTP
|
||||
extern unsigned long syncedEpoch;
|
||||
extern unsigned long syncedMillis;
|
||||
extern bool timeIsSynced;
|
||||
|
||||
// WiFi connection timing
|
||||
extern unsigned long wifiConnectStart;
|
||||
|
||||
// Display state
|
||||
extern bool colonBlink;
|
||||
extern unsigned long lastBlinkTime;
|
||||
extern unsigned long lastNTPUpdate;
|
||||
extern unsigned long ipDisplayUntil;
|
||||
|
||||
// Debug variables
|
||||
extern String lastError;
|
||||
extern int ntpAttempts;
|
||||
extern int ntpSuccesses;
|
||||
extern bool internetConnected;
|
||||
|
||||
// Weather and sun data
|
||||
extern WeatherData weather;
|
||||
extern SunTimes sunTimes;
|
||||
|
||||
// Display rotation state
|
||||
extern uint8_t displayMode;
|
||||
extern unsigned long lastModeSwitch;
|
||||
extern unsigned long lastWeatherUpdate;
|
||||
|
||||
// Dissolve transition state
|
||||
extern bool inTransition;
|
||||
extern unsigned long transitionStart;
|
||||
extern unsigned long lastDissolveFrame;
|
||||
extern uint8_t nextDisplayMode;
|
||||
|
||||
// ============ Function declarations ============
|
||||
|
||||
// Helper functions
|
||||
void ICACHE_FLASH_ATTR safeStringCopy(const String& src, char* dest, size_t maxLen);
|
||||
|
||||
// Time functions (ntp_client.cpp)
|
||||
bool isDST(unsigned long epochTime);
|
||||
long getTotalOffset(unsigned long epochTime);
|
||||
unsigned long getAsyncEpoch();
|
||||
void sendNTPRequestAsync();
|
||||
void processNTPResponse();
|
||||
void ICACHE_FLASH_ATTR updateNTPTime();
|
||||
void ICACHE_FLASH_ATTR testInternetConnectivity();
|
||||
|
||||
// WiFi functions (wifi_manager.cpp)
|
||||
void ICACHE_FLASH_ATTR setupWiFi();
|
||||
void processWiFiConnection();
|
||||
|
||||
// Display functions (display.cpp)
|
||||
void ICACHE_FLASH_ATTR clearDisplay();
|
||||
void ICACHE_FLASH_ATTR showNumber(int num, bool leadingZeros);
|
||||
void ICACHE_FLASH_ATTR showNoWiFi(unsigned long nextRetrySeconds);
|
||||
void ICACHE_FLASH_ATTR showStartupAnimation();
|
||||
void ICACHE_FLASH_ATTR showWiFiConnecting(int step);
|
||||
void ICACHE_FLASH_ATTR showConnected();
|
||||
void ICACHE_FLASH_ATTR showIP();
|
||||
void updateDisplay();
|
||||
void ICACHE_FLASH_ATTR displayWeather();
|
||||
void ICACHE_FLASH_ATTR displaySunTimes();
|
||||
void ICACHE_FLASH_ATTR applyDissolveEffect(uint8_t hidePercent, bool withDrift);
|
||||
void ICACHE_FLASH_ATTR updateDisplayRotation();
|
||||
bool ICACHE_FLASH_ATTR isModeEnabled(uint8_t mode);
|
||||
|
||||
// Weather functions (weather.cpp)
|
||||
void fetchWeatherAsync();
|
||||
void calculateSunTimes();
|
||||
|
||||
// Web server functions (web_server.cpp)
|
||||
void ICACHE_FLASH_ATTR setupWebServer();
|
||||
void ICACHE_FLASH_ATTR handleRoot();
|
||||
void ICACHE_FLASH_ATTR handleDebug();
|
||||
void ICACHE_FLASH_ATTR handleTestNTP();
|
||||
void ICACHE_FLASH_ATTR handleTestDisplay();
|
||||
void ICACHE_FLASH_ATTR handleConfig();
|
||||
void ICACHE_FLASH_ATTR handleConfigSave();
|
||||
void ICACHE_FLASH_ATTR handleAPITime();
|
||||
void ICACHE_FLASH_ATTR handleAPIStatus();
|
||||
void ICACHE_FLASH_ATTR handleAPIDebug();
|
||||
void ICACHE_FLASH_ATTR handleAPIWeather();
|
||||
void ICACHE_FLASH_ATTR handleAPIConfigExport();
|
||||
void ICACHE_FLASH_ATTR handleAPIConfigImport();
|
||||
void ICACHE_FLASH_ATTR handleEEPROMClear();
|
||||
void ICACHE_FLASH_ATTR handleReboot();
|
||||
void ICACHE_FLASH_ATTR handleI2CScan();
|
||||
|
||||
// Config functions (in main .ino)
|
||||
void ICACHE_FLASH_ATTR loadConfig();
|
||||
void ICACHE_FLASH_ATTR saveConfig();
|
||||
|
||||
// OTA functions (in main .ino)
|
||||
void ICACHE_FLASH_ATTR setupOTA();
|
||||
|
||||
#endif // GLOBALS_H
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* ntp_client.cpp - NTP client and time functions
|
||||
* TJ-56-654 Weather Clock v1.9.2
|
||||
*/
|
||||
|
||||
#include "globals.h"
|
||||
|
||||
// DST calculation for European rules
|
||||
// DST starts: last Sunday of March at 01:00 UTC
|
||||
// DST ends: last Sunday of October at 01:00 UTC
|
||||
bool ICACHE_FLASH_ATTR isDST(unsigned long epochTime) {
|
||||
if (!config.dst_enabled) return false;
|
||||
|
||||
time_t t = epochTime;
|
||||
struct tm *timeinfo = gmtime(&t);
|
||||
|
||||
int month = timeinfo->tm_mon + 1; // 1-12
|
||||
int day = timeinfo->tm_mday; // 1-31
|
||||
int weekday = timeinfo->tm_wday; // 0=Sunday
|
||||
int hour = timeinfo->tm_hour;
|
||||
|
||||
// Not DST: November - February
|
||||
if (month < 3 || month > 10) return false;
|
||||
|
||||
// Always DST: April - September
|
||||
if (month > 3 && month < 10) return true;
|
||||
|
||||
// March: DST starts last Sunday at 01:00 UTC
|
||||
if (month == 3) {
|
||||
int lastSunday = 31 - ((5 + timeinfo->tm_year) % 7);
|
||||
if (day < lastSunday) return false;
|
||||
if (day > lastSunday) return true;
|
||||
if (hour < 1) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// October: DST ends last Sunday at 01:00 UTC
|
||||
if (month == 10) {
|
||||
int lastSunday = 31 - ((1 + timeinfo->tm_year) % 7);
|
||||
if (day < lastSunday) return true;
|
||||
if (day > lastSunday) return false;
|
||||
if (hour < 1) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get total timezone offset including DST
|
||||
long ICACHE_FLASH_ATTR getTotalOffset(unsigned long epochTime) {
|
||||
long offset = config.timezone_offset;
|
||||
if (isDST(epochTime)) {
|
||||
offset += 3600; // Add 1 hour for DST
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
// Get current epoch (async NTP independent tracking)
|
||||
unsigned long ICACHE_FLASH_ATTR getAsyncEpoch() {
|
||||
if (!timeIsSynced) return timeClient.getEpochTime();
|
||||
unsigned long elapsed = (millis() - syncedMillis) / 1000;
|
||||
return syncedEpoch + elapsed;
|
||||
}
|
||||
|
||||
// Test internet connectivity
|
||||
void ICACHE_FLASH_ATTR testInternetConnectivity() {
|
||||
Serial.println("\n=== Testing Internet Connectivity ===");
|
||||
|
||||
IPAddress ntpIP;
|
||||
if (WiFi.hostByName(config.ntp_server, ntpIP)) {
|
||||
Serial.print("DNS works: ");
|
||||
Serial.print(config.ntp_server);
|
||||
Serial.print(" -> ");
|
||||
Serial.println(ntpIP);
|
||||
} else {
|
||||
Serial.print("DNS failed: cannot resolve ");
|
||||
Serial.println(config.ntp_server);
|
||||
lastError = "DNS resolution failed";
|
||||
return;
|
||||
}
|
||||
|
||||
if (WiFi.hostByName("google.com", ntpIP)) {
|
||||
Serial.println("Can resolve google.com");
|
||||
internetConnected = true;
|
||||
} else {
|
||||
Serial.println("Cannot resolve google.com - no internet?");
|
||||
lastError = "No internet connectivity";
|
||||
internetConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Update NTP time (blocking)
|
||||
void ICACHE_FLASH_ATTR updateNTPTime() {
|
||||
Serial.println("\n=== Updating NTP Time ===");
|
||||
ntpAttempts++;
|
||||
|
||||
if (!internetConnected) {
|
||||
Serial.println("Skipping NTP update - no internet");
|
||||
lastError = "No internet connection";
|
||||
return;
|
||||
}
|
||||
|
||||
bool success = timeClient.update();
|
||||
|
||||
if (success) {
|
||||
ntpSuccesses++;
|
||||
Serial.print("NTP sync successful: ");
|
||||
Serial.println(timeClient.getFormattedTime());
|
||||
lastError = "";
|
||||
} else {
|
||||
Serial.println("NTP sync failed");
|
||||
lastError = "NTP sync failed (timeout or no response)";
|
||||
|
||||
Serial.println(" Trying force update...");
|
||||
if (timeClient.forceUpdate()) {
|
||||
ntpSuccesses++;
|
||||
Serial.print("Force update successful: ");
|
||||
Serial.println(timeClient.getFormattedTime());
|
||||
lastError = "";
|
||||
} else {
|
||||
Serial.println("Force update also failed");
|
||||
testInternetConnectivity();
|
||||
}
|
||||
}
|
||||
|
||||
Serial.print("NTP Stats: ");
|
||||
Serial.print(ntpSuccesses);
|
||||
Serial.print(" / ");
|
||||
Serial.print(ntpAttempts);
|
||||
Serial.println(" successful");
|
||||
}
|
||||
|
||||
// Async NTP - Send request (non-blocking)
|
||||
void ICACHE_FLASH_ATTR sendNTPRequestAsync() {
|
||||
if (ntpState != NTP_IDLE) return;
|
||||
|
||||
if (!internetConnected) {
|
||||
Serial.println(F("Skip NTP - no internet"));
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.println(F("NTP request (async)..."));
|
||||
ntpAttempts++;
|
||||
|
||||
memset(ntpPacketBuffer, 0, 48);
|
||||
ntpPacketBuffer[0] = 0b11100011;
|
||||
ntpPacketBuffer[1] = 0;
|
||||
ntpPacketBuffer[2] = 6;
|
||||
ntpPacketBuffer[3] = 0xEC;
|
||||
|
||||
ntpUDP.beginPacket(config.ntp_server, 123);
|
||||
ntpUDP.write(ntpPacketBuffer, 48);
|
||||
ntpUDP.endPacket();
|
||||
|
||||
ntpState = NTP_REQUEST_SENT;
|
||||
ntpRequestTime = millis();
|
||||
Serial.println("NTP sent (non-blocking)");
|
||||
}
|
||||
|
||||
// Async NTP - Process response (call in loop)
|
||||
void ICACHE_FLASH_ATTR processNTPResponse() {
|
||||
if (ntpState == NTP_IDLE) return;
|
||||
|
||||
// Timeout check with exponential backoff retry
|
||||
if (millis() - ntpRequestTime > NTP_TIMEOUT_MS) {
|
||||
ntpState = NTP_IDLE;
|
||||
Serial.printf("NTP timeout (attempt %d/%d)\n", ntpRetry.currentRetry + 1, ntpRetry.maxRetries);
|
||||
|
||||
ntpRetry.scheduleRetry();
|
||||
if (ntpRetry.maxRetriesReached()) {
|
||||
Serial.println("NTP max retries reached, will try again later");
|
||||
lastError = "NTP timeout - max retries";
|
||||
} else {
|
||||
unsigned long backoff = ntpRetry.getBackoffDelay() / 1000;
|
||||
Serial.printf(" Retry scheduled in %lu seconds\n", backoff);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for response packet
|
||||
if (ntpUDP.parsePacket() >= 48) {
|
||||
ntpUDP.read(ntpPacketBuffer, 48);
|
||||
|
||||
unsigned long high = word(ntpPacketBuffer[40], ntpPacketBuffer[41]);
|
||||
unsigned long low = word(ntpPacketBuffer[42], ntpPacketBuffer[43]);
|
||||
unsigned long epoch = (high << 16 | low) - 2208988800UL;
|
||||
|
||||
syncedEpoch = epoch;
|
||||
syncedMillis = millis();
|
||||
timeIsSynced = true;
|
||||
|
||||
timeClient = NTPClient(ntpUDP, config.ntp_server, 0, config.ntp_interval * 1000);
|
||||
timeClient.begin();
|
||||
timeClient.update();
|
||||
|
||||
ntpState = NTP_IDLE;
|
||||
ntpSuccesses++;
|
||||
ntpRetry.reset();
|
||||
lastError = "";
|
||||
|
||||
unsigned long h = (epoch % 86400L) / 3600;
|
||||
unsigned long m = (epoch % 3600) / 60;
|
||||
Serial.printf("NTP synced (async): %02lu:%02lu UTC\n", h, m);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* weather.cpp - Weather API functions
|
||||
* TJ-56-654 Weather Clock v1.9.2
|
||||
*/
|
||||
|
||||
// Include AsyncHTTPRequest BEFORE globals.h to provide full type definition
|
||||
#include <ESPAsyncTCP.h>
|
||||
#define ASYNCHTTPREQUEST_GENERIC_VERSION_MIN_TARGET "AsyncHTTPRequest_Generic v1.13.0"
|
||||
#define ASYNCHTTPREQUEST_GENERIC_VERSION_MIN 1013000
|
||||
#include <AsyncHTTPRequest_Generic.h>
|
||||
|
||||
#include "globals.h"
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
// Async HTTP client for weather (local to this file)
|
||||
static AsyncHTTPRequest weatherRequest;
|
||||
|
||||
// Weather response callback
|
||||
void ICACHE_FLASH_ATTR onWeatherResponse(void* optParm, AsyncHTTPRequest* request, int readyState) {
|
||||
(void)optParm; // Unused
|
||||
|
||||
if (readyState == 4) { // Request complete
|
||||
weatherState = WEATHER_IDLE;
|
||||
|
||||
int httpCode = request->responseHTTPcode();
|
||||
if (httpCode == 200) {
|
||||
String payload = request->responseText();
|
||||
Serial.printf("Weather response: %d bytes\n", payload.length());
|
||||
|
||||
// Parse JSON response
|
||||
StaticJsonDocument<1536> doc;
|
||||
DeserializationError error = deserializeJson(doc, payload);
|
||||
|
||||
if (!error) {
|
||||
// Extract current weather
|
||||
JsonObject current = doc["current_weather"];
|
||||
weather.temperature = current["temperature"] | 0.0f;
|
||||
weather.weathercode = current["weathercode"] | -1;
|
||||
weather.windspeed = current["windspeed"] | 0.0f;
|
||||
weather.lastUpdate = millis();
|
||||
weather.valid = true;
|
||||
|
||||
// Extract sunrise/sunset
|
||||
JsonArray daily_sunrise = doc["daily"]["sunrise"];
|
||||
JsonArray daily_sunset = doc["daily"]["sunset"];
|
||||
|
||||
if (daily_sunrise.size() > 0 && daily_sunset.size() > 0) {
|
||||
const char* sunrise_str = daily_sunrise[0];
|
||||
const char* sunset_str = daily_sunset[0];
|
||||
|
||||
// Parse ISO time (2026-01-02T07:52) -> HH:MM
|
||||
if (sunrise_str && strlen(sunrise_str) >= 16) {
|
||||
sunTimes.sunrise[0] = sunrise_str[11];
|
||||
sunTimes.sunrise[1] = sunrise_str[12];
|
||||
sunTimes.sunrise[2] = ':';
|
||||
sunTimes.sunrise[3] = sunrise_str[14];
|
||||
sunTimes.sunrise[4] = sunrise_str[15];
|
||||
sunTimes.sunrise[5] = '\0';
|
||||
|
||||
sunTimes.sunriseMinutes = (sunrise_str[11] - '0') * 600 +
|
||||
(sunrise_str[12] - '0') * 60 +
|
||||
(sunrise_str[14] - '0') * 10 +
|
||||
(sunrise_str[15] - '0');
|
||||
}
|
||||
|
||||
if (sunset_str && strlen(sunset_str) >= 16) {
|
||||
sunTimes.sunset[0] = sunset_str[11];
|
||||
sunTimes.sunset[1] = sunset_str[12];
|
||||
sunTimes.sunset[2] = ':';
|
||||
sunTimes.sunset[3] = sunset_str[14];
|
||||
sunTimes.sunset[4] = sunset_str[15];
|
||||
sunTimes.sunset[5] = '\0';
|
||||
|
||||
sunTimes.sunsetMinutes = (sunset_str[11] - '0') * 600 +
|
||||
(sunset_str[12] - '0') * 60 +
|
||||
(sunset_str[14] - '0') * 10 +
|
||||
(sunset_str[15] - '0');
|
||||
}
|
||||
|
||||
// Update lastDay
|
||||
time_t epochTime = timeClient.getEpochTime();
|
||||
struct tm *ptm = gmtime(&epochTime);
|
||||
sunTimes.lastDay = ptm->tm_yday;
|
||||
}
|
||||
|
||||
weatherState = WEATHER_SUCCESS;
|
||||
weatherRetry.reset();
|
||||
Serial.printf("Weather: %.1f C, code %d, wind %.1f km/h\n",
|
||||
weather.temperature, weather.weathercode, weather.windspeed);
|
||||
} else {
|
||||
weatherState = WEATHER_FAILED;
|
||||
weather.valid = false;
|
||||
lastError = String("JSON: ") + error.c_str();
|
||||
Serial.printf("JSON parse error (attempt %d/%d): %s\n",
|
||||
weatherRetry.currentRetry + 1, weatherRetry.maxRetries, error.c_str());
|
||||
|
||||
weatherRetry.scheduleRetry();
|
||||
if (weatherRetry.maxRetriesReached()) {
|
||||
Serial.println("Weather max retries reached");
|
||||
} else {
|
||||
unsigned long backoff = weatherRetry.getBackoffDelay() / 1000;
|
||||
Serial.printf(" Retry scheduled in %lu seconds\n", backoff);
|
||||
}
|
||||
}
|
||||
|
||||
doc.clear();
|
||||
} else {
|
||||
weatherState = WEATHER_FAILED;
|
||||
weather.valid = false;
|
||||
lastError = "Weather API: " + String(httpCode);
|
||||
Serial.printf("HTTP error %d (attempt %d/%d)\n",
|
||||
httpCode, weatherRetry.currentRetry + 1, weatherRetry.maxRetries);
|
||||
|
||||
weatherRetry.scheduleRetry();
|
||||
if (weatherRetry.maxRetriesReached()) {
|
||||
Serial.println("Weather max retries reached");
|
||||
} else {
|
||||
unsigned long backoff = weatherRetry.getBackoffDelay() / 1000;
|
||||
Serial.printf(" Retry scheduled in %lu seconds\n", backoff);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Async weather fetch - non-blocking!
|
||||
void ICACHE_FLASH_ATTR fetchWeatherAsync() {
|
||||
if (!config.weather_enabled) {
|
||||
Serial.println(F("Weather disabled"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (weatherState != WEATHER_IDLE) {
|
||||
Serial.println(F("Weather request already in progress"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Build URL
|
||||
String url = "http://api.open-meteo.com/v1/forecast?";
|
||||
url += "latitude=" + String(config.latitude, 2);
|
||||
url += "&longitude=" + String(config.longitude, 2);
|
||||
url += "¤t_weather=true";
|
||||
url += "&daily=sunrise,sunset";
|
||||
url += "&timezone=auto";
|
||||
url += "&forecast_days=1";
|
||||
|
||||
Serial.println(F("Fetching weather (async)..."));
|
||||
|
||||
if (weatherRequest.open("GET", url.c_str())) {
|
||||
weatherRequest.onReadyStateChange(onWeatherResponse);
|
||||
weatherRequest.setTimeout(10); // 10 seconds
|
||||
weatherRequest.send();
|
||||
weatherState = WEATHER_REQUESTING;
|
||||
Serial.println("Weather request sent (non-blocking)");
|
||||
} else {
|
||||
weatherState = WEATHER_FAILED;
|
||||
Serial.println("Failed to open weather request");
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate sun times (placeholder - data comes from API)
|
||||
void ICACHE_FLASH_ATTR calculateSunTimes() {
|
||||
if (!config.show_sunrise_sunset) return;
|
||||
|
||||
if (sunTimes.lastDay != -1) {
|
||||
Serial.println(F("Sun times already available from API"));
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.println(F("Sun times not available yet - will be fetched with weather"));
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
/*
|
||||
* web_server.cpp - Web server handlers
|
||||
* TJ-56-654 Weather Clock v1.9.2
|
||||
*/
|
||||
|
||||
#include "globals.h"
|
||||
#include <EEPROM.h>
|
||||
#include <ESP8266mDNS.h>
|
||||
|
||||
// Dummy function for compatibility
|
||||
void ICACHE_FLASH_ATTR displaySegments(const uint8_t segments[]) {
|
||||
// Not used with OLED
|
||||
}
|
||||
|
||||
// PROGMEM templates for handleRoot()
|
||||
const char ROOT_HTML_HEADER[] PROGMEM =
|
||||
"<!DOCTYPE html><html><head>"
|
||||
"<meta charset='UTF-8'>"
|
||||
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
|
||||
"<title>TJ-56-654 Clock v" FIRMWARE_VERSION "</title>"
|
||||
"<style>"
|
||||
"body{font-family:Arial;margin:20px;background:#f0f0f0;}"
|
||||
".container{max-width:600px;margin:0 auto;background:white;padding:20px;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,0.1);}"
|
||||
"h1{color:#333;}.time{font-size:48px;text-align:center;margin:20px 0;font-weight:bold;color:#0066cc;}"
|
||||
".info{margin:10px 0;padding:10px;background:#f9f9f9;border-radius:5px;}"
|
||||
".error{background:#ffebee;color:#c62828;}"
|
||||
"a.button{display:inline-block;background:#0066cc;color:white;padding:10px 20px;text-decoration:none;border-radius:5px;margin:10px 5px;}"
|
||||
"a.button:hover{background:#0052a3;}"
|
||||
".debug{background:#fff3cd;color:#856404;padding:10px;border-radius:5px;margin:10px 0;}"
|
||||
"</style>"
|
||||
"<script>"
|
||||
"function updateTime(){fetch('/api/time').then(r=>r.json()).then(d=>{document.getElementById('time').innerText=d.time;});}"
|
||||
"setInterval(updateTime,1000);updateTime();"
|
||||
"</script>"
|
||||
"</head><body>"
|
||||
"<div class='container'>"
|
||||
"<h1>TJ-56-654 NTP Clock v" FIRMWARE_VERSION "</h1>"
|
||||
"<div class='time' id='time'>--:--:--</div>";
|
||||
|
||||
const char ROOT_HTML_FOOTER[] PROGMEM =
|
||||
"<a href='/config' class='button'>Configuration</a>"
|
||||
"<a href='/debug' class='button'>Debug Info</a>"
|
||||
"<a href='/update' class='button'>Firmware Update</a>"
|
||||
"<a href='/api/status' class='button'>Status (JSON)</a>"
|
||||
"<button class='button' onclick=\"if(confirm('Reboot device?')) fetch('/api/reboot', {method:'POST'}).then(()=>alert('Rebooting...'))\">Reboot</button>"
|
||||
"</div></body></html>";
|
||||
|
||||
void ICACHE_FLASH_ATTR handleRoot() {
|
||||
char buf[150];
|
||||
|
||||
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||
server.send(200, "text/html", "");
|
||||
|
||||
server.sendContent_P(ROOT_HTML_HEADER);
|
||||
|
||||
if (lastError != "") {
|
||||
snprintf_P(buf, sizeof(buf), PSTR("<div class='info error'><strong>Error:</strong> %s</div>"), lastError.c_str());
|
||||
server.sendContent(buf);
|
||||
}
|
||||
|
||||
snprintf_P(buf, sizeof(buf), PSTR("<div class='info'><strong>WiFi:</strong> %s</div>"), WiFi.SSID().c_str());
|
||||
server.sendContent(buf);
|
||||
|
||||
snprintf_P(buf, sizeof(buf), PSTR("<div class='info'><strong>IP:</strong> %s</div>"), WiFi.localIP().toString().c_str());
|
||||
server.sendContent(buf);
|
||||
|
||||
snprintf_P(buf, sizeof(buf), PSTR("<div class='info'><strong>Hostname:</strong> %s.local</div>"), config.hostname);
|
||||
server.sendContent(buf);
|
||||
|
||||
snprintf_P(buf, sizeof(buf), PSTR("<div class='info'><strong>Uptime:</strong> %lu seconds</div>"), millis()/1000);
|
||||
server.sendContent(buf);
|
||||
|
||||
if (!timeClient.isTimeSet()) {
|
||||
snprintf_P(buf, sizeof(buf), PSTR("<div class='debug'><strong>NTP not synced yet</strong><br>Attempts: %d | Success: %d</div>"),
|
||||
ntpAttempts, ntpSuccesses);
|
||||
server.sendContent(buf);
|
||||
}
|
||||
|
||||
server.sendContent_P(ROOT_HTML_FOOTER);
|
||||
server.sendContent("");
|
||||
}
|
||||
|
||||
// PROGMEM templates for handleDebug()
|
||||
const char DEBUG_HTML_HEADER[] PROGMEM =
|
||||
"<!DOCTYPE html><html><head>"
|
||||
"<meta charset='UTF-8'>"
|
||||
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
|
||||
"<title>Debug Info</title>"
|
||||
"<style>"
|
||||
"body{font-family:monospace;margin:20px;background:#f0f0f0;}"
|
||||
".container{max-width:800px;margin:0 auto;background:white;padding:20px;border-radius:10px;}"
|
||||
".ok{color:green;}.fail{color:red;}"
|
||||
"pre{background:#f5f5f5;padding:10px;border-radius:5px;overflow-x:auto;}"
|
||||
"button{background:#0066cc;color:white;padding:10px 20px;border:none;border-radius:5px;cursor:pointer;margin:5px;}"
|
||||
"</style>"
|
||||
"</head><body>"
|
||||
"<div class='container'>"
|
||||
"<h1>Debug Information</h1>"
|
||||
"<h2>Network</h2><pre>";
|
||||
|
||||
const char DEBUG_HTML_FOOTER[] PROGMEM =
|
||||
"<h2>Actions</h2>"
|
||||
"<button onclick=\"location.href='/test-ntp'\">Test NTP Now</button>"
|
||||
"<button onclick=\"location.href='/test-display'\">Test Display (8888)</button>"
|
||||
"<button onclick=\"location.reload()\">Refresh</button>"
|
||||
"<button onclick=\"location.href='/api/config'\">Download Config (JSON)</button>"
|
||||
"<button onclick=\"if(confirm('Clear EEPROM and reboot?')) fetch('/api/eeprom-clear', {method:'POST'}).then(()=>alert('Rebooting...'))\">Clear EEPROM</button>"
|
||||
"<button onclick=\"location.href='/'\">Back</button>"
|
||||
"</div></body></html>";
|
||||
|
||||
void ICACHE_FLASH_ATTR handleDebug() {
|
||||
char buf[200];
|
||||
|
||||
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||
server.send(200, "text/html", "");
|
||||
|
||||
server.sendContent_P(DEBUG_HTML_HEADER);
|
||||
|
||||
snprintf_P(buf, sizeof(buf), PSTR("SSID: %s\nIP: %s\nGateway: %s\nDNS: %s\nRSSI: %d dBm\nHostname: %s\n</pre>"),
|
||||
WiFi.SSID().c_str(), WiFi.localIP().toString().c_str(), WiFi.gatewayIP().toString().c_str(),
|
||||
WiFi.dnsIP().toString().c_str(), WiFi.RSSI(), config.hostname);
|
||||
server.sendContent(buf);
|
||||
|
||||
server.sendContent_P(PSTR("<h2>Internet Connectivity</h2><pre>Status: "));
|
||||
server.sendContent_P(internetConnected ? PSTR("<span class='ok'>Connected</span>\n</pre>") : PSTR("<span class='fail'>Not connected</span>\n</pre>"));
|
||||
|
||||
server.sendContent_P(PSTR("<h2>NTP</h2><pre>"));
|
||||
snprintf_P(buf, sizeof(buf), PSTR("Server: %s\nUpdate interval: %u seconds\nSynced: "), config.ntp_server, config.ntp_interval);
|
||||
server.sendContent(buf);
|
||||
server.sendContent_P(timeClient.isTimeSet() ? PSTR("<span class='ok'>Yes</span>\n") : PSTR("<span class='fail'>No</span>\n"));
|
||||
|
||||
snprintf_P(buf, sizeof(buf), PSTR("UTC time: %s\n"), timeClient.getFormattedTime().c_str());
|
||||
server.sendContent(buf);
|
||||
|
||||
if (timeClient.isTimeSet()) {
|
||||
unsigned long epochTime = timeClient.getEpochTime();
|
||||
unsigned long localTime = epochTime + getTotalOffset(epochTime);
|
||||
snprintf_P(buf, sizeof(buf), PSTR("Local time: %02d:%02d:%02d\n"), (int)((localTime/3600)%24), (int)((localTime/60)%60), (int)(localTime%60));
|
||||
server.sendContent(buf);
|
||||
}
|
||||
|
||||
snprintf_P(buf, sizeof(buf), PSTR("Attempts: %d\nSuccesses: %d\nLast error: %s\n</pre>"), ntpAttempts, ntpSuccesses, lastError.c_str());
|
||||
server.sendContent(buf);
|
||||
|
||||
server.sendContent_P(PSTR("<h2>Timezone & DST</h2><pre>"));
|
||||
snprintf_P(buf, sizeof(buf), PSTR("Base offset: %.1f hours (%ld seconds)\nDST enabled: %s\n"),
|
||||
config.timezone_offset/3600.0, config.timezone_offset, config.dst_enabled ? "Yes" : "No");
|
||||
server.sendContent(buf);
|
||||
|
||||
if (config.dst_enabled && timeClient.isTimeSet()) {
|
||||
unsigned long epochTime = timeClient.getEpochTime();
|
||||
bool inDST = isDST(epochTime);
|
||||
snprintf_P(buf, sizeof(buf), PSTR("DST active now: %s\nTotal offset: %.1f hours\n"),
|
||||
inDST ? "<span class='ok'>Yes (+1 hour)</span>" : "No", getTotalOffset(epochTime)/3600.0);
|
||||
server.sendContent(buf);
|
||||
}
|
||||
|
||||
snprintf_P(buf, sizeof(buf), PSTR("Time format: %s\n</pre>"), config.hour_format_24 ? "24-hour" : "12-hour (AM/PM)");
|
||||
server.sendContent(buf);
|
||||
|
||||
server.sendContent_P(PSTR("<h2>Weather</h2><pre>"));
|
||||
snprintf_P(buf, sizeof(buf), PSTR("Enabled: %s\nValid data: %s\n"),
|
||||
config.weather_enabled ? "Yes" : "No",
|
||||
weather.valid ? "<span class='ok'>Yes</span>" : "<span class='fail'>No</span>");
|
||||
server.sendContent(buf);
|
||||
|
||||
if (weather.valid) {
|
||||
snprintf_P(buf, sizeof(buf), PSTR("Temperature: %.1f C\nWeather code: %d\nWind speed: %.1f km/h\nLast update: %lu sec ago\n"),
|
||||
weather.temperature, weather.weathercode, weather.windspeed, weather.lastUpdate/1000);
|
||||
server.sendContent(buf);
|
||||
}
|
||||
|
||||
snprintf_P(buf, sizeof(buf), PSTR("City: %s\nLocation: %.6f, %.6f\nUpdate interval: %u seconds\n</pre>"),
|
||||
config.city_name, config.latitude, config.longitude, config.weather_interval);
|
||||
server.sendContent(buf);
|
||||
|
||||
server.sendContent_P(PSTR("<h2>Sunrise/Sunset</h2><pre>"));
|
||||
snprintf_P(buf, sizeof(buf), PSTR("Enabled: %s\n"), config.show_sunrise_sunset ? "Yes" : "No");
|
||||
server.sendContent(buf);
|
||||
|
||||
if (sunTimes.lastDay != -1) {
|
||||
snprintf_P(buf, sizeof(buf), PSTR("<span class='ok'>Data available</span>\nSunrise: %s (%d min)\nSunset: %s (%d min)\nLast update day: %d\n</pre>"),
|
||||
sunTimes.sunrise, sunTimes.sunriseMinutes, sunTimes.sunset, sunTimes.sunsetMinutes, sunTimes.lastDay);
|
||||
} else {
|
||||
snprintf_P(buf, sizeof(buf), PSTR("<span class='fail'>No data</span>\n</pre>"));
|
||||
}
|
||||
server.sendContent(buf);
|
||||
|
||||
server.sendContent_P(PSTR("<h2>Display</h2><pre>"));
|
||||
const char* modeStr = (displayMode == 0) ? " (Time)" : (displayMode == 1) ? " (Weather)" : " (Sun times)";
|
||||
snprintf_P(buf, sizeof(buf), PSTR("Current mode: %d%s\nRotation interval: %u seconds\nBrightness: %d (0-7)\nShow weather: %s\nShow sun times: %s\nNTP synced: %s\n</pre>"),
|
||||
displayMode, modeStr, config.display_rotation_sec, config.brightness,
|
||||
config.show_weather ? "Yes" : "No", config.show_sunrise_sunset ? "Yes" : "No",
|
||||
timeClient.isTimeSet() ? "<span class='ok'>Yes</span>" : "<span class='fail'>No</span>");
|
||||
server.sendContent(buf);
|
||||
|
||||
server.sendContent_P(PSTR("<h2>System</h2><pre>"));
|
||||
snprintf_P(buf, sizeof(buf), PSTR("Uptime: %lu seconds\nFree heap: %u bytes\nChip ID: %X\nFlash size: %u bytes\nSDK version: %s\n</pre>"),
|
||||
millis()/1000, ESP.getFreeHeap(), ESP.getChipId(), ESP.getFlashChipSize(), ESP.getSdkVersion());
|
||||
server.sendContent(buf);
|
||||
|
||||
server.sendContent_P(DEBUG_HTML_FOOTER);
|
||||
server.sendContent("");
|
||||
}
|
||||
|
||||
void ICACHE_FLASH_ATTR handleTestNTP() {
|
||||
testInternetConnectivity();
|
||||
updateNTPTime();
|
||||
|
||||
server.sendHeader("Location", "/debug");
|
||||
server.send(303);
|
||||
}
|
||||
|
||||
void ICACHE_FLASH_ATTR handleTestDisplay() {
|
||||
uint8_t data[] = {0xFF, 0xFF, 0xFF, 0xFF};
|
||||
displaySegments(data);
|
||||
delay(3000);
|
||||
|
||||
server.sendHeader("Location", "/debug");
|
||||
server.send(303);
|
||||
}
|
||||
|
||||
// PROGMEM templates for handleConfig()
|
||||
const char CONFIG_HTML_HEADER[] PROGMEM =
|
||||
"<!DOCTYPE html><html><head>"
|
||||
"<meta charset='UTF-8'>"
|
||||
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
|
||||
"<title>Configuration</title>"
|
||||
"<style>"
|
||||
"body{font-family:Arial;margin:20px;background:#f0f0f0;}"
|
||||
".container{max-width:600px;margin:0 auto;background:white;padding:20px;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,0.1);}"
|
||||
"input,select{width:100%;padding:8px;margin:5px 0 15px 0;border:1px solid #ddd;border-radius:4px;box-sizing:border-box;}"
|
||||
"button{background:#0066cc;color:white;padding:12px 20px;border:none;border-radius:5px;cursor:pointer;width:100%;}"
|
||||
"button:hover{background:#0052a3;}"
|
||||
"label{font-weight:bold;}"
|
||||
"</style>"
|
||||
"</head><body>"
|
||||
"<div class='container'>"
|
||||
"<h1>Configuration</h1>"
|
||||
"<form method='POST' action='/config'>";
|
||||
|
||||
const char CONFIG_HTML_FOOTER[] PROGMEM =
|
||||
"<button type='submit'>Save & Reboot</button>"
|
||||
"</form>"
|
||||
"<p><a href='/'>Back to Home</a></p>"
|
||||
"</div></body></html>";
|
||||
|
||||
void ICACHE_FLASH_ATTR handleConfig() {
|
||||
char buf[150];
|
||||
|
||||
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||
server.send(200, "text/html", "");
|
||||
|
||||
server.sendContent_P(CONFIG_HTML_HEADER);
|
||||
|
||||
snprintf_P(buf, sizeof(buf), PSTR("<label>WiFi SSID:</label><input type='text' name='ssid' value='%s' required>"), config.ssid);
|
||||
server.sendContent(buf);
|
||||
snprintf_P(buf, sizeof(buf), PSTR("<label>WiFi Password:</label><input type='password' name='password' value='%s'>"), config.password);
|
||||
server.sendContent(buf);
|
||||
|
||||
snprintf_P(buf, sizeof(buf), PSTR("<label>Timezone Offset (seconds):</label><input type='number' name='timezone' value='%ld'>"), config.timezone_offset);
|
||||
server.sendContent(buf);
|
||||
snprintf_P(buf, sizeof(buf), PSTR("<label>Brightness (0-7):</label><input type='number' name='brightness' min='0' max='7' value='%d'>"), config.brightness);
|
||||
server.sendContent(buf);
|
||||
snprintf_P(buf, sizeof(buf), PSTR("<label>Hostname:</label><input type='text' name='hostname' value='%s'>"), config.hostname);
|
||||
server.sendContent(buf);
|
||||
|
||||
server.sendContent_P(PSTR("<h2 style='margin-top:20px;'>Weather Settings</h2>"));
|
||||
snprintf_P(buf, sizeof(buf), PSTR("<label>City Name:</label><input type='text' name='city_name' value='%s'>"), config.city_name);
|
||||
server.sendContent(buf);
|
||||
snprintf_P(buf, sizeof(buf), PSTR("<label>Latitude:</label><input type='number' step='0.000001' name='latitude' value='%.6f'>"), config.latitude);
|
||||
server.sendContent(buf);
|
||||
snprintf_P(buf, sizeof(buf), PSTR("<label>Longitude:</label><input type='number' step='0.000001' name='longitude' value='%.6f'>"), config.longitude);
|
||||
server.sendContent(buf);
|
||||
snprintf_P(buf, sizeof(buf), PSTR("<label>Weather Update Interval (seconds):</label><input type='number' name='weather_interval' value='%u'>"), config.weather_interval);
|
||||
server.sendContent(buf);
|
||||
|
||||
server.sendContent_P(PSTR("<h2 style='margin-top:20px;'>Display Settings</h2>"));
|
||||
snprintf_P(buf, sizeof(buf), PSTR("<label>Screen Rotation Interval (seconds):</label><input type='number' name='display_rotation_sec' value='%u'>"), config.display_rotation_sec);
|
||||
server.sendContent(buf);
|
||||
|
||||
server.sendContent_P(CONFIG_HTML_FOOTER);
|
||||
server.sendContent("");
|
||||
}
|
||||
|
||||
void ICACHE_FLASH_ATTR handleConfigSave() {
|
||||
if (server.hasArg("ssid")) {
|
||||
safeStringCopy(server.arg("ssid"), config.ssid, sizeof(config.ssid));
|
||||
}
|
||||
if (server.hasArg("password")) {
|
||||
safeStringCopy(server.arg("password"), config.password, sizeof(config.password));
|
||||
}
|
||||
if (server.hasArg("timezone")) {
|
||||
config.timezone_offset = server.arg("timezone").toInt();
|
||||
}
|
||||
if (server.hasArg("brightness")) {
|
||||
config.brightness = server.arg("brightness").toInt();
|
||||
}
|
||||
if (server.hasArg("hostname")) {
|
||||
safeStringCopy(server.arg("hostname"), config.hostname, sizeof(config.hostname));
|
||||
}
|
||||
if (server.hasArg("city_name")) {
|
||||
safeStringCopy(server.arg("city_name"), config.city_name, sizeof(config.city_name));
|
||||
}
|
||||
if (server.hasArg("latitude")) {
|
||||
config.latitude = server.arg("latitude").toFloat();
|
||||
}
|
||||
if (server.hasArg("longitude")) {
|
||||
config.longitude = server.arg("longitude").toFloat();
|
||||
}
|
||||
if (server.hasArg("weather_interval")) {
|
||||
config.weather_interval = server.arg("weather_interval").toInt();
|
||||
}
|
||||
if (server.hasArg("display_rotation_sec")) {
|
||||
config.display_rotation_sec = server.arg("display_rotation_sec").toInt();
|
||||
}
|
||||
if (server.hasArg("display_orientation")) {
|
||||
config.display_orientation = server.arg("display_orientation").toInt();
|
||||
display.setRotation(config.display_orientation);
|
||||
}
|
||||
|
||||
saveConfig();
|
||||
|
||||
String html = F("<!DOCTYPE html><html><head>");
|
||||
html += F("<meta charset='UTF-8'>");
|
||||
html += F("<meta http-equiv='refresh' content='5;url=/'>");
|
||||
html += F("<style>body{font-family:Arial;text-align:center;margin-top:50px;}</style>");
|
||||
html += F("</head><body>");
|
||||
html += F("<h1>Configuration Saved!</h1>");
|
||||
html += F("<p>Device will reboot in 5 seconds...</p>");
|
||||
html += F("</body></html>");
|
||||
|
||||
server.send(200, "text/html", html);
|
||||
|
||||
delay(1000);
|
||||
ESP.restart();
|
||||
}
|
||||
|
||||
void ICACHE_FLASH_ATTR handleAPITime() {
|
||||
String json = "{";
|
||||
json += "\"time\":\"" + timeClient.getFormattedTime() + "\",";
|
||||
json += "\"hours\":" + String(timeClient.getHours()) + ",";
|
||||
json += "\"minutes\":" + String(timeClient.getMinutes()) + ",";
|
||||
json += "\"seconds\":" + String(timeClient.getSeconds()) + ",";
|
||||
json += "\"epoch\":" + String(timeClient.getEpochTime());
|
||||
json += "}";
|
||||
|
||||
server.send(200, "application/json", json);
|
||||
}
|
||||
|
||||
void ICACHE_FLASH_ATTR handleAPIStatus() {
|
||||
String json = "{";
|
||||
json += "\"wifi\":{";
|
||||
json += "\"ssid\":\"" + String(WiFi.SSID()) + "\",";
|
||||
json += "\"ip\":\"" + WiFi.localIP().toString() + "\",";
|
||||
json += "\"rssi\":" + String(WiFi.RSSI()) + ",";
|
||||
json += "\"hostname\":\"" + String(config.hostname) + "\"";
|
||||
json += "},";
|
||||
json += "\"time\":{";
|
||||
json += "\"current\":\"" + timeClient.getFormattedTime() + "\",";
|
||||
json += "\"timezone_offset\":" + String(config.timezone_offset) + ",";
|
||||
json += "\"ntp_synced\":" + String(timeClient.isTimeSet() ? "true" : "false");
|
||||
json += "},";
|
||||
json += "\"system\":{";
|
||||
json += "\"uptime\":" + String(millis() / 1000) + ",";
|
||||
json += "\"free_heap\":" + String(ESP.getFreeHeap()) + ",";
|
||||
json += "\"chip_id\":\"" + String(ESP.getChipId(), HEX) + "\"";
|
||||
json += "}";
|
||||
json += "}";
|
||||
|
||||
server.send(200, "application/json", json);
|
||||
}
|
||||
|
||||
void ICACHE_FLASH_ATTR handleAPIDebug() {
|
||||
String json = "{";
|
||||
json += "\"internet_connected\":" + String(internetConnected ? "true" : "false") + ",";
|
||||
json += "\"ntp_attempts\":" + String(ntpAttempts) + ",";
|
||||
json += "\"ntp_successes\":" + String(ntpSuccesses) + ",";
|
||||
json += "\"last_error\":\"" + lastError + "\",";
|
||||
json += "\"gateway\":\"" + WiFi.gatewayIP().toString() + "\",";
|
||||
json += "\"dns\":\"" + WiFi.dnsIP().toString() + "\"";
|
||||
json += "}";
|
||||
|
||||
server.send(200, "application/json", json);
|
||||
}
|
||||
|
||||
void ICACHE_FLASH_ATTR handleAPIWeather() {
|
||||
String json = "{";
|
||||
json += "\"enabled\":" + String(config.weather_enabled ? "true" : "false") + ",";
|
||||
json += "\"valid\":" + String(weather.valid ? "true" : "false") + ",";
|
||||
json += "\"temperature\":" + String(weather.temperature, 1) + ",";
|
||||
json += "\"weathercode\":" + String(weather.weathercode) + ",";
|
||||
json += "\"windspeed\":" + String(weather.windspeed, 1) + ",";
|
||||
json += "\"last_update\":" + String(weather.lastUpdate) + ",";
|
||||
json += "\"sunrise\":\"" + String(sunTimes.sunrise) + "\",";
|
||||
json += "\"sunset\":\"" + String(sunTimes.sunset) + "\",";
|
||||
json += "\"sunrise_minutes\":" + String(sunTimes.sunriseMinutes) + ",";
|
||||
json += "\"sunset_minutes\":" + String(sunTimes.sunsetMinutes);
|
||||
json += "}";
|
||||
|
||||
server.send(200, "application/json", json);
|
||||
}
|
||||
|
||||
void ICACHE_FLASH_ATTR handleAPIConfigExport() {
|
||||
String json = "{";
|
||||
json += "\"firmware_version\":\"" FIRMWARE_VERSION "\",";
|
||||
json += "\"magic\":\"0x" + String(config.magic, HEX) + "\",";
|
||||
json += "\"ssid\":\"" + String(config.ssid) + "\",";
|
||||
json += "\"password\":\"" + String(config.password) + "\",";
|
||||
json += "\"timezone_offset\":" + String(config.timezone_offset) + ",";
|
||||
json += "\"dst_enabled\":" + String(config.dst_enabled ? "true" : "false") + ",";
|
||||
json += "\"brightness\":" + String(config.brightness) + ",";
|
||||
json += "\"ntp_server\":\"" + String(config.ntp_server) + "\",";
|
||||
json += "\"ntp_interval\":" + String(config.ntp_interval) + ",";
|
||||
json += "\"hour_format_24\":" + String(config.hour_format_24 ? "true" : "false") + ",";
|
||||
json += "\"hostname\":\"" + String(config.hostname) + "\",";
|
||||
json += "\"latitude\":" + String(config.latitude, 6) + ",";
|
||||
json += "\"longitude\":" + String(config.longitude, 6) + ",";
|
||||
json += "\"city_name\":\"" + String(config.city_name) + "\",";
|
||||
json += "\"weather_enabled\":" + String(config.weather_enabled ? "true" : "false") + ",";
|
||||
json += "\"weather_interval\":" + String(config.weather_interval) + ",";
|
||||
json += "\"display_rotation_sec\":" + String(config.display_rotation_sec) + ",";
|
||||
json += "\"show_weather\":" + String(config.show_weather ? "true" : "false") + ",";
|
||||
json += "\"show_sunrise_sunset\":" + String(config.show_sunrise_sunset ? "true" : "false");
|
||||
json += "}";
|
||||
|
||||
server.sendHeader("Content-Disposition", "attachment; filename=clock-config.json");
|
||||
server.send(200, "application/json", json);
|
||||
}
|
||||
|
||||
void ICACHE_FLASH_ATTR handleAPIConfigImport() {
|
||||
if (!server.hasArg("plain")) {
|
||||
server.send(400, "text/plain", "No config data received");
|
||||
return;
|
||||
}
|
||||
|
||||
String body = server.arg("plain");
|
||||
Serial.println("Received config: " + body);
|
||||
|
||||
// Parse various fields (simplified parsing)
|
||||
int pos;
|
||||
|
||||
pos = body.indexOf("\"ssid\":\"");
|
||||
if (pos >= 0) {
|
||||
int start = pos + 8;
|
||||
int end = body.indexOf("\"", start);
|
||||
if (end > start) {
|
||||
safeStringCopy(body.substring(start, end), config.ssid, sizeof(config.ssid));
|
||||
}
|
||||
}
|
||||
|
||||
pos = body.indexOf("\"password\":\"");
|
||||
if (pos >= 0) {
|
||||
int start = pos + 12;
|
||||
int end = body.indexOf("\"", start);
|
||||
if (end > start) {
|
||||
safeStringCopy(body.substring(start, end), config.password, sizeof(config.password));
|
||||
}
|
||||
}
|
||||
|
||||
pos = body.indexOf("\"timezone_offset\":");
|
||||
if (pos >= 0) {
|
||||
int start = pos + 18;
|
||||
int end = body.indexOf(",", start);
|
||||
if (end < 0) end = body.indexOf("}", start);
|
||||
if (end > start) {
|
||||
config.timezone_offset = body.substring(start, end).toInt();
|
||||
}
|
||||
}
|
||||
|
||||
pos = body.indexOf("\"brightness\":");
|
||||
if (pos >= 0) {
|
||||
int start = pos + 13;
|
||||
int end = body.indexOf(",", start);
|
||||
if (end < 0) end = body.indexOf("}", start);
|
||||
if (end > start) {
|
||||
config.brightness = body.substring(start, end).toInt();
|
||||
}
|
||||
}
|
||||
|
||||
pos = body.indexOf("\"hostname\":\"");
|
||||
if (pos >= 0) {
|
||||
int start = pos + 12;
|
||||
int end = body.indexOf("\"", start);
|
||||
if (end > start) {
|
||||
safeStringCopy(body.substring(start, end), config.hostname, sizeof(config.hostname));
|
||||
}
|
||||
}
|
||||
|
||||
pos = body.indexOf("\"city_name\":\"");
|
||||
if (pos >= 0) {
|
||||
int start = pos + 13;
|
||||
int end = body.indexOf("\"", start);
|
||||
if (end > start) {
|
||||
safeStringCopy(body.substring(start, end), config.city_name, sizeof(config.city_name));
|
||||
}
|
||||
}
|
||||
|
||||
pos = body.indexOf("\"latitude\":");
|
||||
if (pos >= 0) {
|
||||
int start = pos + 11;
|
||||
int end = body.indexOf(",", start);
|
||||
if (end < 0) end = body.indexOf("}", start);
|
||||
if (end > start) {
|
||||
config.latitude = body.substring(start, end).toFloat();
|
||||
}
|
||||
}
|
||||
|
||||
pos = body.indexOf("\"longitude\":");
|
||||
if (pos >= 0) {
|
||||
int start = pos + 12;
|
||||
int end = body.indexOf(",", start);
|
||||
if (end < 0) end = body.indexOf("}", start);
|
||||
if (end > start) {
|
||||
config.longitude = body.substring(start, end).toFloat();
|
||||
}
|
||||
}
|
||||
|
||||
config.magic = CONFIG_MAGIC;
|
||||
saveConfig();
|
||||
|
||||
server.send(200, "application/json", "{\"status\":\"ok\",\"message\":\"Config imported and saved. Reboot recommended.\"}");
|
||||
}
|
||||
|
||||
void ICACHE_FLASH_ATTR handleEEPROMClear() {
|
||||
EEPROM.begin(512);
|
||||
for (int i = 0; i < 512; i++) {
|
||||
EEPROM.write(i, 0xFF);
|
||||
}
|
||||
EEPROM.commit();
|
||||
EEPROM.end();
|
||||
|
||||
Serial.println("EEPROM cleared!");
|
||||
|
||||
server.send(200, "application/json", "{\"status\":\"ok\",\"message\":\"EEPROM cleared, device will reboot\"}");
|
||||
|
||||
delay(1000);
|
||||
ESP.restart();
|
||||
}
|
||||
|
||||
void ICACHE_FLASH_ATTR handleReboot() {
|
||||
Serial.println("Reboot requested via web interface");
|
||||
|
||||
server.send(200, "application/json", "{\"status\":\"ok\",\"message\":\"Device rebooting...\"}");
|
||||
|
||||
delay(1000);
|
||||
ESP.restart();
|
||||
}
|
||||
|
||||
void ICACHE_FLASH_ATTR handleI2CScan() {
|
||||
String json = "{\"i2c_scan\":{\"devices\":[";
|
||||
|
||||
int deviceCount = 0;
|
||||
|
||||
for (uint8_t address = 0x08; address <= 0x77; address++) {
|
||||
Wire.beginTransmission(address);
|
||||
uint8_t error = Wire.endTransmission();
|
||||
|
||||
if (error == 0) {
|
||||
if (deviceCount > 0) json += ",";
|
||||
json += "{\"address\":\"0x";
|
||||
if (address < 16) json += "0";
|
||||
json += String(address, HEX);
|
||||
json += "\",\"decimal\":" + String(address) + "}";
|
||||
deviceCount++;
|
||||
}
|
||||
delay(1);
|
||||
}
|
||||
|
||||
json += "],\"count\":" + String(deviceCount);
|
||||
|
||||
json += ",\"oled_test\":{";
|
||||
Wire.beginTransmission(0x3C);
|
||||
json += "\"0x3C\":\"" + String(Wire.endTransmission() == 0 ? "FOUND" : "not found") + "\",";
|
||||
Wire.beginTransmission(0x3D);
|
||||
json += "\"0x3D\":\"" + String(Wire.endTransmission() == 0 ? "FOUND" : "not found") + "\"";
|
||||
json += "}}}";
|
||||
|
||||
Serial.println("I2C Scan results: " + json);
|
||||
|
||||
server.send(200, "application/json", json);
|
||||
}
|
||||
|
||||
// Setup web server
|
||||
void ICACHE_FLASH_ATTR setupWebServer() {
|
||||
httpUpdater.setup(&server, "/update", "admin", "admin");
|
||||
|
||||
server.on("/", HTTP_GET, handleRoot);
|
||||
server.on("/config", HTTP_GET, handleConfig);
|
||||
server.on("/config", HTTP_POST, handleConfigSave);
|
||||
server.on("/debug", HTTP_GET, handleDebug);
|
||||
server.on("/test-ntp", HTTP_GET, handleTestNTP);
|
||||
server.on("/test-display", HTTP_GET, handleTestDisplay);
|
||||
server.on("/api/time", HTTP_GET, handleAPITime);
|
||||
server.on("/api/status", HTTP_GET, handleAPIStatus);
|
||||
server.on("/api/debug", HTTP_GET, handleAPIDebug);
|
||||
server.on("/api/weather", HTTP_GET, handleAPIWeather);
|
||||
server.on("/api/config", HTTP_GET, handleAPIConfigExport);
|
||||
server.on("/api/config", HTTP_POST, handleAPIConfigImport);
|
||||
server.on("/api/eeprom-clear", HTTP_POST, handleEEPROMClear);
|
||||
server.on("/api/reboot", HTTP_POST, handleReboot);
|
||||
server.on("/api/i2c-scan", HTTP_GET, handleI2CScan);
|
||||
|
||||
server.begin();
|
||||
Serial.println("Web server started");
|
||||
|
||||
if (MDNS.begin(config.hostname)) {
|
||||
Serial.printf("mDNS responder started: %s.local\n", config.hostname);
|
||||
MDNS.addService("http", "tcp", 80);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* wifi_manager.cpp - WiFi connection management
|
||||
* TJ-56-654 Weather Clock v1.9.2
|
||||
*/
|
||||
|
||||
#include "globals.h"
|
||||
#include <WiFiManager.h>
|
||||
|
||||
// Async WiFi - Process connection (call in loop)
|
||||
void ICACHE_FLASH_ATTR processWiFiConnection() {
|
||||
if (wifiConnState != WIFI_CONN_CONNECTING) return;
|
||||
|
||||
// Check connection status
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
wifiConnState = WIFI_CONN_CONNECTED;
|
||||
wifiRetry.reset();
|
||||
internetConnected = true;
|
||||
Serial.println("\nWiFi connected!");
|
||||
Serial.print("SSID: ");
|
||||
Serial.println(WiFi.SSID());
|
||||
Serial.print("IP: ");
|
||||
Serial.println(WiFi.localIP());
|
||||
|
||||
// Disable fallback AP if it was enabled
|
||||
if (WiFi.getMode() == WIFI_AP_STA) {
|
||||
Serial.println("Disabling fallback AP (back to STA mode)");
|
||||
WiFi.softAPdisconnect(true);
|
||||
WiFi.mode(WIFI_STA);
|
||||
}
|
||||
|
||||
// Sync connected SSID to config
|
||||
if (strlen(config.ssid) == 0) {
|
||||
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
|
||||
saveConfig();
|
||||
}
|
||||
|
||||
showIP();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check timeout for this attempt
|
||||
if (millis() - wifiConnectStart > WIFI_TIMEOUT_MS) {
|
||||
wifiRetry.scheduleRetry();
|
||||
unsigned long nextRetryMs = wifiRetry.getBackoffDelay();
|
||||
|
||||
Serial.printf("\nWiFi connection failed. Retry in %lu seconds\n", nextRetryMs / 1000);
|
||||
|
||||
wifiConnState = WIFI_CONN_FAILED;
|
||||
internetConnected = false;
|
||||
|
||||
showNoWiFi(nextRetryMs / 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Still connecting
|
||||
static unsigned long lastDot = 0;
|
||||
if (millis() - lastDot > 500) {
|
||||
Serial.print(".");
|
||||
lastDot = millis();
|
||||
}
|
||||
}
|
||||
|
||||
// WiFi setup (SYNCHRONOUS in setup(), async reconnect in loop())
|
||||
void ICACHE_FLASH_ATTR setupWiFi() {
|
||||
Serial.println("WiFi Setup - Synchronous for initial connection");
|
||||
|
||||
WiFi.hostname(config.hostname);
|
||||
WiFi.mode(WIFI_STA);
|
||||
|
||||
// Try 1: Use WiFi.begin() without params - uses SDK stored credentials
|
||||
Serial.println("Trying SDK-stored credentials...");
|
||||
WiFi.begin();
|
||||
|
||||
// SYNCHRONOUS wait for connection (max 10 seconds)
|
||||
Serial.print("Connecting to WiFi");
|
||||
int attempts = 0;
|
||||
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
|
||||
showWiFiConnecting(attempts);
|
||||
delay(500);
|
||||
Serial.print(".");
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
Serial.println("\nWiFi connected!");
|
||||
Serial.print("SSID: ");
|
||||
Serial.println(WiFi.SSID());
|
||||
Serial.print("IP: ");
|
||||
Serial.println(WiFi.localIP());
|
||||
Serial.print("Gateway: ");
|
||||
Serial.println(WiFi.gatewayIP());
|
||||
Serial.print("DNS: ");
|
||||
Serial.println(WiFi.dnsIP());
|
||||
|
||||
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
|
||||
saveConfig();
|
||||
|
||||
showIP();
|
||||
wifiConnState = WIFI_CONN_CONNECTED;
|
||||
return;
|
||||
}
|
||||
|
||||
// Try 2: If we have EEPROM credentials, try those
|
||||
if (strlen(config.ssid) > 0 && strlen(config.password) > 0) {
|
||||
Serial.println("\nTrying EEPROM credentials...");
|
||||
WiFi.begin(config.ssid, config.password);
|
||||
|
||||
attempts = 0;
|
||||
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
|
||||
showWiFiConnecting(attempts);
|
||||
delay(500);
|
||||
Serial.print(".");
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
Serial.println("\nWiFi connected via EEPROM credentials!");
|
||||
showIP();
|
||||
wifiConnState = WIFI_CONN_CONNECTED;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No stored credentials - use WiFiManager
|
||||
if (strlen(config.ssid) == 0) {
|
||||
Serial.println("\nNo saved credentials, using WiFiManager...");
|
||||
WiFiManager wifiManager;
|
||||
wifiManager.setConfigPortalTimeout(180);
|
||||
|
||||
// Show AP mode indicator
|
||||
display.clearDisplay();
|
||||
display.setTextSize(1);
|
||||
display.setTextColor(SSD1306_WHITE);
|
||||
display.setCursor(25, 15);
|
||||
display.print("Setup Mode");
|
||||
display.setTextSize(1);
|
||||
display.setCursor(10, 35);
|
||||
display.print("Connect to WiFi:");
|
||||
display.setCursor(10, 48);
|
||||
display.print("TJ56654-Setup");
|
||||
display.display();
|
||||
|
||||
Serial.println("Attempting WiFiManager auto-connect...");
|
||||
if (!wifiManager.autoConnect("TJ56654-Setup", "12345678")) {
|
||||
Serial.println("WiFi connection failed. Starting fallback AP...");
|
||||
WiFi.mode(WIFI_AP);
|
||||
WiFi.softAP("TJ56654-Clock", "12345678");
|
||||
Serial.print("Fallback AP IP: ");
|
||||
Serial.println(WiFi.softAPIP());
|
||||
wifiConnState = WIFI_CONN_CONNECTED;
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.println("WiFi connected via WiFiManager!");
|
||||
Serial.print("SSID: ");
|
||||
Serial.println(WiFi.SSID());
|
||||
Serial.print("IP: ");
|
||||
Serial.println(WiFi.localIP());
|
||||
|
||||
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
|
||||
saveConfig();
|
||||
|
||||
showIP();
|
||||
wifiConnState = WIFI_CONN_CONNECTED;
|
||||
return;
|
||||
}
|
||||
|
||||
// We have credentials but WiFi is not available
|
||||
Serial.println("\nWiFi not available. Will retry in background.");
|
||||
wifiConnState = WIFI_CONN_FAILED;
|
||||
wifiRetry.scheduleRetry();
|
||||
showNoWiFi(wifiRetry.getBackoffDelay() / 1000);
|
||||
}
|
||||
새 이슈에서 참조
사용자 차단