Compare commits

...

64 Commits

Author SHA1 Message Date
e90e56c3f8 fix: Update configuration instructions to reflect changes in secrets management and file structure 2025-11-28 10:20:49 -05:00
5cfa36e558 fix: Remove test_send.py script to clean up unused code 2025-11-17 17:16:30 -05:00
c8102e62ee fix: Refactor main loop for graceful shutdown and improved error handling 2025-11-15 14:11:20 -05:00
d76b11430c fix: Remove redundant garbage collection calls in send_discord_message function 2025-11-15 13:54:08 -05:00
cb274545a3 fix: Remove unused variable 'schedules' and optimize garbage collection in schedule handling 2025-11-15 13:53:09 -05:00
6cd1349633 fix: Remove unused variables and trigger garbage collection in schedule handling 2025-11-15 13:49:20 -05:00
bcecf2a81a fix: Add garbage collection calls to optimize memory usage in web server operations 2025-11-15 13:40:41 -05:00
621a48f011 fix: Adjust memory threshold for Discord message sending to enhance reliability 2025-11-15 13:15:45 -05:00
ce816af9e7 fix: Adjust memory threshold for Discord message sending and improve error logging 2025-11-15 13:10:48 -05:00
519cb25038 refactor: Optimize import statements and restructure monitoring logic for improved performance 2025-11-15 13:10:41 -05:00
f81d89980b refactor: Remove debug print statements from POST request handling in TempWebServer 2025-11-15 13:10:32 -05:00
7fc7661dad fix: Adjust memory threshold for Discord message sending and add debug logging for RAM usage 2025-11-15 12:02:50 -05:00
3b7982a3a3 fix: Adjust memory threshold for Discord message sending to improve reliability 2025-11-15 11:58:32 -05:00
697f0bf31e fix: Improve Discord message sending logic and memory management 2025-11-15 11:52:54 -05:00
b632a76d5a refactor: Remove debug_force_send function to streamline message sending process 2025-11-15 11:22:36 -05:00
d670067b89 fix: Optimize memory management in debug_force_send and send_discord_message functions 2025-11-15 10:32:04 -05:00
ac860207d9 fix: Increase memory thresholds for Discord message sending and adjust garbage collection logging 2025-11-15 10:27:26 -05:00
03b26b5339 feat: Add debug_force_send function for memory tracking and testing 2025-11-15 10:19:49 -05:00
5a8d14eb4d fix: Enable debug logging in send_discord_message for better memory tracking 2025-11-15 10:03:15 -05:00
79445bf879 fix: Add debug logging to send_discord_message for memory checks and import impact 2025-11-15 09:58:09 -05:00
4400fb5a74 fix: Adjust memory thresholds for Discord message sending to match device capabilities 2025-11-15 09:46:22 -05:00
c6f46e097b fix: Increase memory thresholds and backoff duration for Discord message sending 2025-11-15 09:42:16 -05:00
d2c0f68488 fix: Enhance Discord message sending with memory checks and scheduling 2025-11-15 09:36:44 -05:00
13e3a56fa6 fix: Add low-memory guard and cooldown for Discord message sending
This isn't quite the fix though just want to save my position till tomorrow and see what changes come up before and after
2025-11-14 21:48:19 -05:00
efea4a1384 fix: Enhance Discord message sending with aggressive GC and low-memory guard 2025-11-14 21:28:10 -05:00
73b5a5aefe fix: Improve HTTP response handling and clarify default values in schedule configuration 2025-11-14 21:18:20 -05:00
03766d6b09 fix: Improve HTTP response handling and add schedule JavaScript support 2025-11-14 21:13:44 -05:00
e5f9331d30 fix: Clarify logic for matching AC and heater adjustments in synchronization 2025-11-14 20:49:51 -05:00
6128e585b8 fix: Improve error handling in web server request processing 2025-11-14 20:47:42 -05:00
81174b78e4 fix: Enhance live synchronization logic for heater and AC inputs with last changed tracking 2025-11-14 20:38:48 -05:00
70cc2cad81 fix: Refactor live synchronization logic for heater and AC inputs in schedule form 2025-11-14 20:37:06 -05:00
6bc7b1da93 fix: Implement live synchronization for heater and AC inputs in schedule form 2025-11-14 20:29:12 -05:00
eceee9c88d syncs while typing and guarantees posted values follow the rule 2025-11-14 20:19:13 -05:00
72eb3c2acf fix: Enhance schedule synchronization logic for heater and AC targets 2025-11-14 19:42:21 -05:00
eff69cfe52 fix: Implement auto-sync for heater and AC targets in scheduling and settings
Fixes #17
2025-11-14 18:17:17 -05:00
63588ee3f1 Merge branch 'main' of https://gitea.rcs1.top/sickprodigy/Auto-Garden 2025-11-14 17:19:52 -05:00
8363406647 fix: Move discord_webhook import to after config loading and update WiFi connection comment. Save on ram usage 2025-11-14 17:19:44 -05:00
df08692726 fix: Add type ignore comments for import errors 2025-11-14 17:19:44 -05:00
0030e0a932 fix: Add type ignore comments for imports in multiple scripts to improve compatibility 2025-11-14 17:19:43 -05:00
d95f212d2e Add example configuration file, moved everything from secrets.py to here.
Feat: Also refactored some of the logic in discord_webhook.py and networking.py to be more friendly towards the pico with ram usage.

Fixes #26
2025-11-14 17:18:17 -05:00
0f7c4cc4d7 fix: Move discord_webhook import to after config loading and update WiFi connection comment. Save on ram usage 2025-11-14 17:04:47 -05:00
a9641947ba fix: Add type ignore comments for import errors 2025-11-14 17:02:19 -05:00
63ff2cec77 fix: Add type ignore comments for imports in multiple scripts to improve compatibility 2025-11-14 16:53:51 -05:00
6890d0570e Add example configuration file, moved everything from secrets.py to here.
Feat: Also refactored some of the logic in discord_webhook.py and networking.py to be more friendly towards the pico with ram usage.
2025-11-14 16:50:53 -05:00
a20bbd7cdf Ignore config.json 2025-11-14 15:57:53 -05:00
7edd209abe start of moving secrets.py to config.json 2025-11-14 15:55:36 -05:00
2c39ebd985 feat: Update TemperatureMonitor to send alerts via Discord with improved messaging function 2025-11-11 17:13:34 -05:00
1016e96b58 feat: Add static IP configuration options to config and main files. Also remove creation of config from web_server.py because I was already doing it in main.py like it should be done, somewhere first.
Fixes #25
2025-11-11 16:55:27 -05:00
b3c56864ac update: clean up code formatting 2025-11-10 18:59:18 -05:00
95e159ee5d feat: Update README with recent enhancements including immediate schedule application, aggressive memory management, and improved config persistence 2025-11-09 12:43:37 -05:00
5da44e1397 feat: Enhance schedule application by saving updated config to file and ensuring target persistence 2025-11-09 12:43:32 -05:00
b346be9431 feat: Implement immediate application of active schedule on startup and enhance schedule resume handling
Fixes #24
2025-11-09 12:25:16 -05:00
229bde85e9 feat: Add temperature validation in TemperatureMonitor and implement aggressive garbage collection in main loop 2025-11-09 11:54:12 -05:00
dae6971112 feat: Implement NTP sync function with garbage collection and improve schedule handling in web server
reduce ram usage bascically.
Fixes #22 (more garbage collection all it needed, but went further to try and cut more memory usage)
Fixes #21 (Just forgot to already marke this one as completed. Possibly in this commit too)
2025-11-09 11:29:18 -05:00
3c2e936d56 feat: Add advanced settings page and update handling for temperature hold configurations
Fixes #20

didn't rename it that way but have done quite a bit already want to save working point
2025-11-09 10:48:49 -05:00
9da21f7c89 feat: Implement temporary hold management with config integration and improved notifications
fixes #19
2025-11-09 10:11:00 -05:00
b6aae121bb feat: Add dynamic config reload and timezone offset handling in web server
Fixes #18
Already Fixed #13
after long hours of tedious back and forth coding to figure out wtf was happening jesus
2025-11-09 09:24:21 -05:00
24b53b9446 Removed stuff from the try block so it doesn't loop. just initialize in the beginning 2025-11-09 01:18:16 -05:00
749eb956a5 feat: Enhance request handling with improved header parsing and error management
Fixes #16
Fixes #15
Fixes #14
Fixes #6
2025-11-09 00:54:07 -05:00
5ce7cd43a4 feat: Improve HTTP response handling in web server with proper headers 2025-11-08 19:28:11 -05:00
bb46a69eba feat: Update Discord message function with improved comments and error handling 2025-11-08 19:28:06 -05:00
b018b427f6 feat: Enhance NTP synchronization with timeout and error handling 2025-11-08 19:28:00 -05:00
99d92a6e90 feat: Add type ignore comments for imports in multiple scripts 2025-11-08 18:38:11 -05:00
b712c19740 feat: Refactor request handling to include config parameter and improve error logging 2025-11-08 18:38:01 -05:00
14 changed files with 1501 additions and 561 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
/__pycache__/ /__pycache__/
secrets.py secrets.py
config.json
/pymakr-test/ /pymakr-test/
.gitignore .gitignore
.vscode/ .vscode/

View File

@@ -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 schedules 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

View File

@@ -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."""

View File

@@ -1,8 +1,25 @@
import urequests as requests # 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,46 +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 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:
# print("DEBUG: no webhook URL in secrets") 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 by hand so emoji (and other unicode) are preserved as UTF-8 bytes 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"}
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:
# print("Discord message sent")
return True
else:
# print(f"Discord webhook failed with status {status}")
return False
except Exception as e: except Exception as e:
# print("Failed to send Discord message:", e) 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

View File

@@ -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."""

View File

@@ -1,4 +1,4 @@
import gc import gc # type: ignore
def check_memory_once(): def check_memory_once():
"""One-time memory check (for startup diagnostics).""" """One-time memory check (for startup diagnostics)."""

View File

@@ -1,5 +1,5 @@
import time 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,31 +240,32 @@ 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."""
import network import network # type: ignore
from scripts.networking import connect_wifi from scripts.networking import connect_wifi
is_connected = self.wifi.isconnected() if self.wifi else False is_connected = self.wifi.isconnected() if self.wifi else False
@@ -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):

View File

@@ -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!')

View File

@@ -1,4 +1,4 @@
import time import time # type: ignore
class ScheduleMonitor: class ScheduleMonitor:
"""Monitor that checks and applies temperature schedules.""" """Monitor that checks and applies temperature schedules."""
@@ -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

View File

@@ -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

View File

@@ -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
} }

340
main.py
View File

@@ -1,12 +1,9 @@
from machine import Pin, WDT # type: ignore from machine import Pin, RTC # type: ignore
import time import time # type: ignore
import network import network # type: ignore
import json import json
import gc # 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',
@@ -77,7 +120,7 @@ def load_config():
'heater_target': 72.0 'heater_target': 72.0
} }
], ],
'schedule_enabled': False, # Schedules disabled by default (user can enable via web) 'schedule_enabled': True, # Schedules disabled by default (user can enable via web)
'permanent_hold': False # Permanent hold disabled by default 'permanent_hold': False # Permanent hold disabled by default
} }
@@ -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,23 +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 # Try sending Discord webhook NOW, before creating other objects
send_discord_message(f"Pico W online at http://{ifconfig[0]}") gc.collect()
ram_free = gc.mem_free()
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")
discord_sent = True
else:
print("Discord startup notification failed")
pending_discord_message = "Pico W online at http://{}".format(ifconfig[0])
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 non-blocking (short timeout + retry flag) # ===== INITIAL NTP SYNC (using function) =====
ntp_synced = False ntp_synced = False
try: try:
import ntptime ntp_synced = sync_ntp_time(TIMEZONE_OFFSET)
ntptime.settime() if ntp_synced:
ntp_synced = True print("Time synced with NTP server (UTC{:+d})".format(TIMEZONE_OFFSET))
print("Time synced with NTP server") else:
print("Initial NTP sync failed, will retry in background...")
except Exception as e: except Exception as e:
print("Initial NTP sync failed: {}".format(e)) print("Initial NTP sync error: {}".format(e))
# Will retry later in loop # ===== END: INITIAL NTP SYNC =====
else: else:
# WiFi connection failed # WiFi connection failed
@@ -166,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 = {
@@ -239,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 =====
@@ -259,103 +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)
last_monitor_run = {
"wifi": 0,
"schedule": 0,
"ac": 0,
"heater": 0,
"inside_temp": 0,
"outside_temp": 0,
}
while True: while True:
try: now = time.time()
# Run all monitors (each checks if it's time to run via should_run())
run_monitors(monitors)
# Web requests # WiFi monitor every 5 seconds (can be stateless)
web_server.check_requests(sensors, ac_monitor, heater_monitor, schedule_monitor) if now - last_monitor_run["wifi"] >= 5:
from scripts.monitors import WiFiMonitor
# Retry NTP sync every ~10s if not yet synced wifi_monitor = WiFiMonitor(wifi, led, interval=5, reconnect_cooldown=60, config=config)
if not ntp_synced and retry_ntp_attempts < max_ntp_attempts:
# Try once immediately, then whenever (time.time() % 10) < 1 (rough 10s window)
try: try:
import ntptime wifi_monitor.run()
if retry_ntp_attempts == 0 or (time.time() % 10) < 1:
ntptime.settime()
ntp_synced = True
print("NTP sync succeeded on retry #{}".format(retry_ntp_attempts + 1))
except Exception as e: except Exception as e:
# Increment only when an actual attempt was made print("WiFiMonitor error:", e)
if retry_ntp_attempts == 0 or (time.time() % 10) < 1: del wifi_monitor
retry_ntp_attempts += 1 gc.collect()
print("NTP retry {} failed: {}".format(retry_ntp_attempts, e)) last_monitor_run["wifi"] = now
# Enable garbage collection to free memory
# 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)
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 =====

View File

@@ -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
}