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/),
|
||||
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
|
||||
|
||||
### 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)
|
||||
- [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">
|
||||
<img src="https://img.shields.io/badge/ESP8266-ESP--01S-blue?style=flat-square" />
|
||||
<img src="https://img.shields.io/badge/Firmware-v1.9.1-green?style=flat-square" />
|
||||
<img src="https://img.shields.io/badge/OTA-Enabled-orange?style=flat-square" />
|
||||
<img src="https://img.shields.io/badge/Status-Production%20Ready-brightgreen?style=flat-square" />
|
||||
<a href="https://github.com/petrochen/esp8266-weather-clock-opensource/releases">
|
||||
<img src="https://img.shields.io/github/v/release/petrochen/esp8266-weather-clock-opensource?style=flat-square&label=Release&color=green" alt="Release">
|
||||
</a>
|
||||
<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>
|
||||
|
||||
## 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 Solution: Custom Firmware](#the-solution-custom-firmware)
|
||||
- [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)
|
||||
- [How to Flash This Firmware](#how-to-flash-this-firmware)
|
||||
- [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
|
||||
**Model**: TJ-56-654
|
||||
**Price**: ~$12 USD
|
||||
**Price**: ~€5 EUR
|
||||
**Source**: [AliExpress Link](https://pt.aliexpress.com/item/1005008333782531.html)
|
||||
|
||||
### 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 ✅
|
||||
|
||||
@@ -413,7 +420,7 @@ Wire.begin(0, 2); // SDA=GPIO0, SCL=GPIO2
|
||||
|
||||
**Result**: Device stays responsive during OTA updates while weather is fetching!
|
||||
|
||||
### v1.9.1: Hybrid Fix (Current) 🎯
|
||||
### v1.9.1: Hybrid Fix 🎯
|
||||
|
||||
**Problem Discovered:**
|
||||
|
||||
@@ -444,6 +451,59 @@ void setup() {
|
||||
- ✅ Proper initialization order guaranteed
|
||||
- ✅ 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:**
|
||||
```
|
||||
[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.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.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.
|
||||
|
||||
@@ -858,15 +919,18 @@ Device reboots immediately.
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
clock_ntp_ota_v1.9/
|
||||
├── clock_ntp_ota_v1.9.ino # Main firmware (2,096 lines)
|
||||
├── v1.9_RELEASE_NOTES.md # Detailed changelog
|
||||
├── v1.9.1_HYBRID_FIX.md # WiFi startup fix documentation
|
||||
├── README.md # This file
|
||||
└── build/
|
||||
├── clock_ntp_ota_v1.9.ino.bin # Compiled firmware
|
||||
├── clock_ntp_ota_v1.9.ino.elf # Debug symbols
|
||||
└── clock_ntp_ota_v1.9.ino.map # Memory map
|
||||
esp8266-weather-clock/
|
||||
├── firmware/
|
||||
│ └── clock_ntp_ota_v1.9/
|
||||
│ └── clock_ntp_ota_v1.9.ino # Main firmware (~2,100 lines)
|
||||
├── docs/
|
||||
│ ├── HARDWARE.md # Hardware specifications
|
||||
│ ├── INSTALLATION.md # Flashing guide
|
||||
│ ├── v1.9_RELEASE_NOTES.md # v1.9.0 async refactoring
|
||||
│ ├── 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)
|
||||
- ✅ 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.
|
||||
|
||||
@@ -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!
|
||||
|
||||
**Built with**: Claude Code (Opus 4.5)
|
||||
**Author**: apetrochenko
|
||||
**Date**: 2026-01-03
|
||||
**Firmware Version**: v1.9.1 (Production Ready)
|
||||
**Date**: 2026-01-06
|
||||
**Firmware Version**: v1.9.2 (Production Ready)
|
||||
|
||||
+135
-135
@@ -1,71 +1,71 @@
|
||||
# v1.9.1 - Hybrid Async Fix
|
||||
|
||||
## Проблема в v1.9.0
|
||||
## Problem in v1.9.0
|
||||
|
||||
**Симптомы:**
|
||||
- Дисплей показывает пустой экран ~10 секунд после загрузки
|
||||
- Ошибка "DNS resolution failed" в логах
|
||||
- Время появляется только через 10+ секунд
|
||||
**Symptoms:**
|
||||
- Display shows blank screen ~10 seconds after boot
|
||||
- "DNS resolution failed" errors in logs
|
||||
- Time appears only after 10+ seconds
|
||||
|
||||
**Причина:**
|
||||
**Root Cause:**
|
||||
```cpp
|
||||
void setup() {
|
||||
loadConfig();
|
||||
setupWiFi(); // ← Возвращается СРАЗУ (async)
|
||||
setupOTA(); // ← WiFi НЕ готов! ✗
|
||||
setupWebServer(); // ← WiFi НЕ готов! ✗
|
||||
testInternetConnectivity(); // ← WiFi НЕ готов! → "DNS resolution failed"
|
||||
setupWiFi(); // Returns IMMEDIATELY (async)
|
||||
setupOTA(); // WiFi NOT ready!
|
||||
setupWebServer(); // WiFi NOT ready!
|
||||
testInternetConnectivity(); // WiFi NOT ready! -> "DNS resolution failed"
|
||||
}
|
||||
```
|
||||
|
||||
WiFi стал **полностью асинхронным**, но это **неправильно для setup()**:
|
||||
- OTA, web server, NTP **требуют готовое WiFi соединение**
|
||||
- `testInternetConnectivity()` запускался **до подключения WiFi**
|
||||
- Время на дисплее появлялось только когда async WiFi наконец подключался
|
||||
WiFi became **fully asynchronous**, but this is **wrong for setup()**:
|
||||
- OTA, web server, NTP **require a ready WiFi connection**
|
||||
- `testInternetConnectivity()` ran **before WiFi connected**
|
||||
- Time on display appeared only when async WiFi finally connected
|
||||
|
||||
## Решение: Гибридная модель
|
||||
## Solution: Hybrid Model
|
||||
|
||||
| Фаза | WiFi режим | Блокировка | Причина |
|
||||
|------|------------|------------|---------|
|
||||
| **setup()** | **Синхронный** | 10 сек | Нужен для инициализации OTA/web/NTP |
|
||||
| **loop()** | **Асинхронный** | 0 сек | Не замораживать при reconnect |
|
||||
| Phase | WiFi Mode | Blocking | Reason |
|
||||
|-------|-----------|----------|--------|
|
||||
| **setup()** | **Synchronous** | 10 sec | Required for OTA/web/NTP initialization |
|
||||
| **loop()** | **Asynchronous** | 0 sec | Don't freeze on reconnect |
|
||||
|
||||
### Изменения в коде
|
||||
### Code Changes
|
||||
|
||||
#### 1. setupWiFi() - теперь синхронный
|
||||
#### 1. setupWiFi() - Now Synchronous
|
||||
|
||||
```cpp
|
||||
void ICACHE_FLASH_ATTR setupWiFi() {
|
||||
Serial.println("WiFi Setup - Synchronous for initial connection");
|
||||
|
||||
|
||||
WiFi.hostname(config.hostname);
|
||||
|
||||
|
||||
if (strlen(config.ssid) > 0) {
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(config.ssid, config.password);
|
||||
|
||||
// СИНХРОННОЕ ожидание (max 10 секунд)
|
||||
|
||||
// SYNCHRONOUS wait (max 10 seconds)
|
||||
int attempts = 0;
|
||||
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
|
||||
delay(500);
|
||||
showNumber(attempts, false); // Показываем прогресс на дисплее
|
||||
showNumber(attempts, false); // Show progress on display
|
||||
attempts++;
|
||||
}
|
||||
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
// ✅ WiFi готов для OTA/web/NTP!
|
||||
// WiFi ready for OTA/web/NTP!
|
||||
showIP();
|
||||
wifiConnState = WIFI_CONN_CONNECTED;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to WiFiManager если credentials не сработали
|
||||
|
||||
// Fallback to WiFiManager if credentials didn't work
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. loop() - async reconnect
|
||||
#### 2. loop() - Async Reconnect
|
||||
|
||||
```cpp
|
||||
void loop() {
|
||||
@@ -73,146 +73,146 @@ void loop() {
|
||||
static unsigned long lastWiFiCheck = 0;
|
||||
if (millis() - lastWiFiCheck > 5000) {
|
||||
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
|
||||
wifiConnState = WIFI_CONN_CONNECTING;
|
||||
wifiConnectStart = millis();
|
||||
}
|
||||
lastWiFiCheck = millis();
|
||||
}
|
||||
|
||||
processWiFiConnection(); // Async обработка reconnect
|
||||
|
||||
// Остальные async операции
|
||||
|
||||
processWiFiConnection(); // Async reconnect handling
|
||||
|
||||
// Other async operations
|
||||
processNTPResponse();
|
||||
fetchWeatherAsync();
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Результаты тестирования
|
||||
## Test Results
|
||||
|
||||
### До (v1.9.0)
|
||||
### Before (v1.9.0)
|
||||
```
|
||||
⏱️ 0-5s → Display init
|
||||
⏱️ 5-15s → WiFi connecting (async, setup() возвращается сразу)
|
||||
⏱️ 15-20s → OTA/web init БЕЗ WiFi → ✗ Errors!
|
||||
⏱️ 20s → testInternetConnectivity() БЕЗ WiFi → "DNS resolution failed"
|
||||
⏱️ 15-25s → WiFi finally connects (async)
|
||||
⏱️ 25-30s → NTP sync начинается
|
||||
[0-5s] -> Display init
|
||||
[5-15s] -> WiFi connecting (async, setup() returns immediately)
|
||||
[15-20s] -> OTA/web init WITHOUT WiFi -> Errors!
|
||||
[20s] -> testInternetConnectivity() WITHOUT WiFi -> "DNS resolution failed"
|
||||
[15-25s] -> WiFi finally connects (async)
|
||||
[25-30s] -> NTP sync begins
|
||||
|
||||
❌ Display blank for 10+ seconds
|
||||
❌ "DNS resolution failed" errors
|
||||
Display blank for 10+ seconds
|
||||
"DNS resolution failed" errors
|
||||
```
|
||||
|
||||
### После (v1.9.1)
|
||||
### After (v1.9.1)
|
||||
```
|
||||
⏱️ 0-5s → Display init + startup animation
|
||||
⏱️ 5-15s → WiFi connection (SYNCHRONOUS, setup() waits)
|
||||
✅ WiFi connected!
|
||||
⏱️ 15-20s → OTA/web/NTP init С WiFi
|
||||
✅ Internet test: PASSED
|
||||
✅ No DNS errors!
|
||||
⏱️ 20-30s → First async NTP sync
|
||||
✅ Time synced!
|
||||
[0-5s] -> Display init + startup animation
|
||||
[5-15s] -> WiFi connection (SYNCHRONOUS, setup() waits)
|
||||
WiFi connected!
|
||||
[15-20s] -> OTA/web/NTP init WITH WiFi
|
||||
Internet test: PASSED
|
||||
No DNS errors!
|
||||
[20-30s] -> First async NTP sync
|
||||
Time synced!
|
||||
|
||||
✅ Display shows time immediately after WiFi connects (~15 sec)
|
||||
✅ No "DNS resolution failed" errors
|
||||
✅ Proper initialization order
|
||||
Display shows time immediately after WiFi connects (~15 sec)
|
||||
No "DNS resolution failed" errors
|
||||
Proper initialization order
|
||||
```
|
||||
|
||||
## Startup Timeline
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ SETUP PHASE (Synchronous WiFi) │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [0s] ┌─────────────┐ │
|
||||
│ │ Display │ Startup animation │
|
||||
│ [5s] │ Init │ "Weather Clock v1.9.1" │
|
||||
│ └─────────────┘ │
|
||||
│ │
|
||||
│ [5s] ┌─────────────────────────────────┐ │
|
||||
│ │ WiFi Connect (SYNCHRONOUS) │ │
|
||||
│ │ - Connecting to SibWings... │ │
|
||||
│ [15s] │ - ✅ Connected! IP assigned │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ WiFi is READY here ✅ │
|
||||
│ ↓ │
|
||||
│ [15s] ┌─────────────────────────────────┐ │
|
||||
│ │ OTA Init (needs WiFi ✅) │ │
|
||||
│ │ Web Server (needs WiFi ✅) │ │
|
||||
│ [20s] │ NTP Client (needs WiFi ✅) │ │
|
||||
│ │ Internet Test (needs WiFi ✅) │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [20s] Setup complete! → loop() starts │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
+----------------------------------------------------------+
|
||||
| SETUP PHASE (Synchronous WiFi) |
|
||||
+----------------------------------------------------------+
|
||||
| |
|
||||
| [0s] +-------------+ |
|
||||
| | Display | Startup animation |
|
||||
| [5s] | Init | "Weather Clock v1.9.1" |
|
||||
| +-------------+ |
|
||||
| |
|
||||
| [5s] +---------------------------------+ |
|
||||
| | WiFi Connect (SYNCHRONOUS) | |
|
||||
| | - Connecting to network... | |
|
||||
| [15s] | - Connected! IP assigned | |
|
||||
| +---------------------------------+ |
|
||||
| | |
|
||||
| WiFi is READY here |
|
||||
| | |
|
||||
| [15s] +---------------------------------+ |
|
||||
| | OTA Init (needs WiFi) | |
|
||||
| | Web Server (needs WiFi) | |
|
||||
| [20s] | NTP Client (needs WiFi) | |
|
||||
| | Internet Test (needs WiFi) | |
|
||||
| +---------------------------------+ |
|
||||
| |
|
||||
| [20s] Setup complete! -> loop() starts |
|
||||
| |
|
||||
+----------------------------------------------------------+
|
||||
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ LOOP PHASE (Async Operations) │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [Every loop] ┌──────────────────────────┐ │
|
||||
│ │ WiFi Health Check │ │
|
||||
│ │ (every 5 sec) │ │
|
||||
│ │ If disconnected: │ │
|
||||
│ │ → Async reconnect │ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ │
|
||||
│ [Every loop] ┌──────────────────────────┐ │
|
||||
│ │ Async NTP Processing │ │
|
||||
│ │ (non-blocking) │ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ │
|
||||
│ [Every 30m] ┌──────────────────────────┐ │
|
||||
│ │ Async Weather Fetch │ │
|
||||
│ │ (non-blocking) │ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ │
|
||||
│ Loop time: <1ms (no blocking!) │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
+----------------------------------------------------------+
|
||||
| LOOP PHASE (Async Operations) |
|
||||
+----------------------------------------------------------+
|
||||
| |
|
||||
| [Every loop] +------------------------+ |
|
||||
| | WiFi Health Check | |
|
||||
| | (every 5 sec) | |
|
||||
| | If disconnected: | |
|
||||
| | -> Async reconnect | |
|
||||
| +------------------------+ |
|
||||
| |
|
||||
| [Every loop] +------------------------+ |
|
||||
| | Async NTP Processing | |
|
||||
| | (non-blocking) | |
|
||||
| +------------------------+ |
|
||||
| |
|
||||
| [Every 30m] +------------------------+ |
|
||||
| | Async Weather Fetch | |
|
||||
| | (non-blocking) | |
|
||||
| +------------------------+ |
|
||||
| |
|
||||
| Loop time: <1ms (no blocking!) |
|
||||
| |
|
||||
+----------------------------------------------------------+
|
||||
```
|
||||
|
||||
## Преимущества гибридного подхода
|
||||
## Benefits of Hybrid Approach
|
||||
|
||||
### ✅ В setup():
|
||||
1. **Правильный порядок инициализации** - WiFi → OTA → web → NTP
|
||||
2. **Нет ошибок DNS** - internet connectivity test запускается ПОСЛЕ WiFi
|
||||
3. **Предсказуемое поведение** - setup() завершается когда всё готово
|
||||
4. **Дисплей показывает время сразу** - не нужно ждать async WiFi
|
||||
### In setup():
|
||||
1. **Correct initialization order** - WiFi -> OTA -> web -> NTP
|
||||
2. **No DNS errors** - internet connectivity test runs AFTER WiFi
|
||||
3. **Predictable behavior** - setup() completes when everything is ready
|
||||
4. **Display shows time immediately** - no need to wait for async WiFi
|
||||
|
||||
### ✅ В loop():
|
||||
1. **Не зависает при reconnect** - async обработка потери WiFi
|
||||
2. **Async NTP** - не блокирует loop
|
||||
3. **Async weather** - не блокирует loop
|
||||
4. **Exponential backoff** - умные retry при ошибках
|
||||
5. **Loop <1ms** - всегда отзывчивое устройство
|
||||
### In loop():
|
||||
1. **No freeze on reconnect** - async handling of WiFi loss
|
||||
2. **Async NTP** - doesn't block loop
|
||||
3. **Async weather** - doesn't block loop
|
||||
4. **Exponential backoff** - smart retry on errors
|
||||
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 |
|
||||
| IRAM | 61,987 | 61,987 | 0 bytes |
|
||||
| Flash | 408,540 | 408,844 | +304 bytes |
|
||||
|
||||
Минимальные изменения памяти (+0.3%) для критического улучшения UX.
|
||||
Minimal memory changes (+0.3%) for critical UX improvement.
|
||||
|
||||
## Заключение
|
||||
## Conclusion
|
||||
|
||||
**v1.9.1 реализует идеальный баланс:**
|
||||
- Setup: Синхронный для надежной инициализации
|
||||
- Loop: Асинхронный для отзывчивости
|
||||
**v1.9.1 implements the ideal balance:**
|
||||
- Setup: Synchronous for reliable initialization
|
||||
- Loop: Asynchronous for responsiveness
|
||||
|
||||
**Результат:**
|
||||
- ✅ Дисплей показывает время через 15 сек (вместо 25+ сек)
|
||||
- ✅ Никаких ошибок "DNS resolution failed"
|
||||
- ✅ Правильный порядок старта
|
||||
- ✅ Устройство не зависает при потере WiFi в работе
|
||||
**Result:**
|
||||
- Display shows time after 15 sec (instead of 25+ sec)
|
||||
- No "DNS resolution failed" errors
|
||||
- Correct startup order
|
||||
- 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):
|
||||
* - FIXED STARTUP: WiFi now synchronous in setup() for proper initialization
|
||||
@@ -69,7 +76,7 @@
|
||||
|
||||
// Configuration structure with validation
|
||||
#define CONFIG_MAGIC 0xC10CC10C // Magic number to validate EEPROM data
|
||||
#define FIRMWARE_VERSION "1.9.1"
|
||||
#define FIRMWARE_VERSION "1.9.2"
|
||||
|
||||
struct Config {
|
||||
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 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 showIP();
|
||||
void ICACHE_FLASH_ATTR handleRoot();
|
||||
@@ -200,12 +208,14 @@ NTPClient timeClient(ntpUDP, "pool.ntp.org", 0, 60000);
|
||||
|
||||
// Exponential backoff retry configuration
|
||||
struct RetryConfig {
|
||||
uint8_t maxRetries = 3;
|
||||
uint8_t maxRetries = 3; // For NTP/Weather: give up after 3 tries
|
||||
uint8_t currentRetry = 0;
|
||||
unsigned long nextRetryTime = 0;
|
||||
unsigned long maxBackoffMs = 8000; // Max backoff 8 seconds for NTP/Weather
|
||||
|
||||
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() {
|
||||
@@ -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
|
||||
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
|
||||
RetryConfig ntpRetry;
|
||||
RetryConfig weatherRetry;
|
||||
WiFiRetryConfig wifiRetry;
|
||||
|
||||
// Web server
|
||||
ESP8266WebServer server(80);
|
||||
@@ -398,20 +436,45 @@ void loop() {
|
||||
server.handleClient();
|
||||
MDNS.update();
|
||||
|
||||
// WiFi reconnection logic (async, non-blocking)
|
||||
// If WiFi disconnects during operation, try to reconnect asynchronously
|
||||
// WiFi reconnection logic (async, non-blocking with exponential backoff)
|
||||
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) {
|
||||
Serial.println("⚠️ WiFi disconnected, attempting async reconnect...");
|
||||
WiFi.begin(); // Try to reconnect with saved credentials
|
||||
Serial.println("⚠️ WiFi disconnected!");
|
||||
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;
|
||||
wifiConnectStart = millis();
|
||||
}
|
||||
lastWiFiCheck = millis();
|
||||
}
|
||||
|
||||
// Process async WiFi reconnection (only if reconnecting, not during initial setup)
|
||||
// Process async WiFi reconnection
|
||||
processWiFiConnection();
|
||||
|
||||
// Process async NTP response (non-blocking check)
|
||||
@@ -635,7 +698,152 @@ void processWiFiConnection() {
|
||||
// Check connection status
|
||||
if (WiFi.status() == WL_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.println(WiFi.SSID());
|
||||
Serial.print("IP: ");
|
||||
@@ -647,147 +855,15 @@ void processWiFiConnection() {
|
||||
|
||||
// Show IP on display
|
||||
showIP();
|
||||
wifiConnState = WIFI_CONN_CONNECTED;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check timeout
|
||||
if (millis() - wifiConnectStart > WIFI_TIMEOUT_MS) {
|
||||
wifiConnState = WIFI_CONN_FAILED;
|
||||
Serial.println("\n⚠️ WiFi async connection timeout");
|
||||
Serial.println("Falling back to WiFiManager...");
|
||||
|
||||
// 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
|
||||
// We have credentials but WiFi is not available - will retry in loop()
|
||||
Serial.println("\n⚠️ WiFi not available. Will retry in background.");
|
||||
wifiConnState = WIFI_CONN_FAILED;
|
||||
wifiRetry.scheduleRetry();
|
||||
showNoWiFi(wifiRetry.getBackoffDelay() / 1000);
|
||||
}
|
||||
|
||||
// OTA setup
|
||||
@@ -866,7 +942,7 @@ void ICACHE_FLASH_ATTR setupWebServer() {
|
||||
|
||||
// Update OLED display with current time
|
||||
// BLUE zone (top 48px): Large time
|
||||
// YELLOW zone (bottom 16px): Date
|
||||
// YELLOW zone (bottom 16px): Date + WiFi status
|
||||
void updateDisplay() {
|
||||
static unsigned long lastUpdate = 0;
|
||||
|
||||
@@ -876,17 +952,29 @@ void updateDisplay() {
|
||||
|
||||
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.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");
|
||||
}
|
||||
display.display();
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate local time with DST
|
||||
unsigned long epochTime = timeClient.getEpochTime();
|
||||
// Get epoch from best available source
|
||||
unsigned long epochTime = timeIsSynced ? getAsyncEpoch() : timeClient.getEpochTime();
|
||||
unsigned long localTime = epochTime + getTotalOffset(epochTime);
|
||||
|
||||
int hours = (localTime / 3600) % 24;
|
||||
@@ -903,10 +991,16 @@ void updateDisplay() {
|
||||
time_t t = epochTime;
|
||||
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"};
|
||||
char dateStr[16];
|
||||
sprintf(dateStr, "%s %02d.%02d", days[ptm->tm_wday], ptm->tm_mday, ptm->tm_mon + 1);
|
||||
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
|
||||
@@ -1109,6 +1203,28 @@ void ICACHE_FLASH_ATTR showNumber(int num, bool leadingZeros) {
|
||||
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)
|
||||
void displaySegments(const uint8_t segments[]) {
|
||||
// Not used with OLED - kept for compatibility
|
||||
|
||||
Reference in New Issue
Block a user