Compare commits
59 Commits
5ce7cd43a4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e90e56c3f8 | |||
| 5cfa36e558 | |||
| c8102e62ee | |||
| d76b11430c | |||
| cb274545a3 | |||
| 6cd1349633 | |||
| bcecf2a81a | |||
| 621a48f011 | |||
| ce816af9e7 | |||
| 519cb25038 | |||
| f81d89980b | |||
| 7fc7661dad | |||
| 3b7982a3a3 | |||
| 697f0bf31e | |||
| b632a76d5a | |||
| d670067b89 | |||
| ac860207d9 | |||
| 03b26b5339 | |||
| 5a8d14eb4d | |||
| 79445bf879 | |||
| 4400fb5a74 | |||
| c6f46e097b | |||
| d2c0f68488 | |||
| 13e3a56fa6 | |||
| efea4a1384 | |||
| 73b5a5aefe | |||
| 03766d6b09 | |||
| e5f9331d30 | |||
| 6128e585b8 | |||
| 81174b78e4 | |||
| 70cc2cad81 | |||
| 6bc7b1da93 | |||
| eceee9c88d | |||
| 72eb3c2acf | |||
| eff69cfe52 | |||
| 63588ee3f1 | |||
| 8363406647 | |||
| df08692726 | |||
| 0030e0a932 | |||
| d95f212d2e | |||
| 0f7c4cc4d7 | |||
| a9641947ba | |||
| 63ff2cec77 | |||
| 6890d0570e | |||
| a20bbd7cdf | |||
| 7edd209abe | |||
| 2c39ebd985 | |||
| 1016e96b58 | |||
| b3c56864ac | |||
| 95e159ee5d | |||
| 5da44e1397 | |||
| b346be9431 | |||
| 229bde85e9 | |||
| dae6971112 | |||
| 3c2e936d56 | |||
| 9da21f7c89 | |||
| b6aae121bb | |||
| 24b53b9446 | |||
| 749eb956a5 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
/__pycache__/
|
/__pycache__/
|
||||||
secrets.py
|
secrets.py
|
||||||
|
config.json
|
||||||
/pymakr-test/
|
/pymakr-test/
|
||||||
.gitignore
|
.gitignore
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
99
README.md
99
README.md
@@ -2,9 +2,14 @@
|
|||||||
|
|
||||||
> Automated climate control system using Raspberry Pi Pico W with web interface and scheduling
|
> Automated climate control system using Raspberry Pi Pico W with web interface and scheduling
|
||||||
|
|
||||||
## Overview
|
## Recent Updates
|
||||||
|
|
||||||
This project provides automated climate monitoring and control using a Raspberry Pi Pico W. Features dual-zone temperature monitoring, AC/heater control, time-based scheduling, and a web interface for easy management.
|
- 🆕 **Immediate schedule application:** When resuming scheduling from hold mode, the system now instantly applies the current schedule targets (no delay).
|
||||||
|
- 🆕 **Aggressive memory management:** Garbage collection runs every 5 seconds for improved reliability.
|
||||||
|
- 🆕 **Manual hold settings:** `ac_target` and `heater_target` in `config.json` now only store your last manual hold settings, not schedule targets.
|
||||||
|
- 🆕 **NTP sync optimization:** NTP modules are loaded only when needed, saving RAM.
|
||||||
|
- 🆕 **Temperature validation:** Impossible sensor readings are ignored for safety.
|
||||||
|
- 🆕 **Improved config persistence:** All changes are saved and reloaded immediately.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -17,6 +22,7 @@ This project provides automated climate monitoring and control using a Raspberry
|
|||||||
- ✅ Configurable alert thresholds
|
- ✅ Configurable alert thresholds
|
||||||
- ✅ Exception recovery (system won't crash permanently)
|
- ✅ Exception recovery (system won't crash permanently)
|
||||||
- ✅ Graceful shutdown with Ctrl+C
|
- ✅ Graceful shutdown with Ctrl+C
|
||||||
|
- ✅ **Aggressive garbage collection for stability**
|
||||||
|
|
||||||
- **Climate Control**
|
- **Climate Control**
|
||||||
- ✅ Automated AC control with temperature swing logic
|
- ✅ Automated AC control with temperature swing logic
|
||||||
@@ -24,6 +30,7 @@ This project provides automated climate monitoring and control using a Raspberry
|
|||||||
- ✅ Short-cycle protection for both AC and heater
|
- ✅ Short-cycle protection for both AC and heater
|
||||||
- ✅ Dual relay control via opto-coupler for 110V AC
|
- ✅ Dual relay control via opto-coupler for 110V AC
|
||||||
- ✅ Mutual exclusion (AC and heater never run simultaneously)
|
- ✅ Mutual exclusion (AC and heater never run simultaneously)
|
||||||
|
- ✅ **Manual hold settings are preserved and not overwritten by schedules**
|
||||||
|
|
||||||
- **Scheduling System**
|
- **Scheduling System**
|
||||||
- ✅ 4 configurable time-based schedules per day
|
- ✅ 4 configurable time-based schedules per day
|
||||||
@@ -33,6 +40,7 @@ This project provides automated climate monitoring and control using a Raspberry
|
|||||||
- ✅ Permanent hold mode (manual control until restart)
|
- ✅ Permanent hold mode (manual control until restart)
|
||||||
- ✅ Schedule configuration persists through reboots
|
- ✅ Schedule configuration persists through reboots
|
||||||
- ✅ Hold modes reset to Automatic on restart (safety feature)
|
- ✅ Hold modes reset to Automatic on restart (safety feature)
|
||||||
|
- ✅ **Immediate schedule application after resuming from hold**
|
||||||
|
|
||||||
- **Web Interface**
|
- **Web Interface**
|
||||||
- ✅ Real-time temperature display
|
- ✅ Real-time temperature display
|
||||||
@@ -43,11 +51,23 @@ This project provides automated climate monitoring and control using a Raspberry
|
|||||||
- ✅ Countdown timer for temporary holds
|
- ✅ Countdown timer for temporary holds
|
||||||
- ✅ Mobile-responsive design
|
- ✅ Mobile-responsive design
|
||||||
- ✅ Auto-refresh dashboard (30 seconds)
|
- ✅ Auto-refresh dashboard (30 seconds)
|
||||||
|
- ✅ **Settings and schedule changes are reflected instantly**
|
||||||
|
|
||||||
- **Planned Features**
|
## Configuration Notes
|
||||||
- 🚧 Humidity monitoring (DHT22/SHT31)
|
|
||||||
- 🚧 Soil moisture monitoring
|
- **AC/Heater target settings:**
|
||||||
- 🚧 Additional relay control for fans, grow lights
|
- `ac_target` and `heater_target` in `config.json` are updated whenever you use Temp Hold, Perm Hold, or when a schedule is applied.
|
||||||
|
- When schedules are active, these values are updated to match the current schedule’s targets.
|
||||||
|
- This ensures the config file always reflects the current operating temperatures, whether in hold mode or schedule mode.
|
||||||
|
|
||||||
|
- **Immediate schedule application:**
|
||||||
|
- When you click "Resume Scheduling," the system applies the current schedule targets instantly, so the dashboard updates without delay.
|
||||||
|
|
||||||
|
- **Memory management:**
|
||||||
|
- Garbage collection runs every 5 seconds to prevent memory fragmentation and crashes.
|
||||||
|
|
||||||
|
- **Sensor validation:**
|
||||||
|
- Temperatures outside the range -50°F to 150°F are ignored to prevent false readings.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -126,56 +146,28 @@ RUN pin → Button → GND
|
|||||||
|
|
||||||
### 4. Configuration
|
### 4. Configuration
|
||||||
|
|
||||||
**Create `secrets.py`** (copy from `secrets.example.py`):
|
**Edit `config.json`** (created automatically on first boot, or edit manually):
|
||||||
|
|
||||||
```python
|
```json
|
||||||
secrets = {
|
|
||||||
'ssid': 'YOUR_WIFI_NAME',
|
|
||||||
'password': 'YOUR_WIFI_PASSWORD',
|
|
||||||
'discord_webhook_url': 'https://discord.com/api/webhooks/...',
|
|
||||||
'discord_alert_webhook_url': 'https://discord.com/api/webhooks/...',
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Sensor Configuration in `main.py`:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Sensor configuration
|
|
||||||
SENSOR_CONFIG = {
|
|
||||||
'inside': {
|
|
||||||
'pin': 10,
|
|
||||||
'label': 'Inside',
|
|
||||||
'alert_high': 80.0,
|
|
||||||
'alert_low': 70.0
|
|
||||||
},
|
|
||||||
'outside': {
|
|
||||||
'pin': 11,
|
|
||||||
'label': 'Outside',
|
|
||||||
'alert_high': 85.0,
|
|
||||||
'alert_low': 68.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Default Climate Settings (auto-saved to config.json):**
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Default config (created on first boot)
|
|
||||||
{
|
{
|
||||||
"ac_target": 77.0, # AC target temperature (°F)
|
"ssid": "YOUR_WIFI_NAME",
|
||||||
"ac_swing": 1.0, # AC turns on at 78°F, off at 76°F
|
"password": "YOUR_WIFI_PASSWORD",
|
||||||
"heater_target": 72.0, # Heater target temperature (°F)
|
"discord_webhook_url": "https://discord.com/api/webhooks/...",
|
||||||
"heater_swing": 2.0, # Heater turns on at 70°F, off at 74°F
|
"discord_alert_webhook_url": "https://discord.com/api/webhooks/...",
|
||||||
"temp_hold_duration": 3600, # Temporary hold lasts 1 hour (3600 seconds)
|
"ac_target": 77.0,
|
||||||
"schedule_enabled": true, # Schedules active by default
|
"ac_swing": 1.0,
|
||||||
"schedules": [ # 4 time-based schedules
|
"heater_target": 72.0,
|
||||||
|
"heater_swing": 2.0,
|
||||||
|
"temp_hold_duration": 3600,
|
||||||
|
"schedule_enabled": true,
|
||||||
|
"schedules": [
|
||||||
{
|
{
|
||||||
"time": "06:00",
|
"time": "06:00",
|
||||||
"name": "Morning",
|
"name": "Morning",
|
||||||
"ac_target": 75.0,
|
"ac_target": 75.0,
|
||||||
"heater_target": 72.0
|
"heater_target": 72.0
|
||||||
},
|
}
|
||||||
# ... 3 more schedules
|
// ... 3 more schedules
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -189,7 +181,6 @@ Upload all files to your Pico:
|
|||||||
```text
|
```text
|
||||||
/
|
/
|
||||||
├── main.py
|
├── main.py
|
||||||
├── secrets.py
|
|
||||||
├── config.json # Auto-generated on first boot
|
├── config.json # Auto-generated on first boot
|
||||||
└── scripts/
|
└── scripts/
|
||||||
├── air_conditioning.py # AC/Heater controller classes
|
├── air_conditioning.py # AC/Heater controller classes
|
||||||
@@ -208,9 +199,7 @@ The Pico will auto-start `main.py` on boot and be accessible at **<http://192.16
|
|||||||
```text
|
```text
|
||||||
Auto-Garden/
|
Auto-Garden/
|
||||||
├── main.py # Entry point, configuration, system initialization
|
├── main.py # Entry point, configuration, system initialization
|
||||||
├── secrets.py # WiFi & Discord credentials (gitignored)
|
├── config.json # Persistent configuration and credentials (auto-generated)
|
||||||
├── secrets.example.py # Template for secrets.py
|
|
||||||
├── config.json # Persistent configuration (auto-generated)
|
|
||||||
└── scripts/
|
└── scripts/
|
||||||
├── air_conditioning.py # AC & Heater controllers with short-cycle protection
|
├── air_conditioning.py # AC & Heater controllers with short-cycle protection
|
||||||
├── discord_webhook.py # Discord notification handling
|
├── discord_webhook.py # Discord notification handling
|
||||||
@@ -427,14 +416,14 @@ report_interval=30 # Discord report frequency
|
|||||||
|
|
||||||
**WiFi not connecting:**
|
**WiFi not connecting:**
|
||||||
|
|
||||||
- Verify SSID/password in `secrets.py`
|
- Verify SSID/password in `config.json`
|
||||||
- Check 2.4GHz WiFi (Pico W doesn't support 5GHz)
|
- Check 2.4GHz WiFi (Pico W doesn't support 5GHz)
|
||||||
- LED should be solid when connected
|
- LED should be solid when connected
|
||||||
- Check serial console for connection status
|
- Check serial console for connection status
|
||||||
|
|
||||||
**Discord messages not sending:**
|
**Discord messages not sending:**
|
||||||
|
|
||||||
- Verify webhook URLs in `secrets.py`
|
- Verify webhook URLs in `config.json`
|
||||||
- Test webhooks with curl/Postman first
|
- Test webhooks with curl/Postman first
|
||||||
- Check Pico has internet access (ping test)
|
- Check Pico has internet access (ping test)
|
||||||
- Look for error messages in serial console
|
- Look for error messages in serial console
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from machine import Pin
|
from machine import Pin # type: ignore
|
||||||
import time
|
import time # type: ignore
|
||||||
|
|
||||||
class ACController:
|
class ACController:
|
||||||
"""Control AC unit via opto-coupler relay."""
|
"""Control AC unit via opto-coupler relay."""
|
||||||
|
|||||||
@@ -1,8 +1,25 @@
|
|||||||
import urequests as requests # type: ignore
|
# Minimal module-level state (only what we need)
|
||||||
from secrets import secrets
|
_CONFIG = {"discord_webhook_url": None, "discord_alert_webhook_url": None}
|
||||||
|
# Cooldown after low-memory failures (epoch seconds)
|
||||||
|
_NEXT_ALLOWED_SEND_TS = 0
|
||||||
|
|
||||||
|
def set_config(cfg: dict):
|
||||||
|
"""Initialize module with minimal values from loaded config (call from main)."""
|
||||||
|
global _CONFIG
|
||||||
|
if not cfg:
|
||||||
|
_CONFIG = {"discord_webhook_url": None, "discord_alert_webhook_url": None}
|
||||||
|
return
|
||||||
|
_CONFIG = {
|
||||||
|
"discord_webhook_url": cfg.get("discord_webhook_url"),
|
||||||
|
"discord_alert_webhook_url": cfg.get("discord_alert_webhook_url"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_webhook_url(is_alert: bool = False):
|
||||||
|
if is_alert:
|
||||||
|
return _CONFIG.get("discord_alert_webhook_url") or _CONFIG.get("discord_webhook_url")
|
||||||
|
return _CONFIG.get("discord_webhook_url")
|
||||||
|
|
||||||
def _escape_json_str(s: str) -> str:
|
def _escape_json_str(s: str) -> str:
|
||||||
# minimal JSON string escaper for quotes/backslashes and control chars
|
|
||||||
s = s.replace("\\", "\\\\")
|
s = s.replace("\\", "\\\\")
|
||||||
s = s.replace('"', '\\"')
|
s = s.replace('"', '\\"')
|
||||||
s = s.replace("\n", "\\n")
|
s = s.replace("\n", "\\n")
|
||||||
@@ -10,45 +27,110 @@ def _escape_json_str(s: str) -> str:
|
|||||||
s = s.replace("\t", "\\t")
|
s = s.replace("\t", "\\t")
|
||||||
return s
|
return s
|
||||||
|
|
||||||
def send_discord_message(message, username="Auto Garden Bot", is_alert=False):
|
def send_discord_message(message, username="Auto Garden Bot", is_alert=False, debug: bool = False):
|
||||||
"""Send Discord message with 3-second timeout to prevent blocking."""
|
"""
|
||||||
|
Send Discord message with aggressive GC and low-memory guard to avoid ENOMEM.
|
||||||
|
When debug=True prints mem_free at important steps so you can see peak usage.
|
||||||
|
Returns True on success, False otherwise.
|
||||||
|
"""
|
||||||
|
global _NEXT_ALLOWED_SEND_TS
|
||||||
resp = None
|
resp = None
|
||||||
|
url = _get_webhook_url(is_alert=is_alert)
|
||||||
# Use alert webhook if specified, otherwise normal webhook
|
|
||||||
if is_alert:
|
|
||||||
url = secrets.get('discord_alert_webhook_url') or secrets.get('discord_webhook_url')
|
|
||||||
else:
|
|
||||||
url = secrets.get('discord_webhook_url')
|
|
||||||
|
|
||||||
try:
|
|
||||||
if not url:
|
if not url:
|
||||||
|
if debug: print("DBG: no webhook URL configured")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
url = url.strip().strip('\'"')
|
# Respect cooldown if we recently saw ENOMEM
|
||||||
|
try:
|
||||||
|
import time # type: ignore
|
||||||
|
now = time.time()
|
||||||
|
if _NEXT_ALLOWED_SEND_TS and now < _NEXT_ALLOWED_SEND_TS:
|
||||||
|
if debug: print("DBG: backing off until", _NEXT_ALLOWED_SEND_TS)
|
||||||
|
return False
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
# Build JSON manually to preserve emoji/unicode as UTF-8
|
try:
|
||||||
content = _escape_json_str(message)
|
# Lightweight local imports and GC
|
||||||
user = _escape_json_str(username)
|
import gc # type: ignore
|
||||||
|
import time # type: ignore
|
||||||
|
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
# Quick mem check before importing urequests/SSL
|
||||||
|
mem = getattr(gc, "mem_free", lambda: None)()
|
||||||
|
# Require larger headroom based on device testing (adjust if you re-test)
|
||||||
|
if mem is not None and mem < 95000:
|
||||||
|
print("Discord send skipped: ENOMEM ({} bytes free)".format(mem))
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Import urequests only when we plan to send
|
||||||
|
try:
|
||||||
|
import urequests as requests # type: ignore
|
||||||
|
except Exception as e:
|
||||||
|
print("Discord send failed: urequests import error:", e)
|
||||||
|
try:
|
||||||
|
_NEXT_ALLOWED_SEND_TS = time.time() + 60
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
gc.collect()
|
||||||
|
if debug:
|
||||||
|
try: print("DBG: mem after import:", gc.mem_free() // 1024, "KB")
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# Build tiny payload
|
||||||
|
url = str(url).strip().strip('\'"')
|
||||||
|
content = _escape_json_str(str(message)[:140])
|
||||||
|
user = _escape_json_str(str(username)[:32])
|
||||||
body_bytes = ('{"content":"%s","username":"%s"}' % (content, user)).encode("utf-8")
|
body_bytes = ('{"content":"%s","username":"%s"}' % (content, user)).encode("utf-8")
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
|
||||||
headers = {"Content-Type": "application/json; charset=utf-8"}
|
|
||||||
|
|
||||||
# Make POST request (urequests has built-in ~5s timeout)
|
|
||||||
resp = requests.post(url, data=body_bytes, headers=headers)
|
resp = requests.post(url, data=body_bytes, headers=headers)
|
||||||
|
|
||||||
status = getattr(resp, "status", getattr(resp, "status_code", None))
|
status = getattr(resp, "status", getattr(resp, "status_code", None))
|
||||||
|
return bool(status and 200 <= status < 300)
|
||||||
if status and 200 <= status < 300:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Silently fail (don't spam console with Discord errors)
|
print("Discord send failed:", e)
|
||||||
return False
|
|
||||||
finally:
|
|
||||||
if resp:
|
|
||||||
try:
|
try:
|
||||||
|
if ("ENOMEM" in str(e)) or isinstance(e, MemoryError):
|
||||||
|
import time # type: ignore
|
||||||
|
_NEXT_ALLOWED_SEND_TS = time.time() + 60
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if resp:
|
||||||
resp.close()
|
resp.close()
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
# remove local refs and unload heavy modules to free peak RAM (urequests, ussl/ssl)
|
||||||
|
try:
|
||||||
|
if 'resp' in locals(): del resp
|
||||||
|
if 'body_bytes' in locals(): del body_bytes
|
||||||
|
if 'content' in locals(): del content
|
||||||
|
if 'user' in locals(): del user
|
||||||
|
if 'headers' in locals(): del headers
|
||||||
|
if 'requests' in locals(): del requests
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
import sys
|
||||||
|
# remove urequests and SSL modules from module cache so their memory can be reclaimed
|
||||||
|
for m in ('urequests', 'ussl', 'ssl'):
|
||||||
|
if m in sys.modules:
|
||||||
|
try:
|
||||||
|
del sys.modules[m]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
import gc # type: ignore
|
||||||
|
gc.collect()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from machine import Pin
|
from machine import Pin # type: ignore
|
||||||
import time
|
import time # type: ignore
|
||||||
|
|
||||||
class HeaterController:
|
class HeaterController:
|
||||||
"""Control heater via opto-coupler relay."""
|
"""Control heater via opto-coupler relay."""
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import time # type: ignore
|
import time # type: ignore
|
||||||
from scripts.discord_webhook import send_discord_message
|
import scripts.discord_webhook as discord_webhook
|
||||||
from scripts.temperature_sensor import TemperatureSensor
|
from scripts.temperature_sensor import TemperatureSensor
|
||||||
|
|
||||||
class Monitor:
|
class Monitor:
|
||||||
@@ -77,6 +77,12 @@ class TemperatureMonitor(Monitor):
|
|||||||
|
|
||||||
temp = list(temps.values())[0] # Get first temp reading
|
temp = list(temps.values())[0] # Get first temp reading
|
||||||
|
|
||||||
|
# ===== ADD THIS: Validate temperature is reasonable =====
|
||||||
|
if temp < -50 or temp > 150: # Sanity check (outside normal range)
|
||||||
|
print("⚠️ Warning: {} sensor returned invalid temp: {:.1f}°F".format(self.label, temp))
|
||||||
|
return # Don't cache invalid reading
|
||||||
|
# ===== END: Validation =====
|
||||||
|
|
||||||
# Cache the reading for web server (avoid blocking reads)
|
# Cache the reading for web server (avoid blocking reads)
|
||||||
self.last_temp = temp
|
self.last_temp = temp
|
||||||
self.last_read_time = current_time
|
self.last_read_time = current_time
|
||||||
@@ -102,13 +108,11 @@ class TemperatureMonitor(Monitor):
|
|||||||
self.alert_start_time = current_time
|
self.alert_start_time = current_time
|
||||||
print(alert_message)
|
print(alert_message)
|
||||||
|
|
||||||
# Send to appropriate Discord channel
|
# send alert (use module-level discord_webhook; set_config must be called in main)
|
||||||
if self.send_alerts_to_separate_channel:
|
if self.send_alerts_to_separate_channel:
|
||||||
from scripts.discord_webhook import send_alert_message
|
discord_webhook.send_discord_message(alert_message, is_alert=True)
|
||||||
send_alert_message(alert_message)
|
|
||||||
else:
|
else:
|
||||||
from scripts.discord_webhook import send_discord_message
|
discord_webhook.send_discord_message(alert_message)
|
||||||
send_discord_message(alert_message)
|
|
||||||
|
|
||||||
self.alert_sent = True
|
self.alert_sent = True
|
||||||
|
|
||||||
@@ -133,13 +137,11 @@ class TemperatureMonitor(Monitor):
|
|||||||
)
|
)
|
||||||
print(recovery_message)
|
print(recovery_message)
|
||||||
|
|
||||||
# Send to appropriate Discord channel
|
# send recovery message
|
||||||
if self.send_alerts_to_separate_channel:
|
if self.send_alerts_to_separate_channel:
|
||||||
from scripts.discord_webhook import send_alert_message
|
discord_webhook.send_discord_message(recovery_message, is_alert=True)
|
||||||
send_alert_message(recovery_message)
|
|
||||||
else:
|
else:
|
||||||
from scripts.discord_webhook import send_discord_message
|
discord_webhook.send_discord_message(recovery_message)
|
||||||
send_discord_message(recovery_message)
|
|
||||||
|
|
||||||
self.alert_sent = False
|
self.alert_sent = False
|
||||||
self.alert_start_time = None
|
self.alert_start_time = None
|
||||||
@@ -192,14 +194,14 @@ class ACMonitor(Monitor):
|
|||||||
# Too hot, turn AC on
|
# Too hot, turn AC on
|
||||||
if self.ac.turn_on():
|
if self.ac.turn_on():
|
||||||
if not self.last_notified_state:
|
if not self.last_notified_state:
|
||||||
send_discord_message(f"❄️ AC turned ON - Current: {current_temp:.1f}°F, Target: {self.target_temp:.1f}°F")
|
discord_webhook.send_discord_message(f"❄️ AC turned ON - Current: {current_temp:.1f}°F, Target: {self.target_temp:.1f}°F")
|
||||||
self.last_notified_state = True
|
self.last_notified_state = True
|
||||||
|
|
||||||
elif current_temp < (self.target_temp - self.temp_swing):
|
elif current_temp < (self.target_temp - self.temp_swing):
|
||||||
# Cool enough, turn AC off
|
# Cool enough, turn AC off
|
||||||
if self.ac.turn_off():
|
if self.ac.turn_off():
|
||||||
if self.last_notified_state:
|
if self.last_notified_state:
|
||||||
send_discord_message(f"✅ AC turned OFF - Current: {current_temp:.1f}°F, Target: {self.target_temp:.1f}°F")
|
discord_webhook.send_discord_message(f"✅ AC turned OFF - Current: {current_temp:.1f}°F, Target: {self.target_temp:.1f}°F")
|
||||||
self.last_notified_state = False
|
self.last_notified_state = False
|
||||||
|
|
||||||
# Else: within temp_swing range, maintain current state
|
# Else: within temp_swing range, maintain current state
|
||||||
@@ -238,27 +240,28 @@ class HeaterMonitor(Monitor):
|
|||||||
# Too cold, turn heater on
|
# Too cold, turn heater on
|
||||||
if self.heater.turn_on():
|
if self.heater.turn_on():
|
||||||
if not self.last_notified_state:
|
if not self.last_notified_state:
|
||||||
send_discord_message(f"🔥 Heater turned ON - Current: {current_temp:.1f}°F, Target: {self.target_temp:.1f}°F")
|
discord_webhook.send_discord_message(f"🔥 Heater turned ON - Current: {current_temp:.1f}°F, Target: {self.target_temp:.1f}°F")
|
||||||
self.last_notified_state = True
|
self.last_notified_state = True
|
||||||
|
|
||||||
elif current_temp > (self.target_temp + self.temp_swing):
|
elif current_temp > (self.target_temp + self.temp_swing):
|
||||||
# Warm enough, turn heater off
|
# Warm enough, turn heater off
|
||||||
if self.heater.turn_off():
|
if self.heater.turn_off():
|
||||||
if self.last_notified_state:
|
if self.last_notified_state:
|
||||||
send_discord_message(f"✅ Heater turned OFF - Current: {current_temp:.1f}°F, Target: {self.target_temp:.1f}°F")
|
discord_webhook.send_discord_message(f"✅ Heater turned OFF - Current: {current_temp:.1f}°F, Target: {self.target_temp:.1f}°F")
|
||||||
self.last_notified_state = False
|
self.last_notified_state = False
|
||||||
|
|
||||||
# Else: within temp_swing range, maintain current state
|
# Else: within temp_swing range, maintain current state
|
||||||
|
|
||||||
class WiFiMonitor(Monitor):
|
class WiFiMonitor(Monitor):
|
||||||
"""Monitor WiFi connection and handle reconnection."""
|
"""Monitor WiFi connection and handle reconnection."""
|
||||||
def __init__(self, wifi, led, interval=5, reconnect_cooldown=60):
|
def __init__(self, wifi, led, interval=5, reconnect_cooldown=60, config=None):
|
||||||
super().__init__(interval)
|
super().__init__(interval)
|
||||||
self.wifi = wifi
|
self.wifi = wifi
|
||||||
self.led = led
|
self.led = led
|
||||||
self.reconnect_cooldown = reconnect_cooldown
|
self.reconnect_cooldown = reconnect_cooldown
|
||||||
self.last_reconnect_attempt = 0
|
self.last_reconnect_attempt = 0
|
||||||
self.was_connected = wifi.isconnected() if wifi else False
|
self.was_connected = wifi.isconnected() if wifi else False
|
||||||
|
self.config = config
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Check WiFi status, blink LED, attempt reconnect if needed."""
|
"""Check WiFi status, blink LED, attempt reconnect if needed."""
|
||||||
@@ -278,10 +281,10 @@ class WiFiMonitor(Monitor):
|
|||||||
if time.ticks_diff(now, self.last_reconnect_attempt) >= (self.reconnect_cooldown * 1000):
|
if time.ticks_diff(now, self.last_reconnect_attempt) >= (self.reconnect_cooldown * 1000):
|
||||||
self.last_reconnect_attempt = now
|
self.last_reconnect_attempt = now
|
||||||
# print("Attempting WiFi reconnect...")
|
# print("Attempting WiFi reconnect...")
|
||||||
self.wifi = connect_wifi(self.led)
|
self.wifi = connect_wifi(self.led, config=self.config)
|
||||||
|
|
||||||
if self.wifi and self.wifi.isconnected():
|
if self.wifi and self.wifi.isconnected():
|
||||||
send_discord_message("WiFi connection restored 🔄")
|
discord_webhook.send_discord_message("WiFi connection restored 🔄")
|
||||||
self.was_connected = True
|
self.was_connected = True
|
||||||
else:
|
else:
|
||||||
# Slow blink when connected
|
# Slow blink when connected
|
||||||
@@ -291,7 +294,7 @@ class WiFiMonitor(Monitor):
|
|||||||
|
|
||||||
# Notify if connection was just restored
|
# Notify if connection was just restored
|
||||||
if not self.was_connected:
|
if not self.was_connected:
|
||||||
send_discord_message("WiFi connection restored 🔄")
|
discord_webhook.send_discord_message("WiFi connection restored 🔄")
|
||||||
self.was_connected = True
|
self.was_connected = True
|
||||||
|
|
||||||
def run_monitors(monitors):
|
def run_monitors(monitors):
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
import network
|
import network # type: ignore
|
||||||
import time
|
import time # type: ignore
|
||||||
from secrets import secrets
|
|
||||||
|
|
||||||
def connect_wifi(led=None, max_retries=3, timeout=20):
|
def connect_wifi(led=None, max_retries=3, timeout=20, config=None):
|
||||||
"""
|
"""
|
||||||
Connect to WiFi using credentials from secrets.py
|
Connect to WiFi using credentials from provided config dict.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
led: Optional LED pin for visual feedback
|
led: Optional LED pin for visual feedback
|
||||||
max_retries: Number of connection attempts (default: 3)
|
max_retries: Number of connection attempts (default: 3)
|
||||||
timeout: Seconds to wait for connection per attempt (default: 20)
|
timeout: Seconds to wait for connection per attempt (default: 20)
|
||||||
|
config: Dict loaded from config.json, must contain config['wifi'] with 'ssid' and 'password'
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
WLAN object if connected, None if failed
|
WLAN object if connected, None if failed
|
||||||
"""
|
"""
|
||||||
|
if config is None:
|
||||||
|
print("connect_wifi: config is required")
|
||||||
|
return None
|
||||||
|
|
||||||
|
wifi_cfg = config.get('wifi') or {}
|
||||||
|
# support either config['wifi'] = {'ssid','password'} OR top-level 'ssid'/'password'
|
||||||
|
ssid = wifi_cfg.get('ssid') or config.get('ssid')
|
||||||
|
password = wifi_cfg.get('password') or config.get('password')
|
||||||
|
|
||||||
|
if not ssid or not password:
|
||||||
|
print("connect_wifi: missing wifi credentials in config['wifi']")
|
||||||
|
return None
|
||||||
|
|
||||||
wlan = network.WLAN(network.STA_IF)
|
wlan = network.WLAN(network.STA_IF)
|
||||||
|
|
||||||
# Ensure clean state
|
# Ensure clean state
|
||||||
@@ -41,13 +54,13 @@ def connect_wifi(led=None, max_retries=3, timeout=20):
|
|||||||
# Try connecting with retries
|
# Try connecting with retries
|
||||||
for attempt in range(1, max_retries + 1):
|
for attempt in range(1, max_retries + 1):
|
||||||
if wlan.isconnected():
|
if wlan.isconnected():
|
||||||
print(f"Already connected to WiFi")
|
print("Already connected to WiFi")
|
||||||
break
|
break
|
||||||
|
|
||||||
print(f'Connecting to WiFi (attempt {attempt}/{max_retries})...')
|
print(f'Connecting to WiFi SSID: {ssid} (attempt {attempt}/{max_retries})...')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
wlan.connect(secrets['ssid'], secrets['password'])
|
wlan.connect(ssid, password)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Connection attempt failed: {e}")
|
print(f"Connection attempt failed: {e}")
|
||||||
if attempt < max_retries:
|
if attempt < max_retries:
|
||||||
@@ -62,7 +75,17 @@ def connect_wifi(led=None, max_retries=3, timeout=20):
|
|||||||
break
|
break
|
||||||
|
|
||||||
if led:
|
if led:
|
||||||
|
try:
|
||||||
|
# some LED wrappers use toggle(), others use on/off
|
||||||
|
if hasattr(led, "toggle"):
|
||||||
led.toggle()
|
led.toggle()
|
||||||
|
else:
|
||||||
|
# flash quickly to show activity
|
||||||
|
led.on()
|
||||||
|
time.sleep(0.05)
|
||||||
|
led.off()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
wait_time += 0.5
|
wait_time += 0.5
|
||||||
@@ -85,17 +108,24 @@ def connect_wifi(led=None, max_retries=3, timeout=20):
|
|||||||
if not wlan.isconnected():
|
if not wlan.isconnected():
|
||||||
print('WiFi connection failed after all attempts!')
|
print('WiFi connection failed after all attempts!')
|
||||||
if led:
|
if led:
|
||||||
|
try:
|
||||||
|
# prefer available method names
|
||||||
|
if hasattr(led, "off"):
|
||||||
led.off()
|
led.off()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Success feedback
|
# Success feedback
|
||||||
if led:
|
if led:
|
||||||
# Double pulse on successful connection
|
try:
|
||||||
for _ in range(2):
|
for _ in range(2):
|
||||||
led.on()
|
led.on()
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
led.off()
|
led.off()
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
print('Connected to WiFi successfully!')
|
print('Connected to WiFi successfully!')
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ class ScheduleMonitor:
|
|||||||
self.last_check = 0
|
self.last_check = 0
|
||||||
self.current_schedule = None
|
self.current_schedule = None
|
||||||
self.last_applied_schedule = None
|
self.last_applied_schedule = None
|
||||||
self.temp_hold_start_time = None # When temporary hold was activated
|
|
||||||
self.temp_hold_duration = config.get('temp_hold_duration', 3600) # Use config value, default 1 hour
|
self.temp_hold_duration = config.get('temp_hold_duration', 3600) # Use config value, default 1 hour
|
||||||
|
|
||||||
def should_run(self):
|
def should_run(self):
|
||||||
@@ -86,25 +85,66 @@ class ScheduleMonitor:
|
|||||||
if not schedule:
|
if not schedule:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if this is a different schedule than last applied
|
|
||||||
schedule_id = schedule.get('time', '') + schedule.get('name', '')
|
schedule_id = schedule.get('time', '') + schedule.get('name', '')
|
||||||
if schedule_id == self.last_applied_schedule:
|
if schedule_id == self.last_applied_schedule:
|
||||||
return # Already applied
|
return # Already applied
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Track whether we changed persisted values to avoid unnecessary writes
|
||||||
|
changed = False
|
||||||
|
|
||||||
# Update AC settings if provided
|
# Update AC settings if provided
|
||||||
if 'ac_target' in schedule:
|
if 'ac_target' in schedule:
|
||||||
self.ac_monitor.target_temp = float(schedule['ac_target'])
|
new_ac = float(schedule['ac_target'])
|
||||||
|
if self.config.get('ac_target') != new_ac:
|
||||||
|
self.config['ac_target'] = new_ac
|
||||||
|
changed = True
|
||||||
|
self.ac_monitor.target_temp = new_ac
|
||||||
|
|
||||||
if 'ac_swing' in schedule:
|
if 'ac_swing' in schedule:
|
||||||
self.ac_monitor.temp_swing = float(schedule['ac_swing'])
|
new_ac_swing = float(schedule['ac_swing'])
|
||||||
|
if self.config.get('ac_swing') != new_ac_swing:
|
||||||
|
self.config['ac_swing'] = new_ac_swing
|
||||||
|
changed = True
|
||||||
|
self.ac_monitor.temp_swing = new_ac_swing
|
||||||
|
|
||||||
# Update heater settings if provided
|
# Update heater settings if provided
|
||||||
if 'heater_target' in schedule:
|
if 'heater_target' in schedule:
|
||||||
self.heater_monitor.target_temp = float(schedule['heater_target'])
|
new_ht = float(schedule['heater_target'])
|
||||||
|
if self.config.get('heater_target') != new_ht:
|
||||||
|
self.config['heater_target'] = new_ht
|
||||||
|
changed = True
|
||||||
|
self.heater_monitor.target_temp = new_ht
|
||||||
|
|
||||||
if 'heater_swing' in schedule:
|
if 'heater_swing' in schedule:
|
||||||
self.heater_monitor.temp_swing = float(schedule['heater_swing'])
|
new_ht_swing = float(schedule['heater_swing'])
|
||||||
|
if self.config.get('heater_swing') != new_ht_swing:
|
||||||
|
self.config['heater_swing'] = new_ht_swing
|
||||||
|
changed = True
|
||||||
|
self.heater_monitor.temp_swing = new_ht_swing
|
||||||
|
|
||||||
|
# Save updated config only if something changed
|
||||||
|
if changed:
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
with open('config.json', 'w') as f:
|
||||||
|
json.dump(self.config, f)
|
||||||
|
except Exception as e:
|
||||||
|
print("⚠️ Could not save config: {}".format(e))
|
||||||
|
else:
|
||||||
|
# import once and update module-level webhook config
|
||||||
|
try:
|
||||||
|
import scripts.discord_webhook as discord_webhook
|
||||||
|
discord_webhook.set_config(self.config)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
print("✅ Config updated with active schedule targets")
|
||||||
|
else:
|
||||||
|
# nothing to persist
|
||||||
|
try:
|
||||||
|
import scripts.discord_webhook as discord_webhook
|
||||||
|
except Exception:
|
||||||
|
discord_webhook = None
|
||||||
|
|
||||||
# Log the change
|
# Log the change
|
||||||
schedule_name = schedule.get('name', 'Unnamed')
|
schedule_name = schedule.get('name', 'Unnamed')
|
||||||
@@ -115,16 +155,17 @@ class ScheduleMonitor:
|
|||||||
print("Heater Target: {}°F".format(self.heater_monitor.target_temp))
|
print("Heater Target: {}°F".format(self.heater_monitor.target_temp))
|
||||||
print("="*50 + "\n")
|
print("="*50 + "\n")
|
||||||
|
|
||||||
# Send Discord notification
|
# Send Discord notification (use discord_webhook if available)
|
||||||
try:
|
try:
|
||||||
from scripts.discord_webhook import send_discord_message
|
if 'discord_webhook' not in locals() or discord_webhook is None:
|
||||||
|
import scripts.discord_webhook as discord_webhook
|
||||||
message = "🕐 Schedule '{}' applied - AC: {}°F | Heater: {}°F".format(
|
message = "🕐 Schedule '{}' applied - AC: {}°F | Heater: {}°F".format(
|
||||||
schedule_name,
|
schedule_name,
|
||||||
self.ac_monitor.target_temp,
|
self.ac_monitor.target_temp,
|
||||||
self.heater_monitor.target_temp
|
self.heater_monitor.target_temp
|
||||||
)
|
)
|
||||||
send_discord_message(message)
|
discord_webhook.send_discord_message(message)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self.last_applied_schedule = schedule_id
|
self.last_applied_schedule = schedule_id
|
||||||
@@ -137,40 +178,41 @@ class ScheduleMonitor:
|
|||||||
|
|
||||||
# ===== START: Check if temporary hold has expired =====
|
# ===== START: Check if temporary hold has expired =====
|
||||||
if not self.config.get('schedule_enabled', False) and not self.config.get('permanent_hold', False):
|
if not self.config.get('schedule_enabled', False) and not self.config.get('permanent_hold', False):
|
||||||
# We're in temporary hold mode
|
# In temporary hold mode - check if timer expired
|
||||||
if self.temp_hold_start_time is None:
|
temp_hold_start = self.config.get('temp_hold_start_time') # <-- READ FROM CONFIG NOW
|
||||||
# Just entered hold mode, record start time
|
|
||||||
self.temp_hold_start_time = time.time()
|
if temp_hold_start is not None:
|
||||||
print("⏸️ Temporary hold started - will auto-resume in {} minutes".format(
|
elapsed = time.time() - temp_hold_start
|
||||||
self.temp_hold_duration // 60
|
|
||||||
))
|
|
||||||
else:
|
|
||||||
# Check if hold has expired
|
|
||||||
elapsed = time.time() - self.temp_hold_start_time
|
|
||||||
if elapsed >= self.temp_hold_duration:
|
if elapsed >= self.temp_hold_duration:
|
||||||
# Hold expired, resume schedules
|
# Timer expired - resume automatic scheduling
|
||||||
print("⏰ Temporary hold expired - resuming automatic mode")
|
print("⏰ Temporary hold expired - resuming schedule")
|
||||||
self.config['schedule_enabled'] = True
|
self.config['schedule_enabled'] = True
|
||||||
self.config['permanent_hold'] = False
|
self.config['temp_hold_start_time'] = None
|
||||||
self.temp_hold_start_time = None
|
|
||||||
|
|
||||||
# Save updated config
|
# Save updated config
|
||||||
try:
|
try:
|
||||||
import json
|
import json
|
||||||
with open('config.json', 'w') as f:
|
with open('config.json', 'w') as f:
|
||||||
json.dump(self.config, f)
|
json.dump(self.config, f)
|
||||||
except:
|
except Exception as e:
|
||||||
|
print("⚠️ Could not save config: {}".format(e))
|
||||||
|
else:
|
||||||
|
# ensure in-memory webhook config updated
|
||||||
|
try:
|
||||||
|
import scripts.discord_webhook as discord_webhook
|
||||||
|
discord_webhook.set_config(self.config)
|
||||||
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Send Discord notification
|
print("✅ Config updated - automatic mode resumed")
|
||||||
|
|
||||||
|
# Notify user
|
||||||
try:
|
try:
|
||||||
from scripts.discord_webhook import send_discord_message
|
import scripts.discord_webhook as discord_webhook
|
||||||
send_discord_message("⏰ Temporary hold expired - Automatic mode resumed")
|
discord_webhook.send_discord_message("⏰ Temporary hold expired - Schedule resumed automatically")
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
else:
|
|
||||||
# Not in temporary hold, reset timer
|
|
||||||
self.temp_hold_start_time = None
|
|
||||||
# ===== END: Check if temporary hold has expired =====
|
# ===== END: Check if temporary hold has expired =====
|
||||||
|
|
||||||
# Find and apply active schedule
|
# Find and apply active schedule
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import machine
|
import machine # type: ignore
|
||||||
import onewire
|
import onewire # type: ignore
|
||||||
import ds18x20
|
import ds18x20 # type: ignore
|
||||||
import time
|
import time # type: ignore
|
||||||
|
|
||||||
class TemperatureSensor:
|
class TemperatureSensor:
|
||||||
def __init__(self, pin=10, label=None):
|
def __init__(self, pin=10, label=None):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,13 @@
|
|||||||
{
|
{
|
||||||
|
"static_ip": "192.168.1.69",
|
||||||
|
"subnet": "255.255.255.0",
|
||||||
|
"gateway": "192.168.1.1",
|
||||||
|
"dns": "192.168.1.1",
|
||||||
|
"timezone_offset": -5,
|
||||||
|
"ssid": " Change_to_wifi_SSID",
|
||||||
|
"password": "Change_to_wifi_Pasword",
|
||||||
|
"discord_webhook_url": "https://discord.com/api/webhooks/key/long-Combination_1234-ChangeMe",
|
||||||
|
"discord_alert_webhook_url": "https://discord.com/api/webhooks/key/long-Combination_1234-ChangeMe",
|
||||||
"ac_target": 77.0,
|
"ac_target": 77.0,
|
||||||
"ac_swing": 1.0,
|
"ac_swing": 1.0,
|
||||||
"heater_target": 72.0,
|
"heater_target": 72.0,
|
||||||
@@ -31,5 +40,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"schedule_enabled": true,
|
"schedule_enabled": true,
|
||||||
"permanent_hold": false
|
"permanent_hold": false,
|
||||||
|
"temp_hold_start_time": null
|
||||||
}
|
}
|
||||||
387
main.py
387
main.py
@@ -3,10 +3,7 @@ import time # type: ignore
|
|||||||
import network # type: ignore
|
import network # type: ignore
|
||||||
import json
|
import json
|
||||||
import gc # type: ignore # ADD THIS - for garbage collection
|
import gc # type: ignore # ADD THIS - for garbage collection
|
||||||
|
import sys
|
||||||
# Enable watchdog (8 seconds timeout - auto-reboot if frozen)
|
|
||||||
# wdt = WDT(timeout=8000) # Maximum is 8388ms, use 8000ms (8 seconds)
|
|
||||||
# print("Watchdog enabled (8s timeout)")
|
|
||||||
|
|
||||||
# Initialize pins (LED light onboard)
|
# Initialize pins (LED light onboard)
|
||||||
led = Pin("LED", Pin.OUT)
|
led = Pin("LED", Pin.OUT)
|
||||||
@@ -24,14 +21,53 @@ except Exception as e:
|
|||||||
|
|
||||||
# Import after WiFi reset
|
# Import after WiFi reset
|
||||||
from scripts.networking import connect_wifi
|
from scripts.networking import connect_wifi
|
||||||
from scripts.discord_webhook import send_discord_message
|
|
||||||
from scripts.monitors import TemperatureMonitor, WiFiMonitor, ACMonitor, HeaterMonitor, run_monitors
|
# ===== NEW: NTP Sync Function (imports locally) =====
|
||||||
from scripts.temperature_sensor import TemperatureSensor
|
def sync_ntp_time(timezone_offset):
|
||||||
from scripts.air_conditioning import ACController
|
"""
|
||||||
from scripts.heating import HeaterController
|
Sync time with NTP server (imports modules locally to save RAM).
|
||||||
from scripts.web_server import TempWebServer
|
Returns True if successful, False otherwise.
|
||||||
from scripts.scheduler import ScheduleMonitor # NEW: Import scheduler for time-based temp changes
|
"""
|
||||||
from scripts.memory_check import check_memory_once # Just the function
|
try:
|
||||||
|
# Import ONLY when needed (freed by GC after function ends)
|
||||||
|
import socket # type: ignore
|
||||||
|
import struct # type: ignore
|
||||||
|
|
||||||
|
NTP_DELTA = 2208988800
|
||||||
|
host = "pool.ntp.org"
|
||||||
|
NTP_QUERY = bytearray(48)
|
||||||
|
NTP_QUERY[0] = 0x1B
|
||||||
|
|
||||||
|
# Create socket with timeout
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.settimeout(3.0) # 3-second timeout
|
||||||
|
|
||||||
|
try:
|
||||||
|
addr = socket.getaddrinfo(host, 123)[0][-1]
|
||||||
|
s.sendto(NTP_QUERY, addr)
|
||||||
|
msg = s.recv(48)
|
||||||
|
val = struct.unpack("!I", msg[40:44])[0]
|
||||||
|
utc_timestamp = val - NTP_DELTA
|
||||||
|
|
||||||
|
# Apply timezone offset
|
||||||
|
local_timestamp = utc_timestamp + (timezone_offset * 3600)
|
||||||
|
|
||||||
|
# Set RTC with local time
|
||||||
|
tm = time.gmtime(local_timestamp)
|
||||||
|
RTC().datetime((tm[0], tm[1], tm[2], tm[6] + 1, tm[3], tm[4], tm[5], 0))
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print("NTP sync failed: {}".format(e))
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
# Force garbage collection to free socket/struct modules
|
||||||
|
gc.collect()
|
||||||
|
# ===== END: NTP Sync Function =====
|
||||||
|
|
||||||
# ===== START: Configuration Loading =====
|
# ===== START: Configuration Loading =====
|
||||||
# Load saved settings from config.json file on Pico
|
# Load saved settings from config.json file on Pico
|
||||||
@@ -47,10 +83,17 @@ def load_config():
|
|||||||
print("No saved config found, creating default config.json...")
|
print("No saved config found, creating default config.json...")
|
||||||
|
|
||||||
default_config = {
|
default_config = {
|
||||||
|
'static_ip': '192.168.86.43',
|
||||||
|
'subnet': '255.255.255.0',
|
||||||
|
'gateway': '192.168.86.1',
|
||||||
|
'dns': '192.168.86.1',
|
||||||
|
'timezone_offset': -6, # Timezone offset from UTC (CST=-6, EST=-5, MST=-7, PST=-8, add 1 for DST)
|
||||||
'ac_target': 75.0, # Default AC target temp
|
'ac_target': 75.0, # Default AC target temp
|
||||||
'ac_swing': 1.0, # Default AC tolerance (+/- degrees)
|
'ac_swing': 1.0, # Default AC tolerance (+/- degrees)
|
||||||
'heater_target': 72.0, # Default heater target temp
|
'heater_target': 72.0, # Default heater target temp
|
||||||
'heater_swing': 2.0, # Default heater tolerance (+/- degrees)
|
'heater_swing': 2.0, # Default heater tolerance (+/- degrees)
|
||||||
|
'temp_hold_duration': 3600, # Default hold duration in seconds (1 hour)
|
||||||
|
'temp_hold_start_time': None, # No hold active at startup
|
||||||
'schedules': [ # Default 4 schedules
|
'schedules': [ # Default 4 schedules
|
||||||
{
|
{
|
||||||
'time': '06:00',
|
'time': '06:00',
|
||||||
@@ -93,8 +136,19 @@ def load_config():
|
|||||||
|
|
||||||
return default_config
|
return default_config
|
||||||
|
|
||||||
|
# global variables for Discord webhook status
|
||||||
|
discord_sent = False
|
||||||
|
discord_send_attempts = 0
|
||||||
|
pending_discord_message = None
|
||||||
|
|
||||||
# Load configuration from file
|
# Load configuration from file
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
import scripts.discord_webhook as discord_webhook
|
||||||
|
# Initialize discord webhook module with loaded config (must be done BEFORE any send_discord_message calls)
|
||||||
|
discord_webhook.set_config(config)
|
||||||
|
|
||||||
|
# Get timezone offset from config (with fallback to -6 if not present)
|
||||||
|
TIMEZONE_OFFSET = config.get('timezone_offset', -6)
|
||||||
|
|
||||||
# ===== START: Reset hold modes on startup =====
|
# ===== START: Reset hold modes on startup =====
|
||||||
# Always reset to automatic mode on boot (don't persist hold states)
|
# Always reset to automatic mode on boot (don't persist hold states)
|
||||||
@@ -102,6 +156,8 @@ if 'schedule_enabled' in config:
|
|||||||
config['schedule_enabled'] = True # Always enable schedules on boot
|
config['schedule_enabled'] = True # Always enable schedules on boot
|
||||||
if 'permanent_hold' in config:
|
if 'permanent_hold' in config:
|
||||||
config['permanent_hold'] = False # Always clear permanent hold on boot
|
config['permanent_hold'] = False # Always clear permanent hold on boot
|
||||||
|
if 'temp_hold_start_time' in config:
|
||||||
|
config['temp_hold_start_time'] = None # Clear temp hold start time
|
||||||
|
|
||||||
# Save the reset config immediately
|
# Save the reset config immediately
|
||||||
try:
|
try:
|
||||||
@@ -114,16 +170,16 @@ except Exception as e:
|
|||||||
# ===== END: Configuration Loading =====
|
# ===== END: Configuration Loading =====
|
||||||
|
|
||||||
# ===== START: WiFi Connection =====
|
# ===== START: WiFi Connection =====
|
||||||
# Connect to WiFi using credentials from secrets.py
|
# Connect to WiFi using credentials from config.json
|
||||||
wifi = connect_wifi(led)
|
wifi = connect_wifi(led, config=config)
|
||||||
|
|
||||||
# Set static IP and print WiFi details
|
# Set static IP and print WiFi details
|
||||||
if wifi and wifi.isconnected():
|
if wifi and wifi.isconnected():
|
||||||
# Configure static IP (easier to bookmark web interface)
|
# Get static IP settings from config
|
||||||
static_ip = '192.168.86.43' # Change this to match your network
|
static_ip = config.get('static_ip')
|
||||||
subnet = '255.255.255.0'
|
subnet = config.get('subnet')
|
||||||
gateway = '192.168.86.1' # Usually your router IP
|
gateway = config.get('gateway')
|
||||||
dns = '192.168.86.1' # Usually your router IP
|
dns = config.get('dns')
|
||||||
|
|
||||||
# Apply static IP configuration
|
# Apply static IP configuration
|
||||||
wifi.ifconfig((static_ip, subnet, gateway, dns))
|
wifi.ifconfig((static_ip, subnet, gateway, dns))
|
||||||
@@ -141,60 +197,49 @@ if wifi and wifi.isconnected():
|
|||||||
print(f"Web Interface: http://{ifconfig[0]}")
|
print(f"Web Interface: http://{ifconfig[0]}")
|
||||||
print("="*50 + "\n")
|
print("="*50 + "\n")
|
||||||
|
|
||||||
# Send startup notification to Discord (with timeout, non-blocking)
|
# Try sending Discord webhook NOW, before creating other objects
|
||||||
try:
|
gc.collect()
|
||||||
success = send_discord_message(f"Pico W online at http://{ifconfig[0]} ✅")
|
ram_free = gc.mem_free()
|
||||||
if success:
|
print(f"DEBUG: Free RAM before Discord send: {ram_free // 1024} KB")
|
||||||
|
mem_ok = ram_free > 95000
|
||||||
|
if mem_ok:
|
||||||
|
ok = discord_webhook.send_discord_message("Pico W online at http://{}".format(ifconfig[0]), debug=False)
|
||||||
|
if ok:
|
||||||
print("Discord startup notification sent")
|
print("Discord startup notification sent")
|
||||||
|
discord_sent = True
|
||||||
else:
|
else:
|
||||||
print("Discord startup notification failed (continuing anyway)")
|
print("Discord startup notification failed")
|
||||||
except Exception as e:
|
pending_discord_message = "Pico W online at http://{}".format(ifconfig[0])
|
||||||
print("Discord notification error: {}".format(e))
|
discord_send_attempts = 1
|
||||||
|
else:
|
||||||
|
print("Not enough memory for Discord startup notification")
|
||||||
|
pending_discord_message = "Pico W online at http://{}".format(ifconfig[0])
|
||||||
|
discord_send_attempts = 1
|
||||||
|
|
||||||
|
# ===== Moved to later so discord could fire off startup message hopefully =====
|
||||||
|
from scripts.monitors import TemperatureMonitor, WiFiMonitor, ACMonitor, HeaterMonitor, run_monitors
|
||||||
|
from scripts.temperature_sensor import TemperatureSensor
|
||||||
|
from scripts.air_conditioning import ACController
|
||||||
|
from scripts.heating import HeaterController
|
||||||
|
from scripts.web_server import TempWebServer
|
||||||
|
from scripts.scheduler import ScheduleMonitor
|
||||||
|
from scripts.memory_check import check_memory_once
|
||||||
|
|
||||||
# Start web server early so page can load even if time sync is slow
|
# Start web server early so page can load even if time sync is slow
|
||||||
web_server = TempWebServer(port=80)
|
web_server = TempWebServer(port=80)
|
||||||
web_server.start()
|
web_server.start()
|
||||||
|
|
||||||
# Attempt time sync with timeout (MicroPython compatible)
|
# ===== INITIAL NTP SYNC (using function) =====
|
||||||
ntp_synced = False
|
ntp_synced = False
|
||||||
try:
|
try:
|
||||||
import ntptime # type: ignore
|
ntp_synced = sync_ntp_time(TIMEZONE_OFFSET)
|
||||||
import socket
|
if ntp_synced:
|
||||||
import struct
|
print("Time synced with NTP server (UTC{:+d})".format(TIMEZONE_OFFSET))
|
||||||
|
else:
|
||||||
# Monkey-patch ntptime.time() to add timeout
|
print("Initial NTP sync failed, will retry in background...")
|
||||||
original_time_func = ntptime.time
|
|
||||||
|
|
||||||
def time_with_timeout():
|
|
||||||
"""NTP time fetch with 3-second timeout."""
|
|
||||||
NTP_DELTA = 2208988800
|
|
||||||
host = "pool.ntp.org"
|
|
||||||
NTP_QUERY = bytearray(48)
|
|
||||||
NTP_QUERY[0] = 0x1B
|
|
||||||
|
|
||||||
# Create socket with timeout
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
s.settimeout(3.0) # 3-second timeout
|
|
||||||
|
|
||||||
try:
|
|
||||||
addr = socket.getaddrinfo(host, 123)[0][-1]
|
|
||||||
s.sendto(NTP_QUERY, addr)
|
|
||||||
msg = s.recv(48)
|
|
||||||
s.close()
|
|
||||||
val = struct.unpack("!I", msg[40:44])[0]
|
|
||||||
return val - NTP_DELTA
|
|
||||||
finally:
|
|
||||||
s.close()
|
|
||||||
|
|
||||||
# Use patched version
|
|
||||||
ntptime.time = time_with_timeout
|
|
||||||
ntptime.settime()
|
|
||||||
ntp_synced = True
|
|
||||||
print("Time synced with NTP server")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("Initial NTP sync failed: {}".format(e))
|
print("Initial NTP sync error: {}".format(e))
|
||||||
print("Will retry in background...")
|
# ===== END: INITIAL NTP SYNC =====
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# WiFi connection failed
|
# WiFi connection failed
|
||||||
@@ -203,6 +248,8 @@ else:
|
|||||||
print("="*50 + "\n")
|
print("="*50 + "\n")
|
||||||
# ===== END: WiFi Connection =====
|
# ===== END: WiFi Connection =====
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ===== START: Sensor Configuration =====
|
# ===== START: Sensor Configuration =====
|
||||||
# Define all temperature sensors and their alert thresholds
|
# Define all temperature sensors and their alert thresholds
|
||||||
SENSOR_CONFIG = {
|
SENSOR_CONFIG = {
|
||||||
@@ -276,6 +323,24 @@ schedule_monitor = ScheduleMonitor(
|
|||||||
config=config, # Pass config with schedules
|
config=config, # Pass config with schedules
|
||||||
interval=60 # Check schedule every 60 seconds
|
interval=60 # Check schedule every 60 seconds
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ===== APPLY ACTIVE SCHEDULE IMMEDIATELY ON STARTUP =====
|
||||||
|
if config.get('schedule_enabled', False):
|
||||||
|
try:
|
||||||
|
# Find and apply the current active schedule
|
||||||
|
active_schedule = schedule_monitor._find_active_schedule()
|
||||||
|
if active_schedule:
|
||||||
|
schedule_monitor._apply_schedule(active_schedule)
|
||||||
|
print("✅ Active schedule applied on startup: {}".format(
|
||||||
|
active_schedule.get('name', 'Unnamed')
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
print("ℹ️ No active schedule found (using manual targets)")
|
||||||
|
except Exception as e:
|
||||||
|
print("⚠️ Warning: Could not apply startup schedule: {}".format(e))
|
||||||
|
else:
|
||||||
|
print("ℹ️ Schedules disabled - using manual targets")
|
||||||
|
# ===== END: APPLY ACTIVE SCHEDULE ON STARTUP =====
|
||||||
# ===== END: Schedule Monitor Setup =====
|
# ===== END: Schedule Monitor Setup =====
|
||||||
|
|
||||||
# ===== START: Print Current Settings =====
|
# ===== START: Print Current Settings =====
|
||||||
@@ -296,125 +361,127 @@ print("="*50 + "\n")
|
|||||||
check_memory_once()
|
check_memory_once()
|
||||||
# ===== END: Startup Memory Check =====
|
# ===== END: Startup Memory Check =====
|
||||||
|
|
||||||
# ===== START: Monitor Setup =====
|
|
||||||
# Set up all monitoring systems (run in order during main loop)
|
|
||||||
monitors = [
|
|
||||||
# WiFi monitor: Checks connection, reconnects if needed, blinks LED
|
|
||||||
WiFiMonitor(wifi, led, interval=5, reconnect_cooldown=60),
|
|
||||||
|
|
||||||
# Schedule monitor: Changes temp targets based on time of day
|
|
||||||
schedule_monitor,
|
|
||||||
|
|
||||||
# AC monitor: Automatically turns AC on/off based on temperature
|
|
||||||
ac_monitor,
|
|
||||||
|
|
||||||
# Heater monitor: Automatically turns heater on/off based on temperature
|
|
||||||
heater_monitor,
|
|
||||||
|
|
||||||
# Inside temperature monitor: Logs temps, sends alerts if out of range
|
|
||||||
TemperatureMonitor(
|
|
||||||
sensor=sensors['inside'],
|
|
||||||
label=SENSOR_CONFIG['inside']['label'],
|
|
||||||
check_interval=10, # Check temp every 10 seconds
|
|
||||||
report_interval=30, # Log to CSV every 30 seconds
|
|
||||||
alert_high=SENSOR_CONFIG['inside']['alert_high'], # High temp alert threshold
|
|
||||||
alert_low=SENSOR_CONFIG['inside']['alert_low'], # Low temp alert threshold
|
|
||||||
log_file="/temp_logs.csv", # CSV file path
|
|
||||||
send_alerts_to_separate_channel=True # Use separate Discord channel
|
|
||||||
),
|
|
||||||
|
|
||||||
# Outside temperature monitor: Logs temps, sends alerts if out of range
|
|
||||||
TemperatureMonitor(
|
|
||||||
sensor=sensors['outside'],
|
|
||||||
label=SENSOR_CONFIG['outside']['label'],
|
|
||||||
check_interval=10, # Check temp every 10 seconds
|
|
||||||
report_interval=30, # Log to CSV every 30 seconds
|
|
||||||
alert_high=SENSOR_CONFIG['outside']['alert_high'], # High temp alert threshold
|
|
||||||
alert_low=SENSOR_CONFIG['outside']['alert_low'], # Low temp alert threshold
|
|
||||||
log_file="/temp_logs.csv", # CSV file path
|
|
||||||
send_alerts_to_separate_channel=False # Use main Discord channel
|
|
||||||
),
|
|
||||||
]
|
|
||||||
# ===== END: Monitor Setup =====
|
|
||||||
|
|
||||||
print("Starting monitoring loop...")
|
print("Starting monitoring loop...")
|
||||||
print("Press Ctrl+C to stop\n")
|
print("Press Ctrl+C to stop\n")
|
||||||
|
|
||||||
# Add NTP retry flags (before main loop)
|
# Add NTP retry flags (before main loop)
|
||||||
retry_ntp_attempts = 0
|
retry_ntp_attempts = 0
|
||||||
max_ntp_attempts = 5 # Try up to 5 times after initial failure
|
max_ntp_attempts = 5 # Try up to 5 times after initial failure
|
||||||
|
last_ntp_sync = time.time() # Track when we last synced
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
|
||||||
# ===== START: Main Loop =====
|
# ===== START: Main Loop =====
|
||||||
# Main monitoring loop (runs forever until Ctrl+C)
|
# Main monitoring loop (runs forever until Ctrl+C)
|
||||||
while True:
|
last_monitor_run = {
|
||||||
try:
|
"wifi": 0,
|
||||||
# Run all monitors (each checks if it's time to run via should_run())
|
"schedule": 0,
|
||||||
run_monitors(monitors)
|
"ac": 0,
|
||||||
|
"heater": 0,
|
||||||
|
"inside_temp": 0,
|
||||||
|
"outside_temp": 0,
|
||||||
|
}
|
||||||
|
|
||||||
# Web requests
|
while True:
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# WiFi monitor every 5 seconds (can be stateless)
|
||||||
|
if now - last_monitor_run["wifi"] >= 5:
|
||||||
|
from scripts.monitors import WiFiMonitor
|
||||||
|
wifi_monitor = WiFiMonitor(wifi, led, interval=5, reconnect_cooldown=60, config=config)
|
||||||
|
try:
|
||||||
|
wifi_monitor.run()
|
||||||
|
except Exception as e:
|
||||||
|
print("WiFiMonitor error:", e)
|
||||||
|
del wifi_monitor
|
||||||
|
gc.collect()
|
||||||
|
last_monitor_run["wifi"] = now
|
||||||
|
|
||||||
|
# Schedule monitor every 60 seconds (persistent)
|
||||||
|
if now - last_monitor_run["schedule"] >= 60:
|
||||||
|
try:
|
||||||
|
schedule_monitor.run()
|
||||||
|
except Exception as e:
|
||||||
|
print("ScheduleMonitor error:", e)
|
||||||
|
last_monitor_run["schedule"] = now
|
||||||
|
|
||||||
|
# AC monitor every 30 seconds (persistent)
|
||||||
|
if now - last_monitor_run["ac"] >= 30:
|
||||||
|
try:
|
||||||
|
ac_monitor.run()
|
||||||
|
except Exception as e:
|
||||||
|
print("ACMonitor error:", e)
|
||||||
|
last_monitor_run["ac"] = now
|
||||||
|
|
||||||
|
# Heater monitor every 30 seconds (persistent)
|
||||||
|
if now - last_monitor_run["heater"] >= 30:
|
||||||
|
try:
|
||||||
|
heater_monitor.run()
|
||||||
|
except Exception as e:
|
||||||
|
print("HeaterMonitor error:", e)
|
||||||
|
last_monitor_run["heater"] = now
|
||||||
|
|
||||||
|
# Inside temperature monitor every 10 seconds (can be stateless)
|
||||||
|
if now - last_monitor_run["inside_temp"] >= 10:
|
||||||
|
from scripts.monitors import TemperatureMonitor
|
||||||
|
inside_monitor = TemperatureMonitor(
|
||||||
|
sensor=sensors['inside'],
|
||||||
|
label=SENSOR_CONFIG['inside']['label'],
|
||||||
|
check_interval=10,
|
||||||
|
report_interval=30,
|
||||||
|
alert_high=SENSOR_CONFIG['inside']['alert_high'],
|
||||||
|
alert_low=SENSOR_CONFIG['inside']['alert_low'],
|
||||||
|
log_file="/temp_logs.csv",
|
||||||
|
send_alerts_to_separate_channel=True
|
||||||
|
)
|
||||||
|
inside_monitor.run()
|
||||||
|
del inside_monitor
|
||||||
|
gc.collect()
|
||||||
|
last_monitor_run["inside_temp"] = now
|
||||||
|
|
||||||
|
# Outside temperature monitor every 10 seconds (can be stateless)
|
||||||
|
if now - last_monitor_run["outside_temp"] >= 10:
|
||||||
|
from scripts.monitors import TemperatureMonitor
|
||||||
|
outside_monitor = TemperatureMonitor(
|
||||||
|
sensor=sensors['outside'],
|
||||||
|
label=SENSOR_CONFIG['outside']['label'],
|
||||||
|
check_interval=10,
|
||||||
|
report_interval=30,
|
||||||
|
alert_high=SENSOR_CONFIG['outside']['alert_high'],
|
||||||
|
alert_low=SENSOR_CONFIG['outside']['alert_low'],
|
||||||
|
log_file="/temp_logs.csv",
|
||||||
|
send_alerts_to_separate_channel=False
|
||||||
|
)
|
||||||
|
outside_monitor.run()
|
||||||
|
del outside_monitor
|
||||||
|
gc.collect()
|
||||||
|
last_monitor_run["outside_temp"] = now
|
||||||
|
|
||||||
|
# Web requests (keep web server loaded if needed)
|
||||||
web_server.check_requests(sensors, ac_monitor, heater_monitor, schedule_monitor, config)
|
web_server.check_requests(sensors, ac_monitor, heater_monitor, schedule_monitor, config)
|
||||||
|
|
||||||
# Retry NTP sync every ~10s if not yet synced
|
|
||||||
if not ntp_synced and retry_ntp_attempts < max_ntp_attempts:
|
|
||||||
if retry_ntp_attempts == 0 or (time.time() % 10) < 1:
|
|
||||||
try:
|
|
||||||
import ntptime # type: ignore
|
|
||||||
import socket
|
|
||||||
import struct
|
|
||||||
|
|
||||||
# Quick NTP sync with timeout
|
|
||||||
NTP_DELTA = 2208988800
|
|
||||||
host = "pool.ntp.org"
|
|
||||||
NTP_QUERY = bytearray(48)
|
|
||||||
NTP_QUERY[0] = 0x1B
|
|
||||||
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
s.settimeout(3.0) # 3-second timeout
|
|
||||||
|
|
||||||
try:
|
|
||||||
addr = socket.getaddrinfo(host, 123)[0][-1]
|
|
||||||
s.sendto(NTP_QUERY, addr)
|
|
||||||
msg = s.recv(48)
|
|
||||||
val = struct.unpack("!I", msg[40:44])[0]
|
|
||||||
t = val - NTP_DELTA
|
|
||||||
|
|
||||||
tm = time.gmtime(t)
|
|
||||||
RTC().datetime((tm[0], tm[1], tm[2], tm[6] + 1, tm[3], tm[4], tm[5], 0))
|
|
||||||
|
|
||||||
ntp_synced = True
|
|
||||||
print("NTP sync succeeded on retry #{}".format(retry_ntp_attempts + 1))
|
|
||||||
finally:
|
|
||||||
s.close()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
retry_ntp_attempts += 1
|
|
||||||
print("NTP retry {} failed: {}".format(retry_ntp_attempts, e))
|
|
||||||
|
|
||||||
# Enable garbage collection to free memory
|
|
||||||
gc.collect()
|
gc.collect()
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
|
# ===== END: Main Loop =====
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
# Graceful shutdown on Ctrl+C
|
print("\n" + "="*50)
|
||||||
print("\n\n" + "="*50)
|
|
||||||
print("Shutting down gracefully...")
|
print("Shutting down gracefully...")
|
||||||
print("="*50)
|
print("="*50)
|
||||||
|
try:
|
||||||
print("Turning off AC...")
|
print("Turning off AC...")
|
||||||
ac_controller.turn_off()
|
ac_controller.turn_off()
|
||||||
|
except Exception as e:
|
||||||
|
print("AC shutdown error:", e)
|
||||||
|
try:
|
||||||
print("Turning off heater...")
|
print("Turning off heater...")
|
||||||
heater_controller.turn_off()
|
heater_controller.turn_off()
|
||||||
|
except Exception as e:
|
||||||
|
print("Heater shutdown error:", e)
|
||||||
|
try:
|
||||||
print("Turning off LED...")
|
print("Turning off LED...")
|
||||||
led.low()
|
led.low()
|
||||||
print("Shutdown complete!")
|
|
||||||
print("="*50 + "\n")
|
|
||||||
break
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# If loop crashes, print error and keep running
|
print("LED shutdown error:", e)
|
||||||
print("❌ Main loop error: {}".format(e))
|
print("Shutdown complete!")
|
||||||
import sys
|
print("="*50)
|
||||||
sys.print_exception(e)
|
|
||||||
print("⚠️ Pausing 5 seconds before retrying...")
|
|
||||||
time.sleep(5) # Brief pause before retrying
|
|
||||||
# ===== END: Main Loop =====
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
secrets = {
|
|
||||||
'ssid': ' Change_to_wifi_SSID',
|
|
||||||
'password': 'Change_to_wifi_Pasword',
|
|
||||||
'discord_webhook_url': 'https://discord.com/api/webhooks/key/long-Combination_1234-ChangeMe', # normal updates
|
|
||||||
'discord_alert_webhook_url': 'https://discord.com/api/webhooks/key/long-Combination_1234-ChangeMe', # alerts only
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user