Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c0f269ee5 | ||
|
|
abf3a513c9 | ||
|
|
4a7e889052 | ||
|
|
c3bfaa26bc | ||
|
|
0f2860c25e | ||
|
|
220544583a | ||
|
|
f2dc2fb961 |
@@ -1,73 +0,0 @@
|
|||||||
name: Build Firmware
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ main ]
|
|
||||||
release:
|
|
||||||
types: [ created ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Setup Arduino CLI
|
|
||||||
uses: arduino/setup-arduino-cli@v1
|
|
||||||
|
|
||||||
- name: Install ESP8266 platform
|
|
||||||
run: |
|
|
||||||
arduino-cli core update-index
|
|
||||||
arduino-cli core install esp8266:esp8266
|
|
||||||
|
|
||||||
- name: Install libraries
|
|
||||||
run: |
|
|
||||||
arduino-cli lib install "Adafruit GFX Library"
|
|
||||||
arduino-cli lib install "Adafruit SSD1306"
|
|
||||||
arduino-cli lib install "NTPClient"
|
|
||||||
arduino-cli lib install "WiFiManager"
|
|
||||||
arduino-cli lib install "AsyncHTTPRequest_Generic"
|
|
||||||
arduino-cli lib install "ESPAsyncTCP"
|
|
||||||
|
|
||||||
- name: Compile firmware
|
|
||||||
run: |
|
|
||||||
arduino-cli compile --fqbn esp8266:esp8266:generic \
|
|
||||||
--build-property "build.flash_size=1M64" \
|
|
||||||
--build-property "build.flash_mode=dio" \
|
|
||||||
--output-dir build \
|
|
||||||
src/clock_ntp_ota_v1.9.ino
|
|
||||||
|
|
||||||
- name: Check firmware size
|
|
||||||
run: |
|
|
||||||
SIZE=$(stat -c%s "build/clock_ntp_ota_v1.9.ino.bin")
|
|
||||||
echo "Firmware size: $SIZE bytes"
|
|
||||||
MAX_SIZE=470000 # 470KB max for OTA
|
|
||||||
if [ $SIZE -gt $MAX_SIZE ]; then
|
|
||||||
echo "ERROR: Firmware too large ($SIZE > $MAX_SIZE)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload build artifacts
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: firmware
|
|
||||||
path: |
|
|
||||||
build/clock_ntp_ota_v1.9.ino.bin
|
|
||||||
build/clock_ntp_ota_v1.9.ino.elf
|
|
||||||
build/clock_ntp_ota_v1.9.ino.map
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
- name: Upload release assets
|
|
||||||
if: github.event_name == 'release'
|
|
||||||
uses: actions/upload-release-asset@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
upload_url: ${{ github.event.release.upload_url }}
|
|
||||||
asset_path: build/clock_ntp_ota_v1.9.ino.bin
|
|
||||||
asset_name: esp8266-weather-clock-${{ github.event.release.tag_name }}.bin
|
|
||||||
asset_content_type: application/octet-stream
|
|
||||||
+30
-1
@@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.9.2] - 2026-01-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **CRITICAL**: WiFi credentials no longer cleared on connection failure
|
||||||
|
- Previous behavior erased SSID/password after failed connection attempts
|
||||||
|
- Now credentials persist indefinitely through WiFi outages
|
||||||
|
- OTA update credential loss issue (SDK credentials now tried first, then EEPROM)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **WiFi Resilience**: Infinite retry with exponential backoff (5s → 10s → 20s → ... → 5min max)
|
||||||
|
- **Fallback AP**: "TJ56654-Setup" enabled after ~5 min of failed attempts
|
||||||
|
- Device continues retry attempts while AP is active (dual STA+AP mode)
|
||||||
|
- AP automatically disabled when WiFi reconnects
|
||||||
|
- **"No WiFi" display**: Shows retry countdown instead of numeric counter
|
||||||
|
- **"!" indicator**: Shown in date line when WiFi is disconnected
|
||||||
|
- **SDK credentials support**: Tries WiFiManager-stored credentials first
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Clock continues running with last synced time during WiFi outages
|
||||||
|
- Improved user experience during network failures
|
||||||
|
- Removed aggressive credential clearing behavior
|
||||||
|
|
||||||
|
### Network Activity
|
||||||
|
- NTP sync: 1 hour interval (pool.ntp.org:123 UDP)
|
||||||
|
- Weather fetch: 30 min interval (api.open-meteo.com HTTP)
|
||||||
|
- mDNS: continuous (224.0.0.251 UDP multicast)
|
||||||
|
- ~50 requests/day total
|
||||||
|
|
||||||
## [1.9.1] - 2026-01-03
|
## [1.9.1] - 2026-01-03
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -117,7 +145,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
- [Full v1.9 Release Notes](docs/v1.9_RELEASE_NOTES.md)
|
- [Full v1.9 Release Notes](docs/v1.9_RELEASE_NOTES.md)
|
||||||
- [v1.9.1 Hybrid Fix Details](docs/v1.9.1_HYBRID_FIX.md)
|
- [v1.9.1 Hybrid Fix Details](docs/v1.9.1_HYBRID_FIX.md)
|
||||||
|
- [v1.9.2 WiFi Resilience](docs/v1.9.2_WIFI_RESILIENCE.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Status**: v1.9.1 is production-ready and actively used 24/7.
|
**Status**: v1.9.2 is production-ready and actively used 24/7.
|
||||||
|
|||||||
@@ -0,0 +1,800 @@
|
|||||||
|
# ESP8266 Weather Clock - Full Project Context
|
||||||
|
|
||||||
|
**Last Updated**: 2026-01-03
|
||||||
|
**Current Version**: v1.9.1 (Production Ready)
|
||||||
|
**GitHub**: https://github.com/petrochen/esp8266-weather-clock-opensource
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Project Overview](#project-overview)
|
||||||
|
2. [Hardware Details](#hardware-details)
|
||||||
|
3. [Development History](#development-history)
|
||||||
|
4. [Current Status (v1.9.1)](#current-status-v191)
|
||||||
|
5. [Technical Architecture](#technical-architecture)
|
||||||
|
6. [Security Issues Fixed](#security-issues-fixed)
|
||||||
|
7. [Files & Structure](#files--structure)
|
||||||
|
8. [Git Repository](#git-repository)
|
||||||
|
9. [Next Steps](#next-steps)
|
||||||
|
10. [Key Learnings](#key-learnings)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
### What Is This?
|
||||||
|
|
||||||
|
Complete reverse engineering and firmware replacement for an ESP8266-based weather clock purchased from AliExpress (model TJ-56-654, €5).
|
||||||
|
|
||||||
|
### Why?
|
||||||
|
|
||||||
|
**Original firmware had critical security vulnerabilities:**
|
||||||
|
- WiFi password displayed in plaintext
|
||||||
|
- Persistent open access point running in parallel to home WiFi
|
||||||
|
- Dependency on Chinese cloud service (QWeather) requiring API key registration
|
||||||
|
- No OTA updates (required physical FTDI access for updates)
|
||||||
|
|
||||||
|
**Solution:** Complete custom firmware with security, performance, and features.
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
|
||||||
|
- ✅ **Security**: No password leaks, WiFiManager captive portal, no hardcoded credentials
|
||||||
|
- ✅ **Performance**: Fully async architecture, <1ms loop time (was 10ms+)
|
||||||
|
- ✅ **Weather**: Open-Meteo API (free, no registration, no API key)
|
||||||
|
- ✅ **Updates**: OTA via web interface + ArduinoOTA
|
||||||
|
- ✅ **Interface**: Web UI, REST API, full configuration
|
||||||
|
- ✅ **Display**: 3 rotating modes (time, weather, sunrise/sunset with daylight duration)
|
||||||
|
- ✅ **Time**: NTP sync with timezone + automatic European DST
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hardware Details
|
||||||
|
|
||||||
|
### Device Purchased
|
||||||
|
|
||||||
|
- **Product**: ESP8266 Mini Weather Clock Kit
|
||||||
|
- **Model**: TJ-56-654
|
||||||
|
- **Source**: [AliExpress Link](https://pt.aliexpress.com/item/1005008333782531.html)
|
||||||
|
- **Price**: €5 EUR (~$5.50 USD)
|
||||||
|
- **Size**: 40mm x 40mm x 43mm (transparent acrylic case)
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
#### ESP-01S WiFi Module
|
||||||
|
- **Chip**: ESP8266EX
|
||||||
|
- **Flash**: 1MB (8Mbit)
|
||||||
|
- **RAM**: 80KB total
|
||||||
|
- **CPU**: 80MHz
|
||||||
|
- **WiFi**: 802.11 b/g/n (2.4GHz only)
|
||||||
|
- **GPIO**: Only GPIO0 and GPIO2 available
|
||||||
|
- **Voltage**: 3.3V ⚠️ NOT 5V tolerant
|
||||||
|
|
||||||
|
#### Display Module
|
||||||
|
- **Model**: GM009605v4.3
|
||||||
|
- **Type**: OLED (128x64 pixels, 0.96 inches)
|
||||||
|
- **Controller**: SSD1306/SH1106 compatible
|
||||||
|
- **Interface**: I2C
|
||||||
|
- **I2C Address**: 0x3C (default), 0x3D (fallback)
|
||||||
|
- **Colors**: Monochrome (white on black)
|
||||||
|
|
||||||
|
#### Pin Mapping (CRITICAL!)
|
||||||
|
|
||||||
|
**Non-standard I2C mapping:**
|
||||||
|
- SDA: GPIO0 (not GPIO4 as typical)
|
||||||
|
- SCL: GPIO2 (not GPIO5 as typical)
|
||||||
|
|
||||||
|
This mapping is **backwards** from standard ESP8266 breakout boards!
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
Wire.begin(0, 2); // SDA=GPIO0, SCL=GPIO2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Power Supply
|
||||||
|
- Input: 5V via Micro-USB
|
||||||
|
- Current: 80-120mA typical
|
||||||
|
- Regulator: Onboard 3.3V LDO
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development History
|
||||||
|
|
||||||
|
### Timeline
|
||||||
|
|
||||||
|
**2025-12-29**: v1.5 (unreleased)
|
||||||
|
- Attempted TM1637 7-segment display support
|
||||||
|
- ❌ Wrong - device has OLED, not 7-segment LEDs
|
||||||
|
|
||||||
|
**2025-12-30**: v1.6 (unreleased)
|
||||||
|
- Attempted TM1650 LED driver support
|
||||||
|
- ❌ Wrong - I2C addresses didn't match
|
||||||
|
|
||||||
|
**2025-12-31**: v1.7 ✅
|
||||||
|
- ✅ Identified correct display: GM009605v4.3 (SSD1306-compatible OLED)
|
||||||
|
- ✅ Discovered swapped pins: SDA=GPIO0, SCL=GPIO2
|
||||||
|
- ✅ Switched to Adafruit_SSD1306 library
|
||||||
|
- ✅ Basic time display working
|
||||||
|
- ✅ WiFiManager integration
|
||||||
|
- ✅ First OTA deployment
|
||||||
|
|
||||||
|
**2026-01-01**: v1.8 🔒
|
||||||
|
- 🔒 **Security fixes**:
|
||||||
|
- Removed hardcoded WiFi credentials
|
||||||
|
- Added config validation (magic number check)
|
||||||
|
- Input sanitization (buffer overflow protection)
|
||||||
|
- 🛠️ **Stability fixes**:
|
||||||
|
- IRAM crisis fix (94% → 70% via ICACHE_FLASH_ATTR on 26 functions)
|
||||||
|
- NTP interval bug (config value was ignored)
|
||||||
|
- Boolean parsing errors in JSON import/export
|
||||||
|
- Infinite loop protection in display rotation
|
||||||
|
- ⚡ **Performance**:
|
||||||
|
- Chunked HTTP responses (eliminated 140+ String concatenations)
|
||||||
|
- Peak heap usage reduced by ~8KB
|
||||||
|
- Memory leaks fixed
|
||||||
|
|
||||||
|
**2026-01-02**: v1.9.0 ⚡
|
||||||
|
- ⚡ **Full async refactoring**:
|
||||||
|
- Async HTTP weather fetch (AsyncHTTPRequest library)
|
||||||
|
- Custom async NTP implementation (manual UDP packets)
|
||||||
|
- Async WiFi connection (state machine)
|
||||||
|
- Removed all delay() calls from loop()
|
||||||
|
- Exponential backoff retry logic
|
||||||
|
- 📊 **Performance results**:
|
||||||
|
- Loop time: 10ms → <1ms (10x improvement)
|
||||||
|
- Weather fetch: 1-10s blocking → 0ms
|
||||||
|
- NTP sync: 5-20s blocking → 0ms
|
||||||
|
- WiFi reconnect: 15s blocking → 0ms
|
||||||
|
- ❌ **Problem discovered**:
|
||||||
|
- Display blank for 10+ seconds on boot
|
||||||
|
- "DNS resolution failed" errors
|
||||||
|
|
||||||
|
**2026-01-03**: v1.9.1 (Current) 🎯
|
||||||
|
- 🔧 **Critical fix**: Hybrid WiFi model
|
||||||
|
- Synchronous WiFi in setup() (waits up to 10 seconds)
|
||||||
|
- Async WiFi reconnect in loop() (non-blocking)
|
||||||
|
- Ensures proper initialization order: WiFi → OTA → web → NTP
|
||||||
|
- 📺 **Display improvements**:
|
||||||
|
- Fixed sunrise/sunset labels cutoff (removed labels, kept arrows)
|
||||||
|
- Superscript degree symbol (°c)
|
||||||
|
- Daylight duration display instead of static "Sun Times"
|
||||||
|
- ✅ **Result**: Production ready, tested 24/7
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Status (v1.9.1)
|
||||||
|
|
||||||
|
### Version Information
|
||||||
|
|
||||||
|
**Firmware**: v1.9.1 (Production Ready)
|
||||||
|
**Released**: 2026-01-03
|
||||||
|
**Status**: Actively running 24/7, stable
|
||||||
|
|
||||||
|
### Memory Usage
|
||||||
|
|
||||||
|
| Resource | Used | Total | Usage | Status |
|
||||||
|
|----------|------|-------|-------|--------|
|
||||||
|
| Flash | 408,844 | 1,048,576 | 38% | ✅ Plenty |
|
||||||
|
| RAM | 37,644 | 80,192 | 46% | ✅ Safe |
|
||||||
|
| IRAM | 61,987 | 65,536 | 94% | ⚠️ Critical but stable |
|
||||||
|
|
||||||
|
**IRAM Note**: 94% is acceptable because:
|
||||||
|
- ICACHE_FLASH_ATTR applied to 26 functions
|
||||||
|
- Stable across v1.8 → v1.9.1
|
||||||
|
- No growth observed in testing
|
||||||
|
|
||||||
|
### Performance Metrics
|
||||||
|
|
||||||
|
- **Loop time**: <1ms (was 10ms+ before async)
|
||||||
|
- **Boot to time display**: ~15 seconds
|
||||||
|
- **WiFi connection**: 5-10 seconds (synchronous in setup)
|
||||||
|
- **NTP sync interval**: Configurable (default 1 hour)
|
||||||
|
- **Weather update interval**: Configurable (default 30 minutes)
|
||||||
|
|
||||||
|
### Uptime
|
||||||
|
|
||||||
|
- ✅ 24+ hours stable
|
||||||
|
- ✅ No memory leaks
|
||||||
|
- ✅ No crashes or reboots
|
||||||
|
- ✅ OTA updates work during operation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Architecture
|
||||||
|
|
||||||
|
### Async State Machines
|
||||||
|
|
||||||
|
#### Weather State Machine
|
||||||
|
```cpp
|
||||||
|
enum WeatherState { IDLE, REQUESTING, SUCCESS, FAILED };
|
||||||
|
```
|
||||||
|
|
||||||
|
- Library: AsyncHTTPRequest_Generic v1.13.0
|
||||||
|
- API: Open-Meteo (free, no API key)
|
||||||
|
- Callback: `onWeatherResponse()`
|
||||||
|
- Retry: Exponential backoff (1s → 2s → 4s, max 3 retries)
|
||||||
|
|
||||||
|
#### NTP State Machine
|
||||||
|
```cpp
|
||||||
|
enum NTPState { IDLE, REQUEST_SENT, WAITING, SUCCESS, FAILED };
|
||||||
|
```
|
||||||
|
|
||||||
|
- Custom manual UDP packet building/parsing
|
||||||
|
- Independent epoch tracking: `syncedEpoch`, `syncedMillis`, `timeIsSynced`
|
||||||
|
- Non-blocking UDP checks via `parsePacket()`
|
||||||
|
- Timeout: 5 seconds
|
||||||
|
- Why manual? NTPClient library is inherently blocking
|
||||||
|
|
||||||
|
#### WiFi State Machine
|
||||||
|
```cpp
|
||||||
|
enum WiFiConnectionState { IDLE, CONNECTING, CONNECTED, FAILED };
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hybrid model** (critical for v1.9.1):
|
||||||
|
- **Setup phase**: Synchronous (waits up to 10 seconds)
|
||||||
|
- Why? OTA, web server, NTP all need WiFi ready
|
||||||
|
- Prevents "DNS resolution failed" errors
|
||||||
|
- Ensures time appears on display immediately after WiFi connects
|
||||||
|
- **Loop phase**: Asynchronous (checks every 5 seconds)
|
||||||
|
- Why? Don't freeze device if WiFi drops during operation
|
||||||
|
- Graceful reconnection without user impact
|
||||||
|
|
||||||
|
### Configuration Storage
|
||||||
|
|
||||||
|
**EEPROM struct (512 bytes, 26 fields):**
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
struct Config {
|
||||||
|
char ssid[32]; // WiFi network name
|
||||||
|
char password[64]; // WiFi password
|
||||||
|
int timezone_offset; // Seconds from UTC
|
||||||
|
bool dst_enabled; // Auto DST (European rules)
|
||||||
|
uint8_t brightness; // Display brightness (0-7)
|
||||||
|
char ntp_server[64]; // NTP server address
|
||||||
|
unsigned long ntp_interval; // NTP sync interval (seconds)
|
||||||
|
bool hour_format_24; // 24h vs 12h display
|
||||||
|
char hostname[32]; // mDNS hostname
|
||||||
|
float latitude; // Weather location
|
||||||
|
float longitude; // Weather location
|
||||||
|
char city_name[32]; // Display in weather mode
|
||||||
|
bool weather_enabled; // Feature toggle
|
||||||
|
unsigned long weather_interval; // Update interval (seconds)
|
||||||
|
unsigned long display_rotation_sec; // Mode switch interval
|
||||||
|
bool show_weather; // Enable weather mode
|
||||||
|
bool show_sunrise_sunset; // Enable sunrise/sunset mode
|
||||||
|
uint8_t display_orientation; // Screen rotation (0-3)
|
||||||
|
uint32_t magic; // 0xC10CC10C - validation
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation**: Magic number check prevents loading corrupted EEPROM data.
|
||||||
|
|
||||||
|
### Display Modes
|
||||||
|
|
||||||
|
**Mode 1: Time Mode**
|
||||||
|
- Large HH:MM display
|
||||||
|
- Blinking colon (500ms interval)
|
||||||
|
- Day of week + date
|
||||||
|
- 24/12 hour format support
|
||||||
|
|
||||||
|
**Mode 2: Weather Mode**
|
||||||
|
- Temperature with superscript °c
|
||||||
|
- City name at bottom
|
||||||
|
- Example: "15.4°c" + "Portimao"
|
||||||
|
|
||||||
|
**Mode 3: Sunrise/Sunset Mode**
|
||||||
|
- Sunrise time with ↑ arrow
|
||||||
|
- Sunset time with ↓ arrow
|
||||||
|
- Daylight duration (e.g., "Day 9h 41m")
|
||||||
|
- Calculation: sunset - sunrise = total daylight minutes
|
||||||
|
|
||||||
|
**Rotation**: Configurable interval (default 5 seconds), gracefully skips disabled modes.
|
||||||
|
|
||||||
|
### Memory Optimization Techniques
|
||||||
|
|
||||||
|
**1. ICACHE_FLASH_ATTR**
|
||||||
|
|
||||||
|
Applied to 26 functions to move code from IRAM to Flash:
|
||||||
|
- All web handlers (15 functions)
|
||||||
|
- Setup functions (5 functions)
|
||||||
|
- Utilities (6 functions)
|
||||||
|
|
||||||
|
Result: IRAM usage manageable at 94%
|
||||||
|
|
||||||
|
**2. Chunked HTTP Responses**
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// ❌ BAD - 140+ concatenations
|
||||||
|
String html = "";
|
||||||
|
html += F("<!DOCTYPE html>");
|
||||||
|
html += F("<head>...");
|
||||||
|
|
||||||
|
// ✅ GOOD - Chunked transfer
|
||||||
|
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||||
|
server.send(200, "text/html", "");
|
||||||
|
server.sendContent_P(HTML_HEADER);
|
||||||
|
server.sendContent_P(HTML_FOOTER);
|
||||||
|
server.sendContent("");
|
||||||
|
```
|
||||||
|
|
||||||
|
Result: ~8KB peak heap reduction
|
||||||
|
|
||||||
|
**3. Fixed-Size Buffers**
|
||||||
|
|
||||||
|
No dynamic String allocations in loops:
|
||||||
|
```cpp
|
||||||
|
char buf[150];
|
||||||
|
snprintf_P(buf, sizeof(buf), PSTR("<div>IP: %s</div>"),
|
||||||
|
WiFi.localIP().toString().c_str());
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Issues Fixed
|
||||||
|
|
||||||
|
### Original Firmware Vulnerabilities
|
||||||
|
|
||||||
|
**1. WiFi Password Leak (CRITICAL)**
|
||||||
|
- Open access point remained active after setup
|
||||||
|
- Web interface displayed WiFi password in plaintext
|
||||||
|
- Anyone within range could connect and read password
|
||||||
|
- **Impact**: Full network compromise
|
||||||
|
|
||||||
|
**2. Cloud Dependency**
|
||||||
|
- Required QWeather API (Chinese service)
|
||||||
|
- Needed account registration + API key
|
||||||
|
- Unknown data collection practices
|
||||||
|
- **Impact**: Privacy concerns, vendor lock-in
|
||||||
|
|
||||||
|
**3. No OTA Updates**
|
||||||
|
- Required physical FTDI connection for updates
|
||||||
|
- Difficult for non-technical users
|
||||||
|
- **Impact**: Security vulnerabilities can't be patched remotely
|
||||||
|
|
||||||
|
**4. Hardcoded Credentials**
|
||||||
|
- Default WiFi credentials in source code
|
||||||
|
- No secure setup flow
|
||||||
|
- **Impact**: Easy attack vector
|
||||||
|
|
||||||
|
### Custom Firmware Solutions
|
||||||
|
|
||||||
|
**1. WiFiManager Integration ✅**
|
||||||
|
- Captive portal for secure first-time setup
|
||||||
|
- AP automatically closes after 180 seconds
|
||||||
|
- Fallback AP only on connection failure
|
||||||
|
- Password-protected fallback (customizable)
|
||||||
|
|
||||||
|
**2. Open-Meteo API ✅**
|
||||||
|
- Free weather service
|
||||||
|
- No registration required
|
||||||
|
- No API key needed
|
||||||
|
- European service (GDPR compliant)
|
||||||
|
|
||||||
|
**3. OTA Updates ✅**
|
||||||
|
- Web-based upload at `/update`
|
||||||
|
- ArduinoOTA for IDE uploads
|
||||||
|
- Password-protected (admin/admin - customizable)
|
||||||
|
- Non-blocking during operation
|
||||||
|
|
||||||
|
**4. No Hardcoded Secrets ✅**
|
||||||
|
- All credentials stored in EEPROM
|
||||||
|
- Magic number validation
|
||||||
|
- Factory reset capability
|
||||||
|
- Configuration import/export
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files & Structure
|
||||||
|
|
||||||
|
### Project Directory
|
||||||
|
|
||||||
|
**Location**: `/Users/apetrochenko/Library/Mobile Documents/com~apple~CloudDocs/src/arduino/clock/esp8266-weather-clock-opensource`
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
|
||||||
|
**Root Level:**
|
||||||
|
- `README.md` (26KB) - Main documentation (blog-style)
|
||||||
|
- `LICENSE` - MIT License
|
||||||
|
- `CHANGELOG.md` - Version history
|
||||||
|
- `CONTRIBUTING.md` - Contribution guidelines
|
||||||
|
- `PROJECT_STRUCTURE.md` - Directory layout
|
||||||
|
- `PROJECT_CONTEXT.md` - This file
|
||||||
|
- `PUBLISH_TO_GITHUB.md` - GitHub publishing guide
|
||||||
|
- `.gitignore` - Git exclusions
|
||||||
|
|
||||||
|
**Source Code:**
|
||||||
|
- `src/clock_ntp_ota_v1.9.ino` (65KB, 2,096 lines)
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
- `docs/INSTALLATION.md` (18KB) - Complete installation guide
|
||||||
|
- `docs/HARDWARE.md` (8KB) - Hardware specs + pinout
|
||||||
|
- `docs/v1.9_RELEASE_NOTES.md` (7KB) - v1.9.0 changelog
|
||||||
|
- `docs/v1.9.1_HYBRID_FIX.md` (6KB, Russian) - v1.9.1 fix explanation
|
||||||
|
|
||||||
|
**Images:**
|
||||||
|
- `images/product/` - 6 AliExpress product photos
|
||||||
|
- 01-main-product.webp
|
||||||
|
- 02-components.webp
|
||||||
|
- 03-weather-forecast.webp
|
||||||
|
- 04-temperature-display.webp
|
||||||
|
- 05-details.webp
|
||||||
|
- 06-size.webp
|
||||||
|
- `images/build/` - 3 display screenshots
|
||||||
|
- display-time.png
|
||||||
|
- display-temperature.png
|
||||||
|
- display-sunrise-sunset.png
|
||||||
|
|
||||||
|
**GitHub Config:**
|
||||||
|
- `.github/workflows/build.yml` - CI/CD pipeline
|
||||||
|
- `.github/ISSUE_TEMPLATE/bug_report.md`
|
||||||
|
- `.github/ISSUE_TEMPLATE/feature_request.md`
|
||||||
|
|
||||||
|
### Compiled Artifacts (not in git)
|
||||||
|
|
||||||
|
```
|
||||||
|
build/
|
||||||
|
├── clock_ntp_ota_v1.9.ino.bin (409KB) - Flash this via OTA
|
||||||
|
├── clock_ntp_ota_v1.9.ino.elf - Debug symbols
|
||||||
|
└── clock_ntp_ota_v1.9.ino.map - Memory map
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git Repository
|
||||||
|
|
||||||
|
### GitHub Details
|
||||||
|
|
||||||
|
- **Repository**: https://github.com/petrochen/esp8266-weather-clock-opensource
|
||||||
|
- **Owner**: petrochen (Andrey Petrochenko)
|
||||||
|
- **Visibility**: Public
|
||||||
|
- **License**: MIT
|
||||||
|
- **Created**: 2026-01-03
|
||||||
|
|
||||||
|
### Repository Configuration
|
||||||
|
|
||||||
|
**Enabled Features:**
|
||||||
|
- ✅ Issues
|
||||||
|
- ✅ Discussions
|
||||||
|
- ✅ GitHub Actions (CI/CD)
|
||||||
|
- ✅ Releases
|
||||||
|
|
||||||
|
**Topics (Tags):**
|
||||||
|
- `esp8266`, `arduino`, `iot`, `weather-station`
|
||||||
|
- `reverse-engineering`, `security`, `oled-display`
|
||||||
|
- `ntp`, `ota-updates`, `open-meteo`
|
||||||
|
|
||||||
|
**Badges:**
|
||||||
|
- Release version (auto-updates)
|
||||||
|
- License (MIT)
|
||||||
|
- Build status (CI/CD)
|
||||||
|
- Open issues count
|
||||||
|
- Hardware (ESP-01S)
|
||||||
|
- Status (Production Ready)
|
||||||
|
|
||||||
|
### Current Release
|
||||||
|
|
||||||
|
**Tag**: v1.9.1
|
||||||
|
**URL**: https://github.com/petrochen/esp8266-weather-clock-opensource/releases/tag/v1.9.1
|
||||||
|
**Status**: Production Ready
|
||||||
|
**Created**: 2026-01-03
|
||||||
|
|
||||||
|
### Git Commits
|
||||||
|
|
||||||
|
**Total**: 3 commits
|
||||||
|
1. `8f0df02` - Initial commit: v1.9.1 production firmware
|
||||||
|
2. `f2dc2fb` - Add dynamic GitHub badges to README
|
||||||
|
3. `2205445` - Update price: $12 → €5
|
||||||
|
|
||||||
|
### Web Interface URLs
|
||||||
|
|
||||||
|
- **Main**: https://github.com/petrochen/esp8266-weather-clock-opensource
|
||||||
|
- **Issues**: https://github.com/petrochen/esp8266-weather-clock-opensource/issues
|
||||||
|
- **Discussions**: https://github.com/petrochen/esp8266-weather-clock-opensource/discussions
|
||||||
|
- **Actions**: https://github.com/petrochen/esp8266-weather-clock-opensource/actions
|
||||||
|
- **Releases**: https://github.com/petrochen/esp8266-weather-clock-opensource/releases
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
### Planned Features (v1.10 or v2.0)
|
||||||
|
|
||||||
|
**1. Home Assistant Integration (High Priority)**
|
||||||
|
|
||||||
|
User specifically wants custom display screens pulling data from Home Assistant.
|
||||||
|
|
||||||
|
**Implementation ideas:**
|
||||||
|
- Add REST API client to fetch HA sensor data
|
||||||
|
- New display modes:
|
||||||
|
- Energy usage dashboard
|
||||||
|
- Room temperatures (multiple sensors)
|
||||||
|
- Air quality / CO2 levels
|
||||||
|
- Automation states (alarm, doors, lights)
|
||||||
|
- Configuration: HA server URL, access token, entity IDs
|
||||||
|
- Update interval: configurable (default 30 seconds)
|
||||||
|
|
||||||
|
**Technical approach:**
|
||||||
|
- Use AsyncHTTPRequest for non-blocking HA API calls
|
||||||
|
- JSON parsing with ArduinoJson library
|
||||||
|
- Store HA config in EEPROM (new fields)
|
||||||
|
- New web UI section for HA configuration
|
||||||
|
|
||||||
|
**2. MQTT Support**
|
||||||
|
|
||||||
|
- Publish time/weather data to MQTT broker
|
||||||
|
- Subscribe to topics for display content
|
||||||
|
- Enable automation triggers
|
||||||
|
- Library: PubSubClient (async wrapper needed)
|
||||||
|
|
||||||
|
**3. WebSocket Live Updates**
|
||||||
|
|
||||||
|
- Replace polling with WebSocket
|
||||||
|
- Real-time config changes
|
||||||
|
- Live display preview in web UI
|
||||||
|
- Push notifications for updates
|
||||||
|
|
||||||
|
**4. Multiple Weather Locations**
|
||||||
|
|
||||||
|
- Store 2-3 favorite locations
|
||||||
|
- Rotate between them
|
||||||
|
- Useful for travelers or multiple homes
|
||||||
|
|
||||||
|
**5. Display Animations**
|
||||||
|
|
||||||
|
- Smooth transitions between modes
|
||||||
|
- Weather icons (sunny, cloudy, rainy)
|
||||||
|
- Sunrise/sunset animations
|
||||||
|
|
||||||
|
### Code Quality Improvements
|
||||||
|
|
||||||
|
**1. Modular Architecture (v2.0)**
|
||||||
|
|
||||||
|
Split monolith (2,096 lines) into modules:
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.ino
|
||||||
|
├── config.h
|
||||||
|
├── display.cpp/h
|
||||||
|
├── network.cpp/h
|
||||||
|
├── weather.cpp/h
|
||||||
|
├── webserver.cpp/h
|
||||||
|
└── home_assistant.cpp/h (new)
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. ArduinoJson Integration**
|
||||||
|
|
||||||
|
Replace manual JSON parsing (173 lines) with library:
|
||||||
|
- Cleaner code
|
||||||
|
- Better error handling
|
||||||
|
- Type safety
|
||||||
|
|
||||||
|
**3. Constants Organization**
|
||||||
|
|
||||||
|
Eliminate magic numbers:
|
||||||
|
```cpp
|
||||||
|
namespace Hardware {
|
||||||
|
constexpr uint8_t I2C_SDA_PIN = 0;
|
||||||
|
constexpr uint8_t I2C_SCL_PIN = 2;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Unit Tests**
|
||||||
|
|
||||||
|
- Test state machines
|
||||||
|
- Test JSON parsing
|
||||||
|
- Test time calculations
|
||||||
|
- Mock network calls
|
||||||
|
|
||||||
|
### Documentation Improvements
|
||||||
|
|
||||||
|
**1. Video Tutorial**
|
||||||
|
|
||||||
|
- YouTube walkthrough
|
||||||
|
- FTDI connection demo
|
||||||
|
- OTA update demo
|
||||||
|
- Web configuration walkthrough
|
||||||
|
|
||||||
|
**2. Troubleshooting Flow Chart**
|
||||||
|
|
||||||
|
Visual guide for common issues:
|
||||||
|
- Display not working
|
||||||
|
- WiFi not connecting
|
||||||
|
- Time not syncing
|
||||||
|
- Weather not updating
|
||||||
|
|
||||||
|
**3. Localization**
|
||||||
|
|
||||||
|
Translate docs to:
|
||||||
|
- Russian (v1.9.1_HYBRID_FIX.md already in Russian)
|
||||||
|
- Spanish
|
||||||
|
- Portuguese
|
||||||
|
|
||||||
|
**4. Home Assistant Integration Guide**
|
||||||
|
|
||||||
|
Complete guide when HA support is added.
|
||||||
|
|
||||||
|
### Community Engagement
|
||||||
|
|
||||||
|
**1. Reddit Posts**
|
||||||
|
|
||||||
|
Subreddits to share on:
|
||||||
|
- r/esp8266
|
||||||
|
- r/arduino
|
||||||
|
- r/selfhosted
|
||||||
|
- r/homeassistant (after HA integration)
|
||||||
|
- r/ReverseEngineering
|
||||||
|
|
||||||
|
**2. Hackaday**
|
||||||
|
|
||||||
|
Submit project tip: https://hackaday.com/submit-a-tip/
|
||||||
|
|
||||||
|
**3. Hackster.io**
|
||||||
|
|
||||||
|
Create full project page with build guide.
|
||||||
|
|
||||||
|
**4. Awesome Lists**
|
||||||
|
|
||||||
|
Add to "awesome ESP8266" and "awesome IoT" lists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Learnings
|
||||||
|
|
||||||
|
### Hardware
|
||||||
|
|
||||||
|
1. **Always verify pinouts** - Don't assume standard mappings
|
||||||
|
2. **FTDI is essential** - $2 adapter unlocks any ESP8266 device
|
||||||
|
3. **Read PCB markings** - Model numbers save hours of guessing
|
||||||
|
4. **Test voltage** - ESP8266 is NOT 5V tolerant
|
||||||
|
5. **Document discoveries** - Pin mappings, I2C addresses, display models
|
||||||
|
|
||||||
|
### Software
|
||||||
|
|
||||||
|
1. **Async is hard but worth it** - Fully non-blocking eliminates freezes
|
||||||
|
2. **IRAM is precious** - Use ICACHE_FLASH_ATTR liberally on ESP8266
|
||||||
|
3. **Hybrid approaches work** - Don't be dogmatic (sync WiFi in setup() was correct)
|
||||||
|
4. **State machines scale** - Better than callback hell for complex async
|
||||||
|
5. **Test on real hardware** - Emulators miss pin issues and memory constraints
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
1. **IoT security is often terrible** - Always audit before trusting
|
||||||
|
2. **Open source is safer** - Closed firmware is a black box
|
||||||
|
3. **Defaults matter** - Insecure defaults (open AP, plaintext passwords) are vulnerabilities
|
||||||
|
4. **Defense in depth** - Multiple layers catch mistakes
|
||||||
|
5. **Update mechanism is critical** - OTA enables security patches
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
1. **OTA from day 1** - FTDI flashing gets old fast
|
||||||
|
2. **Version control** - Backups (.bak, .bak2) saved the project multiple times
|
||||||
|
3. **Document as you go** - Release notes prevent "what was I thinking?" moments
|
||||||
|
4. **Incremental improvements** - v1.7 → v1.8 → v1.9.x made debugging manageable
|
||||||
|
5. **User testing** - Photos from user revealed display cutoff issues
|
||||||
|
|
||||||
|
### Project Management
|
||||||
|
|
||||||
|
1. **Understand user needs** - User wanted Home Assistant integration (plan for it)
|
||||||
|
2. **Security first** - Privacy/security was main motivation
|
||||||
|
3. **Performance matters** - 10ms loop → <1ms dramatically improves UX
|
||||||
|
4. **Documentation is product** - Good docs = more users = more contributors
|
||||||
|
5. **Publish early** - GitHub repo enables community contributions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### Current Device Configuration
|
||||||
|
|
||||||
|
**Hardware:**
|
||||||
|
- Device: TJ-56-654 Weather Clock
|
||||||
|
- Location: User's home network
|
||||||
|
- IP: 192.168.2.47
|
||||||
|
- Hostname: tj56654-clock.local
|
||||||
|
|
||||||
|
**Software:**
|
||||||
|
- Firmware: v1.9.1
|
||||||
|
- WiFi: SibWings
|
||||||
|
- Weather: Portimao, Portugal (37.19°N, 8.54°W)
|
||||||
|
- Timezone: UTC+0 (Lisbon) with auto DST
|
||||||
|
- NTP: pool.ntp.org (1 hour interval)
|
||||||
|
|
||||||
|
**Access:**
|
||||||
|
- Web UI: http://192.168.2.47 or http://tj56654-clock.local
|
||||||
|
- OTA Update: http://192.168.2.47/update (admin/admin)
|
||||||
|
- API Base: http://192.168.2.47/api/
|
||||||
|
|
||||||
|
### Important Commands
|
||||||
|
|
||||||
|
**Compile:**
|
||||||
|
```bash
|
||||||
|
arduino-cli compile --fqbn esp8266:esp8266:generic \
|
||||||
|
src/clock_ntp_ota_v1.9.ino
|
||||||
|
```
|
||||||
|
|
||||||
|
**Upload OTA:**
|
||||||
|
```bash
|
||||||
|
curl -u admin:admin \
|
||||||
|
-F "file=@build/clock_ntp_ota_v1.9.ino.bin" \
|
||||||
|
http://192.168.2.47/update
|
||||||
|
```
|
||||||
|
|
||||||
|
**Check status:**
|
||||||
|
```bash
|
||||||
|
curl http://192.168.2.47/api/status | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
**View logs:**
|
||||||
|
```bash
|
||||||
|
# Connect via serial (if FTDI attached)
|
||||||
|
screen /dev/cu.usbserial* 115200
|
||||||
|
```
|
||||||
|
|
||||||
|
### Library Dependencies
|
||||||
|
|
||||||
|
Required libraries (install via Arduino Library Manager):
|
||||||
|
|
||||||
|
| Library | Version | Purpose |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| Adafruit GFX Library | 1.11.0+ | Graphics primitives |
|
||||||
|
| Adafruit SSD1306 | 2.5.0+ | OLED display driver |
|
||||||
|
| NTPClient | 3.2.0+ | NTP time sync (base) |
|
||||||
|
| WiFiManager | 2.0.0+ | Captive portal |
|
||||||
|
| AsyncHTTPRequest_Generic | 1.13.0+ | Async weather fetch |
|
||||||
|
| ESPAsyncTCP | 1.2.2+ | Async TCP layer |
|
||||||
|
|
||||||
|
### External APIs
|
||||||
|
|
||||||
|
**Open-Meteo:**
|
||||||
|
- URL: https://api.open-meteo.com/v1/forecast
|
||||||
|
- Authentication: None (free, no API key)
|
||||||
|
- Rate limit: None (reasonable use)
|
||||||
|
- Documentation: https://open-meteo.com/en/docs
|
||||||
|
|
||||||
|
**NTP:**
|
||||||
|
- Default: pool.ntp.org
|
||||||
|
- Protocol: UDP port 123
|
||||||
|
- Fallbacks: time.google.com, time.cloudflare.com
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contact & Collaboration
|
||||||
|
|
||||||
|
**GitHub**: https://github.com/petrochen/esp8266-weather-clock-opensource
|
||||||
|
**Issues**: https://github.com/petrochen/esp8266-weather-clock-opensource/issues
|
||||||
|
**Discussions**: https://github.com/petrochen/esp8266-weather-clock-opensource/discussions
|
||||||
|
|
||||||
|
**Contributions welcome!** See [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This project successfully transformed a €5 AliExpress IoT device with critical security vulnerabilities into a fully secure, high-performance, feature-rich smart clock with open-source firmware.
|
||||||
|
|
||||||
|
**Key achievements:**
|
||||||
|
- ✅ Eliminated WiFi password leak vulnerability
|
||||||
|
- ✅ Achieved <1ms loop time (10x performance improvement)
|
||||||
|
- ✅ Enabled OTA updates (no more FTDI wiring)
|
||||||
|
- ✅ Free weather API (no registration)
|
||||||
|
- ✅ Full async architecture (zero blocking)
|
||||||
|
- ✅ Production-ready stability (24/7 uptime)
|
||||||
|
- ✅ Open-source on GitHub (MIT license)
|
||||||
|
- ✅ Comprehensive documentation (100KB+ docs)
|
||||||
|
|
||||||
|
**Next milestone**: Home Assistant integration for custom display screens.
|
||||||
|
|
||||||
|
**Status**: Project complete and ready for community contributions! 🚀
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last session work (2026-01-03):**
|
||||||
|
1. ✅ Compiled v1.9.1 with daylight duration feature
|
||||||
|
2. ✅ Uploaded via OTA to device (192.168.2.47)
|
||||||
|
3. ✅ Created complete GitHub repository structure
|
||||||
|
4. ✅ Published to https://github.com/petrochen/esp8266-weather-clock-opensource
|
||||||
|
5. ✅ Created release v1.9.1
|
||||||
|
6. ✅ Added badges, topics, documentation
|
||||||
|
7. ✅ Fixed price ($12 → €5)
|
||||||
|
8. ✅ Saved full project context
|
||||||
|
|
||||||
|
**Repository ready for sharing on Reddit, Hackaday, and other communities!**
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
# Reverse Engineering a $12 AliExpress Weather Clock: A Security Story
|
# Reverse Engineering a €5 AliExpress Weather Clock: A Security Story
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/ESP8266-ESP--01S-blue?style=flat-square" />
|
<a href="https://github.com/petrochen/esp8266-weather-clock-opensource/releases">
|
||||||
<img src="https://img.shields.io/badge/Firmware-v1.9.1-green?style=flat-square" />
|
<img src="https://img.shields.io/github/v/release/petrochen/esp8266-weather-clock-opensource?style=flat-square&label=Release&color=green" alt="Release">
|
||||||
<img src="https://img.shields.io/badge/OTA-Enabled-orange?style=flat-square" />
|
</a>
|
||||||
<img src="https://img.shields.io/badge/Status-Production%20Ready-brightgreen?style=flat-square" />
|
<a href="https://github.com/petrochen/esp8266-weather-clock-opensource/blob/main/LICENSE">
|
||||||
|
<img src="https://img.shields.io/github/license/petrochen/esp8266-weather-clock-opensource?style=flat-square&label=License&color=blue" alt="License">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/petrochen/esp8266-weather-clock-opensource/issues">
|
||||||
|
<img src="https://img.shields.io/github/issues/petrochen/esp8266-weather-clock-opensource?style=flat-square&label=Issues&color=orange" alt="Issues">
|
||||||
|
</a>
|
||||||
|
<img src="https://img.shields.io/badge/ESP8266-ESP--01S-blue?style=flat-square" alt="Hardware">
|
||||||
|
<img src="https://img.shields.io/badge/Status-Production%20Ready-brightgreen?style=flat-square" alt="Status">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## TL;DR
|
## TL;DR
|
||||||
@@ -20,7 +27,7 @@ I bought a cute weather clock kit from AliExpress ([TJ-56-654](https://pt.aliexp
|
|||||||
- [The Investigation](#the-investigation)
|
- [The Investigation](#the-investigation)
|
||||||
- [The Solution: Custom Firmware](#the-solution-custom-firmware)
|
- [The Solution: Custom Firmware](#the-solution-custom-firmware)
|
||||||
- [Technical Deep Dive](#technical-deep-dive)
|
- [Technical Deep Dive](#technical-deep-dive)
|
||||||
- [The Journey: v1.7 → v1.9.1](#the-journey-v17--v191)
|
- [The Journey: v1.7 → v1.9.2](#the-journey-v17--v192)
|
||||||
- [What's Next: Home Assistant Integration](#whats-next-home-assistant-integration)
|
- [What's Next: Home Assistant Integration](#whats-next-home-assistant-integration)
|
||||||
- [How to Flash This Firmware](#how-to-flash-this-firmware)
|
- [How to Flash This Firmware](#how-to-flash-this-firmware)
|
||||||
- [Web Interface](#web-interface)
|
- [Web Interface](#web-interface)
|
||||||
@@ -66,7 +73,7 @@ This is a textbook example of poor IoT security design. No thanks.
|
|||||||
|
|
||||||
**Product**: ESP8266 Mini Weather Clock Kit
|
**Product**: ESP8266 Mini Weather Clock Kit
|
||||||
**Model**: TJ-56-654
|
**Model**: TJ-56-654
|
||||||
**Price**: ~$12 USD
|
**Price**: ~€5 EUR
|
||||||
**Source**: [AliExpress Link](https://pt.aliexpress.com/item/1005008333782531.html)
|
**Source**: [AliExpress Link](https://pt.aliexpress.com/item/1005008333782531.html)
|
||||||
|
|
||||||
### Original Hardware Specifications
|
### Original Hardware Specifications
|
||||||
@@ -368,7 +375,7 @@ Wire.begin(0, 2); // SDA=GPIO0, SCL=GPIO2
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The Journey: v1.7 → v1.9.1
|
## The Journey: v1.7 → v1.9.2
|
||||||
|
|
||||||
### v1.7: Display Discovery ✅
|
### v1.7: Display Discovery ✅
|
||||||
|
|
||||||
@@ -413,7 +420,7 @@ Wire.begin(0, 2); // SDA=GPIO0, SCL=GPIO2
|
|||||||
|
|
||||||
**Result**: Device stays responsive during OTA updates while weather is fetching!
|
**Result**: Device stays responsive during OTA updates while weather is fetching!
|
||||||
|
|
||||||
### v1.9.1: Hybrid Fix (Current) 🎯
|
### v1.9.1: Hybrid Fix 🎯
|
||||||
|
|
||||||
**Problem Discovered:**
|
**Problem Discovered:**
|
||||||
|
|
||||||
@@ -444,6 +451,59 @@ void setup() {
|
|||||||
- ✅ Proper initialization order guaranteed
|
- ✅ Proper initialization order guaranteed
|
||||||
- ✅ Device never freezes on WiFi loss during operation
|
- ✅ Device never freezes on WiFi loss during operation
|
||||||
|
|
||||||
|
### v1.9.2: WiFi Resilience (Current) 🛡️
|
||||||
|
|
||||||
|
**Problem Discovered:**
|
||||||
|
|
||||||
|
After WiFi outages, the device would **clear stored credentials** and enter AP mode, requiring manual reconfiguration every time the router restarted.
|
||||||
|
|
||||||
|
**Root Cause:**
|
||||||
|
|
||||||
|
Aggressive credential clearing on connection failure:
|
||||||
|
```cpp
|
||||||
|
if (wifiRetry.currentRetry >= wifiRetry.maxRetries) {
|
||||||
|
memset(config.ssid, 0, sizeof(config.ssid)); // ❌ Clears credentials!
|
||||||
|
memset(config.password, 0, sizeof(config.password));
|
||||||
|
saveConfig();
|
||||||
|
// Enter AP mode...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution: Resilient WiFi**
|
||||||
|
|
||||||
|
| Feature | Before (v1.9.1) | After (v1.9.2) |
|
||||||
|
|---------|-----------------|----------------|
|
||||||
|
| Credential clearing | After 5 failed attempts | Never |
|
||||||
|
| Retry strategy | Give up after 5 tries | Infinite with backoff |
|
||||||
|
| Max retry interval | N/A | 5 minutes |
|
||||||
|
| Fallback AP | After clearing credentials | After ~5 min (dual STA+AP mode) |
|
||||||
|
| Clock during outage | Blank display | Shows last synced time |
|
||||||
|
|
||||||
|
**Key Changes:**
|
||||||
|
- **Never clear credentials** on connection failure
|
||||||
|
- **Exponential backoff**: 5s → 10s → 20s → ... → 5min max
|
||||||
|
- **Fallback AP** ("TJ56654-Setup") enabled after ~5 min, while still retrying
|
||||||
|
- **Dual STA+AP mode**: Device continues reconnect attempts while AP is active
|
||||||
|
- **SDK credentials support**: Tries WiFiManager-stored credentials first, then EEPROM
|
||||||
|
- **"No WiFi" display**: Shows retry countdown instead of cryptic numbers
|
||||||
|
- **"!" indicator**: Shown in date line when WiFi disconnected
|
||||||
|
|
||||||
|
**Network Activity Summary:**
|
||||||
|
|
||||||
|
| Service | Interval | Endpoint | Protocol |
|
||||||
|
|---------|----------|----------|----------|
|
||||||
|
| NTP | 1 hour | pool.ntp.org:123 | UDP |
|
||||||
|
| Weather | 30 min | api.open-meteo.com | HTTP |
|
||||||
|
| mDNS | continuous | 224.0.0.251 | UDP multicast |
|
||||||
|
|
||||||
|
~50 requests/day total.
|
||||||
|
|
||||||
|
**Results:**
|
||||||
|
- ✅ Credentials persist through WiFi outages
|
||||||
|
- ✅ Device automatically reconnects when WiFi returns
|
||||||
|
- ✅ Clock continues running with last synced time
|
||||||
|
- ✅ User can reconfigure via fallback AP if needed
|
||||||
|
|
||||||
**Startup Timeline:**
|
**Startup Timeline:**
|
||||||
```
|
```
|
||||||
[0-5s] Display init, startup animation
|
[0-5s] Display init, startup animation
|
||||||
@@ -462,7 +522,8 @@ void setup() {
|
|||||||
| v1.7 | 34,980 (43%) | **61,987 (94%)** | 407,500 (38%) | IRAM crisis |
|
| v1.7 | 34,980 (43%) | **61,987 (94%)** | 407,500 (38%) | IRAM crisis |
|
||||||
| v1.8 | 36,980 (46%) | **45,120 (68%)** | 407,800 (38%) | ICACHE_FLASH_ATTR fix |
|
| v1.8 | 36,980 (46%) | **45,120 (68%)** | 407,800 (38%) | ICACHE_FLASH_ATTR fix |
|
||||||
| v1.9.0 | 37,516 (46%) | **61,987 (94%)** | 408,540 (38%) | Async libs added |
|
| v1.9.0 | 37,516 (46%) | **61,987 (94%)** | 408,540 (38%) | Async libs added |
|
||||||
| v1.9.1 | 37,644 (46%) | **61,987 (94%)** | 408,844 (38%) | Production ready |
|
| v1.9.1 | 37,644 (46%) | **61,987 (94%)** | 408,844 (38%) | Hybrid WiFi fix |
|
||||||
|
| v1.9.2 | 37,800 (47%) | **61,987 (94%)** | 409,100 (39%) | WiFi resilience |
|
||||||
|
|
||||||
**Verdict**: Stable memory usage, no leaks detected after 24h+ uptime tests.
|
**Verdict**: Stable memory usage, no leaks detected after 24h+ uptime tests.
|
||||||
|
|
||||||
@@ -858,15 +919,18 @@ Device reboots immediately.
|
|||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
clock_ntp_ota_v1.9/
|
esp8266-weather-clock/
|
||||||
├── clock_ntp_ota_v1.9.ino # Main firmware (2,096 lines)
|
├── firmware/
|
||||||
├── v1.9_RELEASE_NOTES.md # Detailed changelog
|
│ └── clock_ntp_ota_v1.9/
|
||||||
├── v1.9.1_HYBRID_FIX.md # WiFi startup fix documentation
|
│ └── clock_ntp_ota_v1.9.ino # Main firmware (~2,100 lines)
|
||||||
├── README.md # This file
|
├── docs/
|
||||||
└── build/
|
│ ├── HARDWARE.md # Hardware specifications
|
||||||
├── clock_ntp_ota_v1.9.ino.bin # Compiled firmware
|
│ ├── INSTALLATION.md # Flashing guide
|
||||||
├── clock_ntp_ota_v1.9.ino.elf # Debug symbols
|
│ ├── v1.9_RELEASE_NOTES.md # v1.9.0 async refactoring
|
||||||
└── clock_ntp_ota_v1.9.ino.map # Memory map
|
│ ├── v1.9.1_HYBRID_FIX.md # WiFi startup fix
|
||||||
|
│ └── v1.9.2_WIFI_RESILIENCE.md # WiFi resilience documentation
|
||||||
|
├── CHANGELOG.md # Version history
|
||||||
|
└── README.md # This file
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -889,7 +953,7 @@ The original firmware had security holes you could drive a truck through. The cu
|
|||||||
- ✅ Integrates with Home Assistant (coming soon)
|
- ✅ Integrates with Home Assistant (coming soon)
|
||||||
- ✅ Is completely auditable (you're reading the source)
|
- ✅ Is completely auditable (you're reading the source)
|
||||||
|
|
||||||
Total cost: $12 hardware + a weekend of tinkering.
|
Total cost: €5 hardware + a weekend of tinkering.
|
||||||
|
|
||||||
If you have one of these devices, **flash this firmware**. If you're buying IoT gadgets, **always audit them first**. And if something seems insecure, **fix it yourself** - that's the hacker spirit.
|
If you have one of these devices, **flash this firmware**. If you're buying IoT gadgets, **always audit them first**. And if something seems insecure, **fix it yourself** - that's the hacker spirit.
|
||||||
|
|
||||||
@@ -899,7 +963,6 @@ Now go make something cool. 🚀
|
|||||||
|
|
||||||
**P.S.**: If you found this useful, consider starring the repo. If you found a bug, open an issue. If you want to add Home Assistant screens, let's collaborate - I'm planning that next!
|
**P.S.**: If you found this useful, consider starring the repo. If you found a bug, open an issue. If you want to add Home Assistant screens, let's collaborate - I'm planning that next!
|
||||||
|
|
||||||
**Built with**: Claude Code (Opus 4.5)
|
|
||||||
**Author**: apetrochenko
|
**Author**: apetrochenko
|
||||||
**Date**: 2026-01-03
|
**Date**: 2026-01-06
|
||||||
**Firmware Version**: v1.9.1 (Production Ready)
|
**Firmware Version**: v1.9.2 (Production Ready)
|
||||||
|
|||||||
+135
-135
@@ -1,71 +1,71 @@
|
|||||||
# v1.9.1 - Hybrid Async Fix
|
# v1.9.1 - Hybrid Async Fix
|
||||||
|
|
||||||
## Проблема в v1.9.0
|
## Problem in v1.9.0
|
||||||
|
|
||||||
**Симптомы:**
|
**Symptoms:**
|
||||||
- Дисплей показывает пустой экран ~10 секунд после загрузки
|
- Display shows blank screen ~10 seconds after boot
|
||||||
- Ошибка "DNS resolution failed" в логах
|
- "DNS resolution failed" errors in logs
|
||||||
- Время появляется только через 10+ секунд
|
- Time appears only after 10+ seconds
|
||||||
|
|
||||||
**Причина:**
|
**Root Cause:**
|
||||||
```cpp
|
```cpp
|
||||||
void setup() {
|
void setup() {
|
||||||
loadConfig();
|
loadConfig();
|
||||||
setupWiFi(); // ← Возвращается СРАЗУ (async)
|
setupWiFi(); // Returns IMMEDIATELY (async)
|
||||||
setupOTA(); // ← WiFi НЕ готов! ✗
|
setupOTA(); // WiFi NOT ready!
|
||||||
setupWebServer(); // ← WiFi НЕ готов! ✗
|
setupWebServer(); // WiFi NOT ready!
|
||||||
testInternetConnectivity(); // ← WiFi НЕ готов! → "DNS resolution failed"
|
testInternetConnectivity(); // WiFi NOT ready! -> "DNS resolution failed"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
WiFi стал **полностью асинхронным**, но это **неправильно для setup()**:
|
WiFi became **fully asynchronous**, but this is **wrong for setup()**:
|
||||||
- OTA, web server, NTP **требуют готовое WiFi соединение**
|
- OTA, web server, NTP **require a ready WiFi connection**
|
||||||
- `testInternetConnectivity()` запускался **до подключения WiFi**
|
- `testInternetConnectivity()` ran **before WiFi connected**
|
||||||
- Время на дисплее появлялось только когда async WiFi наконец подключался
|
- Time on display appeared only when async WiFi finally connected
|
||||||
|
|
||||||
## Решение: Гибридная модель
|
## Solution: Hybrid Model
|
||||||
|
|
||||||
| Фаза | WiFi режим | Блокировка | Причина |
|
| Phase | WiFi Mode | Blocking | Reason |
|
||||||
|------|------------|------------|---------|
|
|-------|-----------|----------|--------|
|
||||||
| **setup()** | **Синхронный** | 10 сек | Нужен для инициализации OTA/web/NTP |
|
| **setup()** | **Synchronous** | 10 sec | Required for OTA/web/NTP initialization |
|
||||||
| **loop()** | **Асинхронный** | 0 сек | Не замораживать при reconnect |
|
| **loop()** | **Asynchronous** | 0 sec | Don't freeze on reconnect |
|
||||||
|
|
||||||
### Изменения в коде
|
### Code Changes
|
||||||
|
|
||||||
#### 1. setupWiFi() - теперь синхронный
|
#### 1. setupWiFi() - Now Synchronous
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
void ICACHE_FLASH_ATTR setupWiFi() {
|
void ICACHE_FLASH_ATTR setupWiFi() {
|
||||||
Serial.println("WiFi Setup - Synchronous for initial connection");
|
Serial.println("WiFi Setup - Synchronous for initial connection");
|
||||||
|
|
||||||
WiFi.hostname(config.hostname);
|
WiFi.hostname(config.hostname);
|
||||||
|
|
||||||
if (strlen(config.ssid) > 0) {
|
if (strlen(config.ssid) > 0) {
|
||||||
WiFi.mode(WIFI_STA);
|
WiFi.mode(WIFI_STA);
|
||||||
WiFi.begin(config.ssid, config.password);
|
WiFi.begin(config.ssid, config.password);
|
||||||
|
|
||||||
// СИНХРОННОЕ ожидание (max 10 секунд)
|
// SYNCHRONOUS wait (max 10 seconds)
|
||||||
int attempts = 0;
|
int attempts = 0;
|
||||||
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
|
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
|
||||||
delay(500);
|
delay(500);
|
||||||
showNumber(attempts, false); // Показываем прогресс на дисплее
|
showNumber(attempts, false); // Show progress on display
|
||||||
attempts++;
|
attempts++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (WiFi.status() == WL_CONNECTED) {
|
if (WiFi.status() == WL_CONNECTED) {
|
||||||
// ✅ WiFi готов для OTA/web/NTP!
|
// WiFi ready for OTA/web/NTP!
|
||||||
showIP();
|
showIP();
|
||||||
wifiConnState = WIFI_CONN_CONNECTED;
|
wifiConnState = WIFI_CONN_CONNECTED;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to WiFiManager если credentials не сработали
|
// Fallback to WiFiManager if credentials didn't work
|
||||||
// ...
|
// ...
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2. loop() - async reconnect
|
#### 2. loop() - Async Reconnect
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
void loop() {
|
void loop() {
|
||||||
@@ -73,146 +73,146 @@ void loop() {
|
|||||||
static unsigned long lastWiFiCheck = 0;
|
static unsigned long lastWiFiCheck = 0;
|
||||||
if (millis() - lastWiFiCheck > 5000) {
|
if (millis() - lastWiFiCheck > 5000) {
|
||||||
if (WiFi.status() != WL_CONNECTED && wifiConnState == WIFI_CONN_CONNECTED) {
|
if (WiFi.status() != WL_CONNECTED && wifiConnState == WIFI_CONN_CONNECTED) {
|
||||||
Serial.println("⚠️ WiFi disconnected, attempting async reconnect...");
|
Serial.println("WiFi disconnected, attempting async reconnect...");
|
||||||
WiFi.begin(); // Async reconnect
|
WiFi.begin(); // Async reconnect
|
||||||
wifiConnState = WIFI_CONN_CONNECTING;
|
wifiConnState = WIFI_CONN_CONNECTING;
|
||||||
wifiConnectStart = millis();
|
wifiConnectStart = millis();
|
||||||
}
|
}
|
||||||
lastWiFiCheck = millis();
|
lastWiFiCheck = millis();
|
||||||
}
|
}
|
||||||
|
|
||||||
processWiFiConnection(); // Async обработка reconnect
|
processWiFiConnection(); // Async reconnect handling
|
||||||
|
|
||||||
// Остальные async операции
|
// Other async operations
|
||||||
processNTPResponse();
|
processNTPResponse();
|
||||||
fetchWeatherAsync();
|
fetchWeatherAsync();
|
||||||
// ...
|
// ...
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Результаты тестирования
|
## Test Results
|
||||||
|
|
||||||
### До (v1.9.0)
|
### Before (v1.9.0)
|
||||||
```
|
```
|
||||||
⏱️ 0-5s → Display init
|
[0-5s] -> Display init
|
||||||
⏱️ 5-15s → WiFi connecting (async, setup() возвращается сразу)
|
[5-15s] -> WiFi connecting (async, setup() returns immediately)
|
||||||
⏱️ 15-20s → OTA/web init БЕЗ WiFi → ✗ Errors!
|
[15-20s] -> OTA/web init WITHOUT WiFi -> Errors!
|
||||||
⏱️ 20s → testInternetConnectivity() БЕЗ WiFi → "DNS resolution failed"
|
[20s] -> testInternetConnectivity() WITHOUT WiFi -> "DNS resolution failed"
|
||||||
⏱️ 15-25s → WiFi finally connects (async)
|
[15-25s] -> WiFi finally connects (async)
|
||||||
⏱️ 25-30s → NTP sync начинается
|
[25-30s] -> NTP sync begins
|
||||||
|
|
||||||
❌ Display blank for 10+ seconds
|
Display blank for 10+ seconds
|
||||||
❌ "DNS resolution failed" errors
|
"DNS resolution failed" errors
|
||||||
```
|
```
|
||||||
|
|
||||||
### После (v1.9.1)
|
### After (v1.9.1)
|
||||||
```
|
```
|
||||||
⏱️ 0-5s → Display init + startup animation
|
[0-5s] -> Display init + startup animation
|
||||||
⏱️ 5-15s → WiFi connection (SYNCHRONOUS, setup() waits)
|
[5-15s] -> WiFi connection (SYNCHRONOUS, setup() waits)
|
||||||
✅ WiFi connected!
|
WiFi connected!
|
||||||
⏱️ 15-20s → OTA/web/NTP init С WiFi
|
[15-20s] -> OTA/web/NTP init WITH WiFi
|
||||||
✅ Internet test: PASSED
|
Internet test: PASSED
|
||||||
✅ No DNS errors!
|
No DNS errors!
|
||||||
⏱️ 20-30s → First async NTP sync
|
[20-30s] -> First async NTP sync
|
||||||
✅ Time synced!
|
Time synced!
|
||||||
|
|
||||||
✅ Display shows time immediately after WiFi connects (~15 sec)
|
Display shows time immediately after WiFi connects (~15 sec)
|
||||||
✅ No "DNS resolution failed" errors
|
No "DNS resolution failed" errors
|
||||||
✅ Proper initialization order
|
Proper initialization order
|
||||||
```
|
```
|
||||||
|
|
||||||
## Startup Timeline
|
## Startup Timeline
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────────────┐
|
+----------------------------------------------------------+
|
||||||
│ SETUP PHASE (Synchronous WiFi) │
|
| SETUP PHASE (Synchronous WiFi) |
|
||||||
├──────────────────────────────────────────────────────────┤
|
+----------------------------------------------------------+
|
||||||
│ │
|
| |
|
||||||
│ [0s] ┌─────────────┐ │
|
| [0s] +-------------+ |
|
||||||
│ │ Display │ Startup animation │
|
| | Display | Startup animation |
|
||||||
│ [5s] │ Init │ "Weather Clock v1.9.1" │
|
| [5s] | Init | "Weather Clock v1.9.1" |
|
||||||
│ └─────────────┘ │
|
| +-------------+ |
|
||||||
│ │
|
| |
|
||||||
│ [5s] ┌─────────────────────────────────┐ │
|
| [5s] +---------------------------------+ |
|
||||||
│ │ WiFi Connect (SYNCHRONOUS) │ │
|
| | WiFi Connect (SYNCHRONOUS) | |
|
||||||
│ │ - Connecting to SibWings... │ │
|
| | - Connecting to network... | |
|
||||||
│ [15s] │ - ✅ Connected! IP assigned │ │
|
| [15s] | - Connected! IP assigned | |
|
||||||
│ └─────────────────────────────────┘ │
|
| +---------------------------------+ |
|
||||||
│ ↓ │
|
| | |
|
||||||
│ WiFi is READY here ✅ │
|
| WiFi is READY here |
|
||||||
│ ↓ │
|
| | |
|
||||||
│ [15s] ┌─────────────────────────────────┐ │
|
| [15s] +---------------------------------+ |
|
||||||
│ │ OTA Init (needs WiFi ✅) │ │
|
| | OTA Init (needs WiFi) | |
|
||||||
│ │ Web Server (needs WiFi ✅) │ │
|
| | Web Server (needs WiFi) | |
|
||||||
│ [20s] │ NTP Client (needs WiFi ✅) │ │
|
| [20s] | NTP Client (needs WiFi) | |
|
||||||
│ │ Internet Test (needs WiFi ✅) │ │
|
| | Internet Test (needs WiFi) | |
|
||||||
│ └─────────────────────────────────┘ │
|
| +---------------------------------+ |
|
||||||
│ │
|
| |
|
||||||
│ [20s] Setup complete! → loop() starts │
|
| [20s] Setup complete! -> loop() starts |
|
||||||
│ │
|
| |
|
||||||
└──────────────────────────────────────────────────────────┘
|
+----------------------------------------------------------+
|
||||||
|
|
||||||
┌──────────────────────────────────────────────────────────┐
|
+----------------------------------------------------------+
|
||||||
│ LOOP PHASE (Async Operations) │
|
| LOOP PHASE (Async Operations) |
|
||||||
├──────────────────────────────────────────────────────────┤
|
+----------------------------------------------------------+
|
||||||
│ │
|
| |
|
||||||
│ [Every loop] ┌──────────────────────────┐ │
|
| [Every loop] +------------------------+ |
|
||||||
│ │ WiFi Health Check │ │
|
| | WiFi Health Check | |
|
||||||
│ │ (every 5 sec) │ │
|
| | (every 5 sec) | |
|
||||||
│ │ If disconnected: │ │
|
| | If disconnected: | |
|
||||||
│ │ → Async reconnect │ │
|
| | -> Async reconnect | |
|
||||||
│ └──────────────────────────┘ │
|
| +------------------------+ |
|
||||||
│ │
|
| |
|
||||||
│ [Every loop] ┌──────────────────────────┐ │
|
| [Every loop] +------------------------+ |
|
||||||
│ │ Async NTP Processing │ │
|
| | Async NTP Processing | |
|
||||||
│ │ (non-blocking) │ │
|
| | (non-blocking) | |
|
||||||
│ └──────────────────────────┘ │
|
| +------------------------+ |
|
||||||
│ │
|
| |
|
||||||
│ [Every 30m] ┌──────────────────────────┐ │
|
| [Every 30m] +------------------------+ |
|
||||||
│ │ Async Weather Fetch │ │
|
| | Async Weather Fetch | |
|
||||||
│ │ (non-blocking) │ │
|
| | (non-blocking) | |
|
||||||
│ └──────────────────────────┘ │
|
| +------------------------+ |
|
||||||
│ │
|
| |
|
||||||
│ Loop time: <1ms (no blocking!) │
|
| Loop time: <1ms (no blocking!) |
|
||||||
│ │
|
| |
|
||||||
└──────────────────────────────────────────────────────────┘
|
+----------------------------------------------------------+
|
||||||
```
|
```
|
||||||
|
|
||||||
## Преимущества гибридного подхода
|
## Benefits of Hybrid Approach
|
||||||
|
|
||||||
### ✅ В setup():
|
### In setup():
|
||||||
1. **Правильный порядок инициализации** - WiFi → OTA → web → NTP
|
1. **Correct initialization order** - WiFi -> OTA -> web -> NTP
|
||||||
2. **Нет ошибок DNS** - internet connectivity test запускается ПОСЛЕ WiFi
|
2. **No DNS errors** - internet connectivity test runs AFTER WiFi
|
||||||
3. **Предсказуемое поведение** - setup() завершается когда всё готово
|
3. **Predictable behavior** - setup() completes when everything is ready
|
||||||
4. **Дисплей показывает время сразу** - не нужно ждать async WiFi
|
4. **Display shows time immediately** - no need to wait for async WiFi
|
||||||
|
|
||||||
### ✅ В loop():
|
### In loop():
|
||||||
1. **Не зависает при reconnect** - async обработка потери WiFi
|
1. **No freeze on reconnect** - async handling of WiFi loss
|
||||||
2. **Async NTP** - не блокирует loop
|
2. **Async NTP** - doesn't block loop
|
||||||
3. **Async weather** - не блокирует loop
|
3. **Async weather** - doesn't block loop
|
||||||
4. **Exponential backoff** - умные retry при ошибках
|
4. **Exponential backoff** - smart retry on errors
|
||||||
5. **Loop <1ms** - всегда отзывчивое устройство
|
5. **Loop <1ms** - always responsive device
|
||||||
|
|
||||||
## Память
|
## Memory
|
||||||
|
|
||||||
| Ресурс | v1.9.0 | v1.9.1 | Изменение |
|
| Resource | v1.9.0 | v1.9.1 | Change |
|
||||||
|--------|--------|--------|-----------|
|
|----------|--------|--------|--------|
|
||||||
| RAM | 37,516 | 37,644 | +128 bytes |
|
| RAM | 37,516 | 37,644 | +128 bytes |
|
||||||
| IRAM | 61,987 | 61,987 | 0 bytes |
|
| IRAM | 61,987 | 61,987 | 0 bytes |
|
||||||
| Flash | 408,540 | 408,844 | +304 bytes |
|
| Flash | 408,540 | 408,844 | +304 bytes |
|
||||||
|
|
||||||
Минимальные изменения памяти (+0.3%) для критического улучшения UX.
|
Minimal memory changes (+0.3%) for critical UX improvement.
|
||||||
|
|
||||||
## Заключение
|
## Conclusion
|
||||||
|
|
||||||
**v1.9.1 реализует идеальный баланс:**
|
**v1.9.1 implements the ideal balance:**
|
||||||
- Setup: Синхронный для надежной инициализации
|
- Setup: Synchronous for reliable initialization
|
||||||
- Loop: Асинхронный для отзывчивости
|
- Loop: Asynchronous for responsiveness
|
||||||
|
|
||||||
**Результат:**
|
**Result:**
|
||||||
- ✅ Дисплей показывает время через 15 сек (вместо 25+ сек)
|
- Display shows time after 15 sec (instead of 25+ sec)
|
||||||
- ✅ Никаких ошибок "DNS resolution failed"
|
- No "DNS resolution failed" errors
|
||||||
- ✅ Правильный порядок старта
|
- Correct startup order
|
||||||
- ✅ Устройство не зависает при потере WiFi в работе
|
- Device doesn't freeze on WiFi loss during operation
|
||||||
|
|
||||||
**Status**: Production ready 🚀
|
**Status**: Production ready
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
# v1.9.2 - WiFi Resilience
|
||||||
|
|
||||||
|
## Problem in v1.9.1
|
||||||
|
|
||||||
|
**Symptoms:**
|
||||||
|
- After WiFi outage (router restart, temporary network issues), device enters AP mode
|
||||||
|
- WiFi credentials are cleared, requiring manual reconfiguration
|
||||||
|
- User must reconnect to "TJ56654-Setup" AP and re-enter WiFi password
|
||||||
|
- This happens every time WiFi goes down temporarily
|
||||||
|
|
||||||
|
**Root Cause:**
|
||||||
|
|
||||||
|
Aggressive credential clearing after connection failures:
|
||||||
|
```cpp
|
||||||
|
void processWiFiConnection() {
|
||||||
|
// After 5 failed attempts...
|
||||||
|
if (wifiRetry.currentRetry >= wifiRetry.maxRetries) {
|
||||||
|
// Clear credentials!
|
||||||
|
memset(config.ssid, 0, sizeof(config.ssid));
|
||||||
|
memset(config.password, 0, sizeof(config.password));
|
||||||
|
saveConfig();
|
||||||
|
|
||||||
|
// Start AP mode for reconfiguration
|
||||||
|
WiFiManager wm;
|
||||||
|
wm.startConfigPortal("TJ56654-Setup", "12345678");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:**
|
||||||
|
- Poor user experience during network outages
|
||||||
|
- Unnecessary manual intervention required
|
||||||
|
- Device unusable until reconfigured
|
||||||
|
|
||||||
|
## Solution: Resilient WiFi
|
||||||
|
|
||||||
|
### Key Changes
|
||||||
|
|
||||||
|
| Feature | Before (v1.9.1) | After (v1.9.2) |
|
||||||
|
|---------|-----------------|----------------|
|
||||||
|
| Credential clearing | After 5 failed attempts | Never |
|
||||||
|
| Retry strategy | Give up after 5 tries | Infinite retries |
|
||||||
|
| Retry interval | Fixed 5 seconds | Exponential backoff (5s-5min) |
|
||||||
|
| Fallback AP | After clearing credentials | After ~5 min (keeps credentials) |
|
||||||
|
| Clock during outage | Shows connection counter | Shows last synced time |
|
||||||
|
| User notification | Numeric counter | "No WiFi" message |
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
#### 1. WiFi Retry Configuration
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Infinite retries with exponential backoff
|
||||||
|
struct WiFiRetryConfig {
|
||||||
|
uint8_t currentRetry = 0;
|
||||||
|
unsigned long nextRetryTime = 0;
|
||||||
|
static const unsigned long MAX_BACKOFF_MS = 300000; // Max 5 minutes
|
||||||
|
|
||||||
|
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 recordAttempt() {
|
||||||
|
currentRetry++;
|
||||||
|
nextRetryTime = millis() + getBackoffDelay();
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
currentRetry = 0;
|
||||||
|
nextRetryTime = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. No Credential Clearing
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void processWiFiConnection() {
|
||||||
|
if (WiFi.status() == WL_CONNECTED) {
|
||||||
|
if (wifiConnState != WIFI_CONN_CONNECTED) {
|
||||||
|
Serial.println("WiFi connected!");
|
||||||
|
wifiConnState = WIFI_CONN_CONNECTED;
|
||||||
|
wifiRetry.reset();
|
||||||
|
|
||||||
|
// Disable fallback AP if it was enabled
|
||||||
|
if (WiFi.getMode() == WIFI_AP_STA) {
|
||||||
|
Serial.println("Disabling fallback AP");
|
||||||
|
WiFi.mode(WIFI_STA);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection failed - but DON'T clear credentials!
|
||||||
|
if (millis() >= wifiRetry.nextRetryTime) {
|
||||||
|
Serial.printf("WiFi retry %d, next in %lu sec\n",
|
||||||
|
wifiRetry.currentRetry,
|
||||||
|
wifiRetry.getBackoffDelay() / 1000);
|
||||||
|
|
||||||
|
wifiRetry.recordAttempt();
|
||||||
|
|
||||||
|
// Try to connect with SDK or EEPROM credentials
|
||||||
|
if (strlen(config.password) > 0) {
|
||||||
|
WiFi.begin(config.ssid, config.password);
|
||||||
|
} else {
|
||||||
|
WiFi.begin(); // Use SDK-stored credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable fallback AP after 5+ attempts (~2.5 min)
|
||||||
|
if (wifiRetry.currentRetry >= 5 && WiFi.getMode() != WIFI_AP_STA) {
|
||||||
|
Serial.println("Enabling fallback AP (dual mode)");
|
||||||
|
WiFi.mode(WIFI_AP_STA);
|
||||||
|
WiFi.softAP("TJ56654-Setup", "12345678");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. "No WiFi" Display
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void ICACHE_FLASH_ATTR showNoWiFi(unsigned long nextRetrySeconds) {
|
||||||
|
display.clearDisplay();
|
||||||
|
display.setTextSize(2);
|
||||||
|
display.setTextColor(SSD1306_WHITE);
|
||||||
|
|
||||||
|
// Center "No WiFi" text
|
||||||
|
display.setCursor(20, 8);
|
||||||
|
display.println("No WiFi");
|
||||||
|
|
||||||
|
// Show retry countdown
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Clock Continues During Outage
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void updateDisplay() {
|
||||||
|
if (WiFi.status() != WL_CONNECTED) {
|
||||||
|
// Show "No WiFi" status
|
||||||
|
unsigned long nextRetry = (wifiRetry.nextRetryTime > millis())
|
||||||
|
? (wifiRetry.nextRetryTime - millis()) / 1000
|
||||||
|
: 0;
|
||||||
|
showNoWiFi(nextRetry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal display modes when WiFi is connected
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. WiFi Disconnect Indicator
|
||||||
|
|
||||||
|
When WiFi is disconnected but time is still valid, show "!" in date line:
|
||||||
|
```cpp
|
||||||
|
void showTimeMode() {
|
||||||
|
// ... time display ...
|
||||||
|
|
||||||
|
// Date line with WiFi status indicator
|
||||||
|
display.setCursor(0, 0);
|
||||||
|
if (WiFi.status() != WL_CONNECTED) {
|
||||||
|
display.print("! "); // WiFi disconnected indicator
|
||||||
|
}
|
||||||
|
display.print(dateString);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6. SDK Credentials Support
|
||||||
|
|
||||||
|
WiFiManager stores credentials in ESP SDK flash, not EEPROM. This caused issues after OTA updates:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void setupWiFi() {
|
||||||
|
// Try SDK-stored credentials first (from WiFiManager)
|
||||||
|
if (strlen(config.password) > 0) {
|
||||||
|
// EEPROM has credentials - use them
|
||||||
|
WiFi.begin(config.ssid, config.password);
|
||||||
|
} else {
|
||||||
|
// EEPROM empty - try SDK credentials
|
||||||
|
WiFi.begin(); // Uses last saved WiFi from SDK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Retry Backoff Schedule
|
||||||
|
|
||||||
|
| Attempt | Delay | Cumulative Time |
|
||||||
|
|---------|-------|-----------------|
|
||||||
|
| 1 | 5 sec | 5 sec |
|
||||||
|
| 2 | 10 sec | 15 sec |
|
||||||
|
| 3 | 20 sec | 35 sec |
|
||||||
|
| 4 | 40 sec | 1 min 15 sec |
|
||||||
|
| 5 | 80 sec | 2 min 35 sec |
|
||||||
|
| 6 | 160 sec | 5 min 15 sec |
|
||||||
|
| 7+ | 300 sec (5 min) | +5 min each |
|
||||||
|
|
||||||
|
**Fallback AP enabled after attempt 5** (~2.5 min of failures)
|
||||||
|
|
||||||
|
## Network Activity Summary
|
||||||
|
|
||||||
|
| Service | Interval | Endpoint | Protocol | Daily Requests |
|
||||||
|
|---------|----------|----------|----------|----------------|
|
||||||
|
| NTP | 1 hour | pool.ntp.org:123 | UDP | 24 |
|
||||||
|
| Weather | 30 min | api.open-meteo.com | HTTP | 48 |
|
||||||
|
| mDNS | continuous | 224.0.0.251 | UDP multicast | N/A |
|
||||||
|
|
||||||
|
**Total**: ~50-75 requests/day (minimal network load)
|
||||||
|
|
||||||
|
## Dual STA+AP Mode
|
||||||
|
|
||||||
|
When fallback AP is enabled, device operates in dual mode:
|
||||||
|
- **STA (Station)**: Continues trying to connect to configured WiFi
|
||||||
|
- **AP (Access Point)**: Allows user to reconfigure if needed
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Enable dual mode
|
||||||
|
WiFi.mode(WIFI_AP_STA);
|
||||||
|
WiFi.softAP("TJ56654-Setup", "12345678");
|
||||||
|
|
||||||
|
// Continue WiFi connection attempts in STA mode
|
||||||
|
WiFi.begin(config.ssid, config.password);
|
||||||
|
```
|
||||||
|
|
||||||
|
When WiFi reconnects:
|
||||||
|
```cpp
|
||||||
|
if (WiFi.status() == WL_CONNECTED && WiFi.getMode() == WIFI_AP_STA) {
|
||||||
|
// Disable AP, return to STA-only mode
|
||||||
|
WiFi.mode(WIFI_STA);
|
||||||
|
Serial.println("WiFi reconnected, disabled fallback AP");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
### Scenario 1: Router Restart (5 minutes)
|
||||||
|
|
||||||
|
**Before (v1.9.1):**
|
||||||
|
1. WiFi disconnects
|
||||||
|
2. 5 retry attempts (25 seconds)
|
||||||
|
3. Credentials cleared
|
||||||
|
4. Device enters AP mode
|
||||||
|
5. User must reconfigure WiFi
|
||||||
|
|
||||||
|
**After (v1.9.2):**
|
||||||
|
1. WiFi disconnects
|
||||||
|
2. Retry with backoff (5s, 10s, 20s, 40s, 80s)
|
||||||
|
3. After ~2.5 min: Fallback AP enabled (dual mode)
|
||||||
|
4. Device continues retrying
|
||||||
|
5. Router comes back
|
||||||
|
6. Device reconnects automatically
|
||||||
|
7. Fallback AP disabled
|
||||||
|
8. No user intervention needed
|
||||||
|
|
||||||
|
### Scenario 2: Extended Outage (30+ minutes)
|
||||||
|
|
||||||
|
**v1.9.2 Behavior:**
|
||||||
|
1. Retries every 5 minutes after initial backoff
|
||||||
|
2. Fallback AP available for reconfiguration if needed
|
||||||
|
3. Clock shows last synced time
|
||||||
|
4. "No WiFi" indicator on display
|
||||||
|
5. Reconnects automatically when WiFi returns
|
||||||
|
|
||||||
|
### Scenario 3: OTA Update
|
||||||
|
|
||||||
|
**Problem**: WiFiManager stores credentials in SDK flash, but our EEPROM may be empty after update.
|
||||||
|
|
||||||
|
**Solution**: Try SDK credentials first:
|
||||||
|
```cpp
|
||||||
|
if (strlen(config.password) > 0) {
|
||||||
|
WiFi.begin(config.ssid, config.password);
|
||||||
|
} else {
|
||||||
|
WiFi.begin(); // SDK credentials
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Memory Impact
|
||||||
|
|
||||||
|
| Resource | v1.9.1 | v1.9.2 | Change |
|
||||||
|
|----------|--------|--------|--------|
|
||||||
|
| RAM | 37,644 | 37,800 | +156 bytes |
|
||||||
|
| IRAM | 61,987 | 61,987 | 0 bytes |
|
||||||
|
| Flash | 408,844 | 409,100 | +256 bytes |
|
||||||
|
|
||||||
|
Minimal memory increase (+0.4%) for significant UX improvement.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
**v1.9.2 provides true WiFi resilience:**
|
||||||
|
- Never clears credentials
|
||||||
|
- Infinite retries with smart backoff
|
||||||
|
- Fallback AP for emergency reconfiguration
|
||||||
|
- Clock continues during outages
|
||||||
|
- Clear user feedback on display
|
||||||
|
|
||||||
|
**Results:**
|
||||||
|
- No more manual reconfiguration after WiFi outages
|
||||||
|
- Device recovers automatically when network returns
|
||||||
|
- User can still reconfigure via fallback AP if needed
|
||||||
|
- Minimal network overhead (~50 requests/day)
|
||||||
|
|
||||||
|
**Status**: Production ready
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
build/
|
||||||
|
*.bak
|
||||||
|
*.bak2
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
/*
|
||||||
|
* TJ-56-654 Weather Clock - Custom NTP Firmware with OTA v1.9.3
|
||||||
|
*
|
||||||
|
* Hardware:
|
||||||
|
* - ESP-01S (ESP8266)
|
||||||
|
* - GM009605v4.3 OLED 128x64 display (SSD1306 I2C)
|
||||||
|
*
|
||||||
|
* Connections:
|
||||||
|
* - GPIO0 -> OLED SDA (I2C Data) - SWAPPED!
|
||||||
|
* - GPIO2 -> OLED SCL (I2C Clock) - SWAPPED!
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Async NTP time sync
|
||||||
|
* - Async weather from Open-Meteo API
|
||||||
|
* - OTA updates (web + ArduinoOTA)
|
||||||
|
* - WiFi resilience with exponential backoff
|
||||||
|
* - "Thanos snap" dissolve transition effect
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <ESP8266WiFi.h>
|
||||||
|
#include <ESP8266WebServer.h>
|
||||||
|
#include <ESP8266HTTPUpdateServer.h>
|
||||||
|
#include <ESP8266mDNS.h>
|
||||||
|
#include <ArduinoOTA.h>
|
||||||
|
#include <WiFiUdp.h>
|
||||||
|
#include <NTPClient.h>
|
||||||
|
#include <EEPROM.h>
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <Wire.h>
|
||||||
|
#include <Adafruit_GFX.h>
|
||||||
|
#include <Adafruit_SSD1306.h>
|
||||||
|
#include <WiFiManager.h>
|
||||||
|
|
||||||
|
#include "config.h"
|
||||||
|
#include "globals.h"
|
||||||
|
|
||||||
|
// ============ Global variable definitions ============
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
Config config;
|
||||||
|
|
||||||
|
// OLED Display
|
||||||
|
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
|
||||||
|
|
||||||
|
// NTP Client
|
||||||
|
WiFiUDP ntpUDP;
|
||||||
|
NTPClient timeClient(ntpUDP, "pool.ntp.org", 0, 60000);
|
||||||
|
|
||||||
|
// Web server
|
||||||
|
ESP8266WebServer server(80);
|
||||||
|
ESP8266HTTPUpdateServer httpUpdater;
|
||||||
|
|
||||||
|
// State machines
|
||||||
|
WeatherState weatherState = WEATHER_IDLE;
|
||||||
|
NTPState ntpState = NTP_IDLE;
|
||||||
|
WiFiConnectionState wifiConnState = WIFI_CONN_IDLE;
|
||||||
|
|
||||||
|
// Retry configurations
|
||||||
|
RetryConfig ntpRetry;
|
||||||
|
RetryConfig weatherRetry;
|
||||||
|
WiFiRetryConfig wifiRetry;
|
||||||
|
|
||||||
|
// NTP packet buffer and timing
|
||||||
|
byte ntpPacketBuffer[48];
|
||||||
|
unsigned long ntpRequestTime = 0;
|
||||||
|
|
||||||
|
// Independent epoch tracking
|
||||||
|
unsigned long syncedEpoch = 0;
|
||||||
|
unsigned long syncedMillis = 0;
|
||||||
|
bool timeIsSynced = false;
|
||||||
|
|
||||||
|
// WiFi connection timing
|
||||||
|
unsigned long wifiConnectStart = 0;
|
||||||
|
|
||||||
|
// Display state
|
||||||
|
bool colonBlink = false;
|
||||||
|
unsigned long lastBlinkTime = 0;
|
||||||
|
unsigned long lastNTPUpdate = 0;
|
||||||
|
unsigned long ipDisplayUntil = 0;
|
||||||
|
|
||||||
|
// Debug variables
|
||||||
|
String lastError = "";
|
||||||
|
int ntpAttempts = 0;
|
||||||
|
int ntpSuccesses = 0;
|
||||||
|
bool internetConnected = false;
|
||||||
|
|
||||||
|
// Weather and sun data
|
||||||
|
WeatherData weather;
|
||||||
|
SunTimes sunTimes;
|
||||||
|
|
||||||
|
// Display rotation state
|
||||||
|
uint8_t displayMode = 0;
|
||||||
|
unsigned long lastModeSwitch = 0;
|
||||||
|
unsigned long lastWeatherUpdate = 0;
|
||||||
|
|
||||||
|
// Dissolve transition state
|
||||||
|
bool inTransition = false;
|
||||||
|
unsigned long transitionStart = 0;
|
||||||
|
unsigned long lastDissolveFrame = 0;
|
||||||
|
uint8_t nextDisplayMode = 0;
|
||||||
|
|
||||||
|
// ============ Helper functions ============
|
||||||
|
|
||||||
|
void ICACHE_FLASH_ATTR safeStringCopy(const String& src, char* dest, size_t maxLen) {
|
||||||
|
if (src.length() >= maxLen) {
|
||||||
|
Serial.printf("WARNING: String truncated from %d to %d chars\n", src.length(), maxLen - 1);
|
||||||
|
}
|
||||||
|
src.toCharArray(dest, maxLen);
|
||||||
|
dest[maxLen - 1] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ EEPROM functions ============
|
||||||
|
|
||||||
|
void ICACHE_FLASH_ATTR loadConfig() {
|
||||||
|
EEPROM.begin(512);
|
||||||
|
|
||||||
|
Config tempConfig;
|
||||||
|
EEPROM.get(0, tempConfig);
|
||||||
|
|
||||||
|
if (tempConfig.magic == CONFIG_MAGIC) {
|
||||||
|
config = tempConfig;
|
||||||
|
Serial.println("Valid configuration loaded from EEPROM");
|
||||||
|
} else {
|
||||||
|
Serial.println("Invalid EEPROM data, using defaults");
|
||||||
|
config.magic = CONFIG_MAGIC;
|
||||||
|
saveConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
EEPROM.end();
|
||||||
|
|
||||||
|
Serial.println("Configuration:");
|
||||||
|
Serial.printf(" Magic: 0x%08X %s\n", config.magic, config.magic == CONFIG_MAGIC ? "OK" : "INVALID");
|
||||||
|
Serial.printf(" SSID: %s\n", config.ssid);
|
||||||
|
Serial.printf(" Timezone: %ld\n", config.timezone_offset);
|
||||||
|
Serial.printf(" Hostname: %s\n", config.hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ICACHE_FLASH_ATTR saveConfig() {
|
||||||
|
EEPROM.begin(512);
|
||||||
|
EEPROM.put(0, config);
|
||||||
|
EEPROM.commit();
|
||||||
|
EEPROM.end();
|
||||||
|
|
||||||
|
Serial.println("Configuration saved!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ OTA setup ============
|
||||||
|
|
||||||
|
void ICACHE_FLASH_ATTR setupOTA() {
|
||||||
|
ArduinoOTA.setHostname(config.hostname);
|
||||||
|
|
||||||
|
ArduinoOTA.onStart([]() {
|
||||||
|
String type = (ArduinoOTA.getCommand() == U_FLASH) ? "sketch" : "filesystem";
|
||||||
|
Serial.println("Start OTA updating " + type);
|
||||||
|
clearDisplay();
|
||||||
|
showNumber(0, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
ArduinoOTA.onEnd([]() {
|
||||||
|
Serial.println("\nOTA Update complete!");
|
||||||
|
showNumber(100, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
|
||||||
|
int percent = (progress / (total / 100));
|
||||||
|
Serial.printf("Progress: %u%%\r", percent);
|
||||||
|
showNumber(percent, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
ArduinoOTA.onError([](ota_error_t error) {
|
||||||
|
Serial.printf("Error[%u]: ", error);
|
||||||
|
if (error == OTA_AUTH_ERROR) Serial.println("Auth Failed");
|
||||||
|
else if (error == OTA_BEGIN_ERROR) Serial.println("Begin Failed");
|
||||||
|
else if (error == OTA_CONNECT_ERROR) Serial.println("Connect Failed");
|
||||||
|
else if (error == OTA_RECEIVE_ERROR) Serial.println("Receive Failed");
|
||||||
|
else if (error == OTA_END_ERROR) Serial.println("End Failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
ArduinoOTA.begin();
|
||||||
|
Serial.println("OTA ready");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Setup ============
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
delay(100);
|
||||||
|
Serial.println("\n\nTJ-56-654 NTP Clock with OTA v" FIRMWARE_VERSION);
|
||||||
|
Serial.println("==========================================");
|
||||||
|
Serial.println("Display: GM009605v4.3 OLED 128x64 (SSD1306 I2C)");
|
||||||
|
|
||||||
|
// Initialize I2C
|
||||||
|
Wire.begin(I2C_SDA, I2C_SCL);
|
||||||
|
|
||||||
|
// Initialize OLED display
|
||||||
|
Serial.print("Initializing OLED at 0x");
|
||||||
|
Serial.println(OLED_ADDRESS, HEX);
|
||||||
|
|
||||||
|
if(!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS)) {
|
||||||
|
Serial.println("OLED initialization FAILED!");
|
||||||
|
if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3D)) {
|
||||||
|
Serial.println("OLED not found at 0x3C or 0x3D!");
|
||||||
|
} else {
|
||||||
|
Serial.println("OLED found at 0x3D");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Serial.println("OLED initialized successfully!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set display rotation
|
||||||
|
display.setRotation(config.display_orientation);
|
||||||
|
Serial.printf("Display rotation: %d (180 deg)\n", config.display_orientation);
|
||||||
|
|
||||||
|
// Show startup animation
|
||||||
|
Serial.println("Showing startup animation...");
|
||||||
|
showStartupAnimation();
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
loadConfig();
|
||||||
|
|
||||||
|
// Setup WiFi
|
||||||
|
setupWiFi();
|
||||||
|
|
||||||
|
// Setup OTA
|
||||||
|
setupOTA();
|
||||||
|
|
||||||
|
// Setup web server
|
||||||
|
setupWebServer();
|
||||||
|
|
||||||
|
// Setup NTP
|
||||||
|
timeClient = NTPClient(ntpUDP, config.ntp_server, 0, config.ntp_interval * 1000);
|
||||||
|
timeClient.begin();
|
||||||
|
|
||||||
|
// Test internet connectivity
|
||||||
|
testInternetConnectivity();
|
||||||
|
|
||||||
|
// Initialize timing to prevent immediate flicker/transition
|
||||||
|
lastBlinkTime = millis();
|
||||||
|
lastModeSwitch = millis();
|
||||||
|
colonBlink = true;
|
||||||
|
|
||||||
|
Serial.println("Setup complete!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Loop ============
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
// Handle OTA updates
|
||||||
|
ArduinoOTA.handle();
|
||||||
|
|
||||||
|
// Handle web server
|
||||||
|
server.handleClient();
|
||||||
|
MDNS.update();
|
||||||
|
|
||||||
|
// WiFi reconnection logic
|
||||||
|
static unsigned long lastWiFiCheck = 0;
|
||||||
|
if (millis() - lastWiFiCheck > 1000) {
|
||||||
|
lastWiFiCheck = millis();
|
||||||
|
|
||||||
|
if (WiFi.status() != WL_CONNECTED && wifiConnState == WIFI_CONN_CONNECTED) {
|
||||||
|
Serial.println("WiFi disconnected!");
|
||||||
|
wifiConnState = WIFI_CONN_FAILED;
|
||||||
|
internetConnected = false;
|
||||||
|
wifiRetry.reset();
|
||||||
|
wifiRetry.scheduleRetry();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wifiConnState == WIFI_CONN_FAILED && wifiRetry.isRetryTime()) {
|
||||||
|
Serial.printf("WiFi retry attempt (backoff level %d)...\n", wifiRetry.currentRetry);
|
||||||
|
|
||||||
|
if (wifiRetry.currentRetry >= 5 && WiFi.getMode() != WIFI_AP_STA) {
|
||||||
|
Serial.println("Enabling fallback AP (dual mode)");
|
||||||
|
WiFi.mode(WIFI_AP_STA);
|
||||||
|
WiFi.softAP("TJ56654-Setup", "12345678");
|
||||||
|
Serial.print("Fallback AP IP: ");
|
||||||
|
Serial.println(WiFi.softAPIP());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen(config.password) > 0) {
|
||||||
|
WiFi.begin(config.ssid, config.password);
|
||||||
|
} else {
|
||||||
|
WiFi.begin();
|
||||||
|
}
|
||||||
|
wifiConnState = WIFI_CONN_CONNECTING;
|
||||||
|
wifiConnectStart = millis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process async WiFi reconnection
|
||||||
|
processWiFiConnection();
|
||||||
|
|
||||||
|
// Process async NTP response
|
||||||
|
processNTPResponse();
|
||||||
|
|
||||||
|
// Check for NTP retry
|
||||||
|
if (ntpRetry.isRetryTime() && ntpState == NTP_IDLE) {
|
||||||
|
Serial.println("NTP retry time reached, attempting retry...");
|
||||||
|
sendNTPRequestAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger async NTP update periodically
|
||||||
|
unsigned long ntpInterval = config.ntp_interval * 1000UL;
|
||||||
|
if (millis() - lastNTPUpdate > ntpInterval || lastNTPUpdate == 0) {
|
||||||
|
if (ntpState == NTP_IDLE && !ntpRetry.isRetryTime()) {
|
||||||
|
sendNTPRequestAsync();
|
||||||
|
lastNTPUpdate = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeIsSynced || timeClient.isTimeSet()) {
|
||||||
|
calculateSunTimes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for weather retry
|
||||||
|
if (weatherRetry.isRetryTime() && weatherState == WEATHER_IDLE) {
|
||||||
|
Serial.println("Weather retry time reached, attempting retry...");
|
||||||
|
fetchWeatherAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update weather periodically
|
||||||
|
if (config.weather_enabled && millis() > 10000) {
|
||||||
|
unsigned long weatherInterval = config.weather_interval * 1000UL;
|
||||||
|
if (millis() - lastWeatherUpdate > weatherInterval || lastWeatherUpdate == 0) {
|
||||||
|
if (timeClient.isTimeSet() && weatherState == WEATHER_IDLE && !weatherRetry.isRetryTime()) {
|
||||||
|
fetchWeatherAsync();
|
||||||
|
lastWeatherUpdate = millis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if IP display should be cleared
|
||||||
|
if (ipDisplayUntil > 0 && millis() >= ipDisplayUntil) {
|
||||||
|
clearDisplay();
|
||||||
|
ipDisplayUntil = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update display with rotation
|
||||||
|
updateDisplayRotation();
|
||||||
|
|
||||||
|
// Blink colon every second
|
||||||
|
if (millis() - lastBlinkTime > 500) {
|
||||||
|
colonBlink = !colonBlink;
|
||||||
|
lastBlinkTime = millis();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/*
|
||||||
|
* config.h - Configuration structures and constants
|
||||||
|
* TJ-56-654 Weather Clock v1.9.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef CONFIG_H
|
||||||
|
#define CONFIG_H
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
// Firmware version
|
||||||
|
#define FIRMWARE_VERSION "1.9.3"
|
||||||
|
|
||||||
|
// 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.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
#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.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
#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.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
#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.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 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.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
#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.3
|
||||||
|
*/
|
||||||
|
|
||||||
|
#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);
|
||||||
|
}
|
||||||
+273
-157
@@ -1,5 +1,12 @@
|
|||||||
/*
|
/*
|
||||||
* TJ-56-654 Weather Clock - Custom NTP Firmware with OTA v1.9.1
|
* TJ-56-654 Weather Clock - Custom NTP Firmware with OTA v1.9.2
|
||||||
|
*
|
||||||
|
* Version 1.9.2 Changes (WIFI RESILIENCE):
|
||||||
|
* - FIXED: No longer clears WiFi credentials on connection failure
|
||||||
|
* - IMPROVED: Infinite retry with exponential backoff (up to 5 min between attempts)
|
||||||
|
* - IMPROVED: Shows "No WiFi" status on display instead of cryptic numbers
|
||||||
|
* - IMPROVED: AP mode only on first boot (empty credentials) or manual trigger
|
||||||
|
* - IMPROVED: Clock continues running with last synced time during WiFi outage
|
||||||
*
|
*
|
||||||
* Version 1.9.1 Changes (HYBRID ASYNC FIX):
|
* Version 1.9.1 Changes (HYBRID ASYNC FIX):
|
||||||
* - FIXED STARTUP: WiFi now synchronous in setup() for proper initialization
|
* - FIXED STARTUP: WiFi now synchronous in setup() for proper initialization
|
||||||
@@ -69,7 +76,7 @@
|
|||||||
|
|
||||||
// Configuration structure with validation
|
// Configuration structure with validation
|
||||||
#define CONFIG_MAGIC 0xC10CC10C // Magic number to validate EEPROM data
|
#define CONFIG_MAGIC 0xC10CC10C // Magic number to validate EEPROM data
|
||||||
#define FIRMWARE_VERSION "1.9.1"
|
#define FIRMWARE_VERSION "1.9.2"
|
||||||
|
|
||||||
struct Config {
|
struct Config {
|
||||||
uint32_t magic = CONFIG_MAGIC; // Magic number for validation
|
uint32_t magic = CONFIG_MAGIC; // Magic number for validation
|
||||||
@@ -122,6 +129,7 @@ void ICACHE_FLASH_ATTR setupOTA();
|
|||||||
void ICACHE_FLASH_ATTR setupWebServer();
|
void ICACHE_FLASH_ATTR setupWebServer();
|
||||||
void ICACHE_FLASH_ATTR clearDisplay();
|
void ICACHE_FLASH_ATTR clearDisplay();
|
||||||
void ICACHE_FLASH_ATTR showNumber(int num, bool leadingZeros);
|
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 showStartupAnimation();
|
||||||
void ICACHE_FLASH_ATTR showIP();
|
void ICACHE_FLASH_ATTR showIP();
|
||||||
void ICACHE_FLASH_ATTR handleRoot();
|
void ICACHE_FLASH_ATTR handleRoot();
|
||||||
@@ -200,12 +208,14 @@ NTPClient timeClient(ntpUDP, "pool.ntp.org", 0, 60000);
|
|||||||
|
|
||||||
// Exponential backoff retry configuration
|
// Exponential backoff retry configuration
|
||||||
struct RetryConfig {
|
struct RetryConfig {
|
||||||
uint8_t maxRetries = 3;
|
uint8_t maxRetries = 3; // For NTP/Weather: give up after 3 tries
|
||||||
uint8_t currentRetry = 0;
|
uint8_t currentRetry = 0;
|
||||||
unsigned long nextRetryTime = 0;
|
unsigned long nextRetryTime = 0;
|
||||||
|
unsigned long maxBackoffMs = 8000; // Max backoff 8 seconds for NTP/Weather
|
||||||
|
|
||||||
unsigned long getBackoffDelay() {
|
unsigned long getBackoffDelay() {
|
||||||
return 1000UL * (1UL << currentRetry); // 1s, 2s, 4s, 8s...
|
unsigned long delay = 1000UL * (1UL << currentRetry); // 1s, 2s, 4s, 8s...
|
||||||
|
return (delay > maxBackoffMs) ? maxBackoffMs : delay;
|
||||||
}
|
}
|
||||||
|
|
||||||
void scheduleRetry() {
|
void scheduleRetry() {
|
||||||
@@ -231,6 +241,33 @@ struct RetryConfig {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Async HTTP client for non-blocking weather fetch
|
// Async HTTP client for non-blocking weather fetch
|
||||||
AsyncHTTPRequest weatherRequest;
|
AsyncHTTPRequest weatherRequest;
|
||||||
|
|
||||||
@@ -278,6 +315,7 @@ const unsigned long WIFI_TIMEOUT_MS = 10000; // 10 second timeout for v1.7 migr
|
|||||||
// Retry configurations with exponential backoff
|
// Retry configurations with exponential backoff
|
||||||
RetryConfig ntpRetry;
|
RetryConfig ntpRetry;
|
||||||
RetryConfig weatherRetry;
|
RetryConfig weatherRetry;
|
||||||
|
WiFiRetryConfig wifiRetry;
|
||||||
|
|
||||||
// Web server
|
// Web server
|
||||||
ESP8266WebServer server(80);
|
ESP8266WebServer server(80);
|
||||||
@@ -398,20 +436,45 @@ void loop() {
|
|||||||
server.handleClient();
|
server.handleClient();
|
||||||
MDNS.update();
|
MDNS.update();
|
||||||
|
|
||||||
// WiFi reconnection logic (async, non-blocking)
|
// WiFi reconnection logic (async, non-blocking with exponential backoff)
|
||||||
// If WiFi disconnects during operation, try to reconnect asynchronously
|
|
||||||
static unsigned long lastWiFiCheck = 0;
|
static unsigned long lastWiFiCheck = 0;
|
||||||
if (millis() - lastWiFiCheck > 5000) { // Check every 5 seconds
|
if (millis() - lastWiFiCheck > 1000) { // Check every second
|
||||||
|
lastWiFiCheck = millis();
|
||||||
|
|
||||||
|
// If WiFi was connected but now disconnected
|
||||||
if (WiFi.status() != WL_CONNECTED && wifiConnState == WIFI_CONN_CONNECTED) {
|
if (WiFi.status() != WL_CONNECTED && wifiConnState == WIFI_CONN_CONNECTED) {
|
||||||
Serial.println("⚠️ WiFi disconnected, attempting async reconnect...");
|
Serial.println("⚠️ WiFi disconnected!");
|
||||||
WiFi.begin(); // Try to reconnect with saved credentials
|
wifiConnState = WIFI_CONN_FAILED;
|
||||||
|
internetConnected = false;
|
||||||
|
wifiRetry.reset(); // Start fresh retry sequence
|
||||||
|
wifiRetry.scheduleRetry(); // Schedule first retry
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's time to retry
|
||||||
|
if (wifiConnState == WIFI_CONN_FAILED && wifiRetry.isRetryTime()) {
|
||||||
|
Serial.printf("🔄 WiFi retry attempt (backoff level %d)...\n", wifiRetry.currentRetry);
|
||||||
|
|
||||||
|
// After 5+ failed attempts (~5 min), enable fallback AP in dual mode
|
||||||
|
if (wifiRetry.currentRetry >= 5 && WiFi.getMode() != WIFI_AP_STA) {
|
||||||
|
Serial.println("📡 Enabling fallback AP (dual mode) for manual configuration");
|
||||||
|
WiFi.mode(WIFI_AP_STA);
|
||||||
|
WiFi.softAP("TJ56654-Setup", "12345678");
|
||||||
|
Serial.print("Fallback AP IP: ");
|
||||||
|
Serial.println(WiFi.softAPIP());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try SDK-stored credentials first, then EEPROM
|
||||||
|
if (strlen(config.password) > 0) {
|
||||||
|
WiFi.begin(config.ssid, config.password);
|
||||||
|
} else {
|
||||||
|
WiFi.begin(); // Use SDK-stored credentials from WiFiManager
|
||||||
|
}
|
||||||
wifiConnState = WIFI_CONN_CONNECTING;
|
wifiConnState = WIFI_CONN_CONNECTING;
|
||||||
wifiConnectStart = millis();
|
wifiConnectStart = millis();
|
||||||
}
|
}
|
||||||
lastWiFiCheck = millis();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process async WiFi reconnection (only if reconnecting, not during initial setup)
|
// Process async WiFi reconnection
|
||||||
processWiFiConnection();
|
processWiFiConnection();
|
||||||
|
|
||||||
// Process async NTP response (non-blocking check)
|
// Process async NTP response (non-blocking check)
|
||||||
@@ -635,7 +698,152 @@ void processWiFiConnection() {
|
|||||||
// Check connection status
|
// Check connection status
|
||||||
if (WiFi.status() == WL_CONNECTED) {
|
if (WiFi.status() == WL_CONNECTED) {
|
||||||
wifiConnState = WIFI_CONN_CONNECTED;
|
wifiConnState = WIFI_CONN_CONNECTED;
|
||||||
Serial.println("\n✅ WiFi connected (async)!");
|
wifiRetry.reset(); // Success! Reset retry counter
|
||||||
|
internetConnected = true;
|
||||||
|
Serial.println("\n✅ WiFi connected!");
|
||||||
|
Serial.print("SSID: ");
|
||||||
|
Serial.println(WiFi.SSID());
|
||||||
|
Serial.print("IP: ");
|
||||||
|
Serial.println(WiFi.localIP());
|
||||||
|
|
||||||
|
// Disable fallback AP if it was enabled (switch back to STA only)
|
||||||
|
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 for display purposes (don't clear on failure!)
|
||||||
|
if (strlen(config.ssid) == 0) {
|
||||||
|
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
|
||||||
|
saveConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show IP on display
|
||||||
|
showIP();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check timeout for this attempt
|
||||||
|
if (millis() - wifiConnectStart > WIFI_TIMEOUT_MS) {
|
||||||
|
// Connection attempt failed - schedule retry with backoff
|
||||||
|
// DO NOT clear credentials!
|
||||||
|
wifiRetry.scheduleRetry();
|
||||||
|
unsigned long nextRetryMs = wifiRetry.getBackoffDelay();
|
||||||
|
|
||||||
|
Serial.printf("\n⚠️ WiFi connection failed. Retry in %lu seconds\n", nextRetryMs / 1000);
|
||||||
|
|
||||||
|
wifiConnState = WIFI_CONN_FAILED;
|
||||||
|
internetConnected = false;
|
||||||
|
|
||||||
|
// Show "No WiFi" status on display
|
||||||
|
showNoWiFi(nextRetryMs / 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still connecting, show progress dots in serial only (no display spam)
|
||||||
|
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");
|
||||||
|
|
||||||
|
// Set hostname before connecting
|
||||||
|
WiFi.hostname(config.hostname);
|
||||||
|
WiFi.mode(WIFI_STA);
|
||||||
|
|
||||||
|
// STRATEGY: First try SDK-stored credentials (from WiFiManager), then EEPROM config
|
||||||
|
// WiFiManager stores credentials in ESP flash separately from our EEPROM
|
||||||
|
|
||||||
|
// Try 1: Use WiFi.begin() without params - uses SDK stored credentials
|
||||||
|
Serial.println("Trying SDK-stored credentials...");
|
||||||
|
WiFi.begin(); // Uses credentials stored by WiFiManager/SDK
|
||||||
|
|
||||||
|
// SYNCHRONOUS wait for connection (max 10 seconds)
|
||||||
|
Serial.print("Connecting to WiFi");
|
||||||
|
int attempts = 0;
|
||||||
|
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
|
||||||
|
delay(500);
|
||||||
|
Serial.print(".");
|
||||||
|
showNumber(attempts, false);
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WiFi.status() == WL_CONNECTED) {
|
||||||
|
Serial.println("\n✅ WiFi 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());
|
||||||
|
|
||||||
|
// Sync connected SSID to our config
|
||||||
|
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
|
||||||
|
// Note: password stays in SDK storage, we don't have access to it
|
||||||
|
saveConfig();
|
||||||
|
|
||||||
|
// Show IP on display
|
||||||
|
showIP();
|
||||||
|
|
||||||
|
wifiConnState = WIFI_CONN_CONNECTED;
|
||||||
|
return; // Success!
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
delay(500);
|
||||||
|
Serial.print(".");
|
||||||
|
showNumber(attempts, false);
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WiFi.status() == WL_CONNECTED) {
|
||||||
|
Serial.println("\n✅ WiFi connected via EEPROM credentials!");
|
||||||
|
showIP();
|
||||||
|
wifiConnState = WIFI_CONN_CONNECTED;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both attempts failed - check if we have ANY stored credentials
|
||||||
|
// If not, use WiFiManager for initial setup
|
||||||
|
if (strlen(config.ssid) == 0) {
|
||||||
|
Serial.println("\nNo saved credentials, using WiFiManager...");
|
||||||
|
WiFiManager wifiManager;
|
||||||
|
wifiManager.setConfigPortalTimeout(180); // 3 minutes timeout
|
||||||
|
|
||||||
|
// Display AP mode indication
|
||||||
|
showNumber(0xAF, false);
|
||||||
|
|
||||||
|
// Auto-connect: Portal SSID "TJ56654-Setup", Password "12345678"
|
||||||
|
Serial.println("Attempting WiFiManager auto-connect...");
|
||||||
|
if (!wifiManager.autoConnect("TJ56654-Setup", "12345678")) {
|
||||||
|
// Connection failed after timeout
|
||||||
|
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; // Mark as handled
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connected successfully via WiFiManager!
|
||||||
|
Serial.println("WiFi connected via WiFiManager!");
|
||||||
Serial.print("SSID: ");
|
Serial.print("SSID: ");
|
||||||
Serial.println(WiFi.SSID());
|
Serial.println(WiFi.SSID());
|
||||||
Serial.print("IP: ");
|
Serial.print("IP: ");
|
||||||
@@ -647,147 +855,15 @@ void processWiFiConnection() {
|
|||||||
|
|
||||||
// Show IP on display
|
// Show IP on display
|
||||||
showIP();
|
showIP();
|
||||||
|
wifiConnState = WIFI_CONN_CONNECTED;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check timeout
|
// We have credentials but WiFi is not available - will retry in loop()
|
||||||
if (millis() - wifiConnectStart > WIFI_TIMEOUT_MS) {
|
Serial.println("\n⚠️ WiFi not available. Will retry in background.");
|
||||||
wifiConnState = WIFI_CONN_FAILED;
|
wifiConnState = WIFI_CONN_FAILED;
|
||||||
Serial.println("\n⚠️ WiFi async connection timeout");
|
wifiRetry.scheduleRetry();
|
||||||
Serial.println("Falling back to WiFiManager...");
|
showNoWiFi(wifiRetry.getBackoffDelay() / 1000);
|
||||||
|
|
||||||
// Clear old credentials from config
|
|
||||||
config.ssid[0] = '\0';
|
|
||||||
config.password[0] = '\0';
|
|
||||||
saveConfig();
|
|
||||||
|
|
||||||
// Fall back to WiFiManager
|
|
||||||
WiFiManager wifiManager;
|
|
||||||
wifiManager.setConfigPortalTimeout(180);
|
|
||||||
showNumber(0xAF, false);
|
|
||||||
|
|
||||||
Serial.println("Starting WiFiManager captive portal...");
|
|
||||||
if (!wifiManager.autoConnect("TJ56654-Setup", "12345678")) {
|
|
||||||
Serial.println("WiFiManager failed. Starting fallback AP...");
|
|
||||||
WiFi.mode(WIFI_AP);
|
|
||||||
WiFi.softAP("TJ56654-Clock", "12345678");
|
|
||||||
Serial.print("Fallback AP IP: ");
|
|
||||||
Serial.println(WiFi.softAPIP());
|
|
||||||
} else {
|
|
||||||
Serial.println("WiFiManager connected!");
|
|
||||||
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
|
|
||||||
saveConfig();
|
|
||||||
showIP();
|
|
||||||
}
|
|
||||||
|
|
||||||
wifiConnState = WIFI_CONN_CONNECTED; // Mark as handled
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Still connecting, update display
|
|
||||||
static uint8_t connectAttempts = 0;
|
|
||||||
static unsigned long lastAttemptDisplay = 0;
|
|
||||||
if (millis() - lastAttemptDisplay > 500) {
|
|
||||||
Serial.print(".");
|
|
||||||
showNumber(connectAttempts, false);
|
|
||||||
connectAttempts++;
|
|
||||||
lastAttemptDisplay = millis();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// WiFi setup (SYNCHRONOUS in setup(), async reconnect in loop())
|
|
||||||
void ICACHE_FLASH_ATTR setupWiFi() {
|
|
||||||
Serial.println("WiFi Setup - Synchronous for initial connection");
|
|
||||||
|
|
||||||
// Set hostname before connecting
|
|
||||||
WiFi.hostname(config.hostname);
|
|
||||||
|
|
||||||
// MIGRATION FROM v1.7: If config has old credentials, try them SYNCHRONOUSLY
|
|
||||||
// This ensures OTA/web/NTP can initialize properly after WiFi is connected
|
|
||||||
if (strlen(config.ssid) > 0) {
|
|
||||||
Serial.println("Found saved credentials, connecting synchronously...");
|
|
||||||
WiFi.mode(WIFI_STA);
|
|
||||||
WiFi.begin(config.ssid, config.password);
|
|
||||||
|
|
||||||
// SYNCHRONOUS wait for connection (max 10 seconds)
|
|
||||||
Serial.print("Connecting to WiFi");
|
|
||||||
int attempts = 0;
|
|
||||||
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
|
|
||||||
delay(500);
|
|
||||||
Serial.print(".");
|
|
||||||
showNumber(attempts, false);
|
|
||||||
attempts++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (WiFi.status() == WL_CONNECTED) {
|
|
||||||
Serial.println("\n✅ WiFi 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());
|
|
||||||
|
|
||||||
// Sync connected SSID to config
|
|
||||||
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
|
|
||||||
saveConfig();
|
|
||||||
|
|
||||||
// Show IP on display
|
|
||||||
showIP();
|
|
||||||
|
|
||||||
wifiConnState = WIFI_CONN_CONNECTED;
|
|
||||||
return; // Success!
|
|
||||||
} else {
|
|
||||||
Serial.println("\n⚠️ WiFi connection failed after 10 seconds");
|
|
||||||
// Clear old credentials and fall through to WiFiManager
|
|
||||||
config.ssid[0] = '\0';
|
|
||||||
config.password[0] = '\0';
|
|
||||||
saveConfig();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No v1.7 credentials - use WiFiManager (blocking, but only on first boot)
|
|
||||||
Serial.println("No saved credentials, using WiFiManager...");
|
|
||||||
WiFiManager wifiManager;
|
|
||||||
wifiManager.setConfigPortalTimeout(180); // 3 minutes timeout
|
|
||||||
|
|
||||||
// Display AP mode indication
|
|
||||||
showNumber(0xAF, false);
|
|
||||||
|
|
||||||
// Auto-connect: Portal SSID "TJ56654-Setup", Password "12345678"
|
|
||||||
Serial.println("Attempting WiFiManager auto-connect...");
|
|
||||||
if (!wifiManager.autoConnect("TJ56654-Setup", "12345678")) {
|
|
||||||
// Connection failed after timeout
|
|
||||||
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; // Mark as handled
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connected successfully via WiFiManager!
|
|
||||||
Serial.println("WiFi connected via WiFiManager!");
|
|
||||||
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());
|
|
||||||
|
|
||||||
// Sync connected SSID to config for display purposes
|
|
||||||
safeStringCopy(WiFi.SSID(), config.ssid, sizeof(config.ssid));
|
|
||||||
saveConfig();
|
|
||||||
|
|
||||||
// Show IP on display
|
|
||||||
showIP();
|
|
||||||
wifiConnState = WIFI_CONN_CONNECTED; // Mark as handled
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OTA setup
|
// OTA setup
|
||||||
@@ -866,7 +942,7 @@ void ICACHE_FLASH_ATTR setupWebServer() {
|
|||||||
|
|
||||||
// Update OLED display with current time
|
// Update OLED display with current time
|
||||||
// BLUE zone (top 48px): Large time
|
// BLUE zone (top 48px): Large time
|
||||||
// YELLOW zone (bottom 16px): Date
|
// YELLOW zone (bottom 16px): Date + WiFi status
|
||||||
void updateDisplay() {
|
void updateDisplay() {
|
||||||
static unsigned long lastUpdate = 0;
|
static unsigned long lastUpdate = 0;
|
||||||
|
|
||||||
@@ -876,17 +952,29 @@ void updateDisplay() {
|
|||||||
|
|
||||||
display.clearDisplay();
|
display.clearDisplay();
|
||||||
|
|
||||||
if (!timeClient.isTimeSet()) {
|
// Check if we have ANY time source (NTPClient or async sync)
|
||||||
|
bool hasTime = timeClient.isTimeSet() || timeIsSynced;
|
||||||
|
|
||||||
|
if (!hasTime) {
|
||||||
display.setTextSize(3);
|
display.setTextSize(3);
|
||||||
display.setTextColor(SSD1306_WHITE);
|
display.setTextColor(SSD1306_WHITE);
|
||||||
display.setCursor(20, 24);
|
display.setCursor(20, 24);
|
||||||
display.println("--:--");
|
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");
|
||||||
|
}
|
||||||
display.display();
|
display.display();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate local time with DST
|
// Get epoch from best available source
|
||||||
unsigned long epochTime = timeClient.getEpochTime();
|
unsigned long epochTime = timeIsSynced ? getAsyncEpoch() : timeClient.getEpochTime();
|
||||||
unsigned long localTime = epochTime + getTotalOffset(epochTime);
|
unsigned long localTime = epochTime + getTotalOffset(epochTime);
|
||||||
|
|
||||||
int hours = (localTime / 3600) % 24;
|
int hours = (localTime / 3600) % 24;
|
||||||
@@ -903,10 +991,16 @@ void updateDisplay() {
|
|||||||
time_t t = epochTime;
|
time_t t = epochTime;
|
||||||
struct tm *ptm = gmtime(&t);
|
struct tm *ptm = gmtime(&t);
|
||||||
|
|
||||||
// Format: "Thu 02.01"
|
// Format: "Thu 02.01" or "! Thu 02.01" if no WiFi
|
||||||
const char* days[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};
|
const char* days[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};
|
||||||
char dateStr[16];
|
char dateStr[20];
|
||||||
sprintf(dateStr, "%s %02d.%02d", days[ptm->tm_wday], ptm->tm_mday, ptm->tm_mon + 1);
|
|
||||||
|
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)
|
// Center in yellow zone (Y: 48-63)
|
||||||
int dateWidth = strlen(dateStr) * 12; // Size 2 = ~12px per char
|
int dateWidth = strlen(dateStr) * 12; // Size 2 = ~12px per char
|
||||||
@@ -1109,6 +1203,28 @@ void ICACHE_FLASH_ATTR showNumber(int num, bool leadingZeros) {
|
|||||||
display.display();
|
display.display();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show "No WiFi" status on OLED with retry countdown
|
||||||
|
void ICACHE_FLASH_ATTR showNoWiFi(unsigned long nextRetrySeconds) {
|
||||||
|
display.clearDisplay();
|
||||||
|
|
||||||
|
// === BLUE ZONE: "No WiFi" message ===
|
||||||
|
display.setTextSize(2);
|
||||||
|
display.setTextColor(SSD1306_WHITE);
|
||||||
|
display.setCursor(20, 8);
|
||||||
|
display.println("No WiFi");
|
||||||
|
|
||||||
|
// === YELLOW ZONE: Retry info ===
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
// Dummy function for compatibility (not needed for OLED)
|
// Dummy function for compatibility (not needed for OLED)
|
||||||
void displaySegments(const uint8_t segments[]) {
|
void displaySegments(const uint8_t segments[]) {
|
||||||
// Not used with OLED - kept for compatibility
|
// Not used with OLED - kept for compatibility
|
||||||
|
|||||||
Reference in New Issue
Block a user