7 Commits
Author SHA1 Message Date
Alex PetrochenkoandClaude Opus 4.5 9c0f269ee5 chore: bump version to 1.9.3
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 09:44:57 +00:00
Alex PetrochenkoandClaude Opus 4.5 abf3a513c9 docs: update documentation for v1.9.2
- CHANGELOG.md: Add v1.9.2 WiFi resilience changes
- README.md: Update journey section, memory stats, project structure
- docs/v1.9.1_HYBRID_FIX.md: Minor formatting fixes
- docs/v1.9.2_WIFI_RESILIENCE.md: New detailed documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 09:19:19 +00:00
Alex PetrochenkoandClaude Opus 4.5 4a7e889052 refactor: split monolithic .ino into modular structure + improve boot UX
## Code Structure
- Split 2172-line .ino into 7 modules:
  - config.h - structs, enums, constants
  - globals.h - extern declarations, function prototypes
  - display.cpp - OLED display functions, Thanos dissolve effect
  - ntp_client.cpp - NTP sync, DST calculation
  - wifi_manager.cpp - WiFi connection management
  - weather.cpp - Open-Meteo API (async)
  - web_server.cpp - Web interface handlers
  - clock_ntp_ota_v1.9.ino - setup/loop, EEPROM, OTA (153 lines)

## Boot UX Improvements
- Remove meaningless "READY" screen
- Replace cryptic counter (0-7) with "WiFi..." + animated dots
- Show SSID + IP on connected screen
- Use yellow zone (Y 48-63) for status text
- Fix text centering on all boot screens
- Initialize lastBlinkTime/lastModeSwitch to prevent first-frame glitch

## Layout (dual-color display)
- Blue zone (Y 0-47): main content
- Yellow zone (Y 48-63): status/version
- Centering formula: width = n×5 + (n-1)×1 for size 1

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 09:05:45 +00:00
Alex PetrochenkoandAlex Petrochenko c3bfaa26bc feat(wifi): v1.9.2 - WiFi resilience and credential persistence (#1)
## Problem
Previous versions would clear WiFi credentials on connection failure,
requiring manual reconfiguration via captive portal after each WiFi outage.

## Solution
- Never clear credentials on connection failure
- Infinite retry with exponential backoff (5s → 10s → 20s → ... → 5min max)
- Fallback AP ("TJ56654-Setup") enabled after ~5 min of failed attempts
- Dual STA+AP mode allows configuration while still retrying connection
- AP automatically disabled when WiFi reconnects

## Changes

### WiFi Credential Handling
- Try SDK-stored credentials first (from WiFiManager)
- Fall back to EEPROM config if SDK credentials fail
- Fixes OTA update credential loss issue

### User Experience
- Display shows "No WiFi" with retry countdown instead of cryptic numbers
- "!" indicator in date line when WiFi is disconnected
- Clock continues running with last synced time during outage

### Network Activity (~50 requests/day)
| Service | Interval | Endpoint |
|---------|----------|----------|
| NTP | 1 hour | pool.ntp.org:123 (UDP) |
| Weather | 30 min | api.open-meteo.com (HTTP) |
| mDNS | continuous | 224.0.0.251 (multicast) |

Co-authored-by: Alex Petrochenko <petrochenko@life-pay.ru>
2026-01-06 20:51:42 +00:00
Alex Petrochenko 0f2860c25e Add comprehensive project context documentation
Complete context file with:
- Full development history (v1.5 → v1.9.1)
- All technical details and architecture
- Security issues and fixes
- Hardware specifications and pinout
- Current device configuration
- Next steps (Home Assistant integration)
- Key learnings from project
- Quick reference commands

This file serves as complete knowledge base for:
- Resuming work after long breaks
- Onboarding new contributors
- Understanding project evolution
- Reference for future features
2026-01-03 15:59:24 +00:00
Alex Petrochenko 220544583a Update price: $12 → €5
The device actually costs €5 on AliExpress, not $12.
Updated all price mentions in README.
2026-01-03 15:26:50 +00:00
Alex Petrochenko f2dc2fb961 Add dynamic GitHub badges to README
- Release version badge (auto-updates)
- License badge (MIT)
- Build status badge (CI/CD)
- Issues count badge
- Hardware and status badges
2026-01-03 15:25:16 +00:00
16 changed files with 3931 additions and 389 deletions
-73
View File
@@ -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
View File
@@ -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.
+800
View File
@@ -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!**
+86 -23
View File
@@ -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
View File
@@ -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
+313
View File
@@ -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
+3
View File
@@ -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();
}
}
+168
View File
@@ -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
+476
View File
@@ -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
}
+143
View File
@@ -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
+205
View File
@@ -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);
}
}
+170
View File
@@ -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 += "&current_weather=true";
url += "&daily=sunrise,sunset";
url += "&timezone=auto";
url += "&forecast_days=1";
Serial.println(F("Fetching weather (async)..."));
if (weatherRequest.open("GET", url.c_str())) {
weatherRequest.onReadyStateChange(onWeatherResponse);
weatherRequest.setTimeout(10); // 10 seconds
weatherRequest.send();
weatherState = WEATHER_REQUESTING;
Serial.println("Weather request sent (non-blocking)");
} else {
weatherState = WEATHER_FAILED;
Serial.println("Failed to open weather request");
}
}
// Calculate sun times (placeholder - data comes from API)
void ICACHE_FLASH_ATTR calculateSunTimes() {
if (!config.show_sunrise_sunset) return;
if (sunTimes.lastDay != -1) {
Serial.println(F("Sun times already available from API"));
return;
}
Serial.println(F("Sun times not available yet - will be fetched with weather"));
}
+611
View File
@@ -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
View File
@@ -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