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>
이 커밋은 다음에 포함됨:
Alex Petrochenko
2026-01-07 09:05:45 +00:00
co-authored by Claude Opus 4.5
부모 c3bfaa26bc
커밋 4a7e889052
8개의 변경된 파일과 2147개의 추가작업 그리고 2067개의 파일을 삭제
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Diff 로드
+168
파일 보기
@@ -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
+476
파일 보기
@@ -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
}
+143
파일 보기
@@ -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
+205
파일 보기
@@ -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);
}
}
+170
파일 보기
@@ -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 += "&current_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"));
}
+611
파일 보기
@@ -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);
}
}
+173
파일 보기
@@ -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);
}