Compare commits

..

13 Commits

Author SHA1 Message Date
9e79d83603 docs: update fork reference in README to include specific commit hash 2025-11-29 15:20:42 -05:00
86c3950221 fix: Update README to reflect changes in configuration from secrets.py to config.json 2025-11-28 10:18:19 -05:00
27f901ff6a fix: Update README for project renaming and clarify thermostat features 2025-11-28 10:09:47 -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
5 changed files with 192 additions and 238 deletions

113
README.md
View File

@@ -1,16 +1,20 @@
# 🌱 Auto Garden # 🌡️ Raspberry Pi Pico Thermostat
> **Forked from [Auto-Garden](https://gitea.rcs1.top/sickprodigy/Auto-Garden); [Commit c8102e62ee](https://gitea.rcs1.top/sickprodigy/Auto-Garden/commit/c8102e62ee8c3b8cf60a57654bab7703df2f27af)**
> Smart thermostat and climate control system using Raspberry Pi Pico W with web interface, scheduling, and relay control
> Automated climate control system using Raspberry Pi Pico W with web interface and scheduling
## Recent Updates ## Recent Updates
- 🆕 **Immediate schedule application:** When resuming scheduling from hold mode, the system now instantly applies the current schedule targets (no delay). - 🆕 **Immediate schedule application:** When resuming scheduling from hold mode, the thermostat instantly applies the current schedule targets (no delay).
- 🆕 **Aggressive memory management:** Garbage collection runs every 5 seconds for improved reliability. - 🆕 **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. - 🆕 **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. - 🆕 **NTP sync optimization:** NTP modules are loaded only when needed, saving RAM.
- 🆕 **Temperature validation:** Impossible sensor readings are ignored for safety. - 🆕 **Temperature validation:** Impossible sensor readings are ignored for safety.
- 🆕 **Improved config persistence:** All changes are saved and reloaded immediately. - 🆕 **Improved config persistence:** All changes are saved and reloaded immediately.
## Features ## Features
- **Core Features** - **Core Features**
@@ -24,9 +28,8 @@
- ✅ Graceful shutdown with Ctrl+C - ✅ Graceful shutdown with Ctrl+C
-**Aggressive garbage collection for stability** -**Aggressive garbage collection for stability**
- **Climate Control** - **Thermostat Control**
- ✅ Automated AC control with temperature swing logic - ✅ Automated AC and heater control with temperature swing logic
- ✅ Automated heater control with separate swing settings
- ✅ 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)
@@ -53,6 +56,7 @@
- ✅ Auto-refresh dashboard (30 seconds) - ✅ Auto-refresh dashboard (30 seconds)
-**Settings and schedule changes are reflected instantly** -**Settings and schedule changes are reflected instantly**
## Configuration Notes ## Configuration Notes
- **AC/Heater target settings:** - **AC/Heater target settings:**
@@ -61,7 +65,7 @@
- This ensures the config file always reflects the current operating temperatures, whether in hold mode or schedule mode. - This ensures the config file always reflects the current operating temperatures, whether in hold mode or schedule mode.
- **Immediate schedule application:** - **Immediate schedule application:**
- When you click "Resume Scheduling," the system applies the current schedule targets instantly, so the dashboard updates without delay. - When you click "Resume Scheduling," the thermostat applies the current schedule targets instantly, so the dashboard updates without delay.
- **Memory management:** - **Memory management:**
- Garbage collection runs every 5 seconds to prevent memory fragmentation and crashes. - Garbage collection runs every 5 seconds to prevent memory fragmentation and crashes.
@@ -69,6 +73,7 @@
- **Sensor validation:** - **Sensor validation:**
- Temperatures outside the range -50°F to 150°F are ignored to prevent false readings. - Temperatures outside the range -50°F to 150°F are ignored to prevent false readings.
## Quick Start ## Quick Start
### 1. Hardware Setup ### 1. Hardware Setup
@@ -146,56 +151,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
] ]
} }
``` ```
@@ -204,12 +181,11 @@ All settings can be changed via the web interface and persist through reboots.
### 5. Upload & Run ### 5. Upload & Run
Upload all files to your Pico: **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
@@ -223,24 +199,24 @@ Upload all files to your Pico:
The Pico will auto-start `main.py` on boot and be accessible at **<http://192.168.x.x>** The Pico will auto-start `main.py` on boot and be accessible at **<http://192.168.x.x>**
## Project Structure ## Project Structure
```text ```text
Auto-Garden/ Raspberry-Pi-Pico-Thermostat/
├── 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
├── monitors.py # Monitor base class & implementations ├── monitors.py # Monitor base class & implementations
├── networking.py # WiFi connection management ├── networking.py # WiFi connection management
├── scheduler.py # Schedule system with temporary/permanent hold modes ├── scheduler.py # Schedule system with temporary/permanent hold modes
├── temperature_sensor.py # DS18B20 sensor interface ├── temperature_sensor.py # DS18B20 sensor interface
└── web_server.py # Web interface for monitoring and control └── web_server.py # Web interface for monitoring and control
``` ```
## How It Works ## How It Works
### Temperature Monitoring ### Temperature Monitoring
@@ -354,6 +330,7 @@ Access at **<http://192.168.x.x>**
- **Auto-reconnect:** Attempts every 60 seconds if disconnected - **Auto-reconnect:** Attempts every 60 seconds if disconnected
- **Static IP:** Always accessible at <http://192.168.x.x> - **Static IP:** Always accessible at <http://192.168.x.x>
## Temperature Logs ## Temperature Logs
Logs are saved to `/temp_logs.csv` on the Pico: Logs are saved to `/temp_logs.csv` on the Pico:
@@ -365,6 +342,7 @@ Logs are saved to `/temp_logs.csv` on the Pico:
Format: `timestamp,location,sensor_id,temperature_f` Format: `timestamp,location,sensor_id,temperature_f`
## Customization ## Customization
### Via Web Interface (Recommended) ### Via Web Interface (Recommended)
@@ -405,6 +383,7 @@ check_interval=10 # Temperature check frequency
report_interval=30 # Discord report frequency report_interval=30 # Discord report frequency
``` ```
## Safety Notes ## Safety Notes
⚠️ **High Voltage Warning:** ⚠️ **High Voltage Warning:**
@@ -429,6 +408,7 @@ report_interval=30 # Discord report frequency
- Hold modes reset on reboot (schedules always resume) - Hold modes reset on reboot (schedules always resume)
- Static IP ensures web interface always accessible - Static IP ensures web interface always accessible
## Troubleshooting ## Troubleshooting
**Web interface not loading:** **Web interface not loading:**
@@ -447,14 +427,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
@@ -501,14 +481,17 @@ report_interval=30 # Discord report frequency
- Ensure config.json has write permissions - Ensure config.json has write permissions
- Try manual edit of config.json and reboot - Try manual edit of config.json and reboot
## Contributing ## Contributing
Feel free to open issues or submit pull requests for improvements! Feel free to open issues or submit pull requests for improvements!
## License ## License
MIT License - See LICENSE file for details MIT License - See LICENSE file for details
## Resources ## Resources
- [Raspberry Pi Pico W Documentation](https://www.raspberrypi.com/documentation/microcontrollers/raspberry-pi-pico.html) - [Raspberry Pi Pico W Documentation](https://www.raspberrypi.com/documentation/microcontrollers/raspberry-pi-pico.html)

View File

@@ -55,24 +55,24 @@ def send_discord_message(message, username="Auto Garden Bot", is_alert=False, de
import gc # type: ignore import gc # type: ignore
import time # type: ignore import time # type: ignore
gc.collect(); gc.collect() gc.collect()
# Quick mem check before importing urequests/SSL # Quick mem check before importing urequests/SSL
mem = getattr(gc, "mem_free", lambda: None)() mem = getattr(gc, "mem_free", lambda: None)()
# Require larger headroom based on device testing (adjust if you re-test) # Require larger headroom based on device testing (adjust if you re-test)
if mem is not None and mem < 150000: if mem is not None and mem < 95000:
# quietly skip send when memory is insufficient print("Discord send skipped: ENOMEM ({} bytes free)".format(mem))
return False return False
# Import urequests only when we plan to send # Import urequests only when we plan to send
try: try:
import urequests as requests # type: ignore import urequests as requests # type: ignore
except Exception: except Exception as e:
print("Discord send failed: urequests import error:", e)
try: try:
_NEXT_ALLOWED_SEND_TS = time.time() + 60 _NEXT_ALLOWED_SEND_TS = time.time() + 60
except: except:
pass pass
# quiet failure to avoid spamming serial; caller can check return value
return False return False
gc.collect() gc.collect()
@@ -93,14 +93,13 @@ def send_discord_message(message, username="Auto Garden Bot", is_alert=False, de
return bool(status and 200 <= status < 300) return bool(status and 200 <= status < 300)
except Exception as e: except Exception as e:
# On ENOMEM/MemoryError back off print("Discord send failed:", e)
try: try:
if ("ENOMEM" in str(e)) or isinstance(e, MemoryError): if ("ENOMEM" in str(e)) or isinstance(e, MemoryError):
import time # type: ignore import time # type: ignore
_NEXT_ALLOWED_SEND_TS = time.time() + 60 _NEXT_ALLOWED_SEND_TS = time.time() + 60
except: except:
pass pass
# quiet exception path; return False for caller to handle/backoff
return False return False
finally: finally:
@@ -132,6 +131,6 @@ def send_discord_message(message, username="Auto Garden Bot", is_alert=False, de
pass pass
try: try:
import gc # type: ignore import gc # type: ignore
gc.collect(); gc.collect() gc.collect()
except: except:
pass pass

View File

@@ -1,16 +0,0 @@
import ujson
# Reload discord module fresh and run the forced debug send once.
try:
# ensure we use latest module on device
import sys
if "scripts.discord_webhook" in sys.modules:
del sys.modules["scripts.discord_webhook"]
import scripts.discord_webhook as d
# load config.json to populate webhook URL
with open("config.json", "r") as f:
cfg = ujson.load(f)
d.set_config(cfg)
print("Running debug_force_send() — may trigger ENOMEM, run once only")
d.debug_force_send("memory test")
except Exception as e:
print("test_send error:", e)

View File

@@ -58,12 +58,6 @@ class TempWebServer:
bytes_read = len(body_so_far.encode('utf-8')) bytes_read = len(body_so_far.encode('utf-8'))
bytes_needed = content_length - bytes_read bytes_needed = content_length - bytes_read
# ===== DEBUG: Print body reading info =====
print("DEBUG POST: Content-Length = {} bytes".format(content_length))
print("DEBUG POST: Already read = {} bytes".format(bytes_read))
print("DEBUG POST: Still need = {} bytes".format(bytes_needed))
# ===== END DEBUG =====
# Read remaining body in loop (recv() may not return all at once!) # Read remaining body in loop (recv() may not return all at once!)
if bytes_needed > 0: if bytes_needed > 0:
remaining_parts = [] remaining_parts = []
@@ -77,21 +71,10 @@ class TempWebServer:
break break
remaining_parts.append(chunk) remaining_parts.append(chunk)
total_read += len(chunk) total_read += len(chunk)
print("DEBUG POST: Read {} bytes (total: {}/{})".format(
len(chunk), total_read, bytes_needed))
remaining = b''.join(remaining_parts) remaining = b''.join(remaining_parts)
print("DEBUG POST: Read additional {} bytes (expected {})".format(
len(remaining), bytes_needed))
request = request[:header_end] + body_so_far + remaining.decode('utf-8') request = request[:header_end] + body_so_far + remaining.decode('utf-8')
# ===== DEBUG: Print final body length =====
final_body = request[header_end:]
print("DEBUG POST: Final body length = {} bytes (expected {})".format(
len(final_body), content_length))
print("DEBUG POST: First 100 chars = {}".format(final_body[:100]))
# ===== END DEBUG =====
if 'POST /update' in request: if 'POST /update' in request:
response = self._handle_update(request, sensors, ac_monitor, heater_monitor, schedule_monitor, config) response = self._handle_update(request, sensors, ac_monitor, heater_monitor, schedule_monitor, config)
# If error page redirects, handle it # If error page redirects, handle it
@@ -118,7 +101,6 @@ class TempWebServer:
for i in range(0, len(response_bytes), chunk_size): for i in range(0, len(response_bytes), chunk_size):
chunk = response_bytes[i:i+chunk_size] chunk = response_bytes[i:i+chunk_size]
conn.sendall(chunk) conn.sendall(chunk)
print("DEBUG: Sent chunk {} ({} bytes)".format(i//chunk_size + 1, len(chunk)))
conn.close() conn.close()
print("DEBUG: Schedule editor page sent successfully ({} bytes total)".format(len(response_bytes))) print("DEBUG: Schedule editor page sent successfully ({} bytes total)".format(len(response_bytes)))
@@ -212,6 +194,8 @@ class TempWebServer:
print("ERROR: Failed to send response: {}".format(e)) print("ERROR: Failed to send response: {}".format(e))
finally: finally:
conn.close() conn.close()
import gc # type: ignore
gc.collect()
print("DEBUG: Client connection closed") print("DEBUG: Client connection closed")
# ===== END: Send response ===== # ===== END: Send response =====
@@ -284,7 +268,8 @@ class TempWebServer:
def _handle_schedule_update(self, request, sensors, ac_monitor, heater_monitor, schedule_monitor, config): def _handle_schedule_update(self, request, sensors, ac_monitor, heater_monitor, schedule_monitor, config):
"""Handle schedule form submission.""" """Handle schedule form submission."""
import gc # type: ignore
gc.collect()
try: try:
body = request.split('\r\n\r\n')[1] if '\r\n\r\n' in request else '' body = request.split('\r\n\r\n')[1] if '\r\n\r\n' in request else ''
params = {} params = {}
@@ -298,6 +283,7 @@ class TempWebServer:
mode_action = params.get('mode_action', '') mode_action = params.get('mode_action', '')
if mode_action == 'resume': if mode_action == 'resume':
gc.collect()
# Resume automatic scheduling # Resume automatic scheduling
config['schedule_enabled'] = True config['schedule_enabled'] = True
config['permanent_hold'] = False config['permanent_hold'] = False
@@ -331,6 +317,7 @@ class TempWebServer:
return redirect_response return redirect_response
elif mode_action == 'temporary_hold': elif mode_action == 'temporary_hold':
gc.collect()
# Enter temporary hold (pause schedules temporarily) # Enter temporary hold (pause schedules temporarily)
config['schedule_enabled'] = False config['schedule_enabled'] = False
config['permanent_hold'] = False config['permanent_hold'] = False
@@ -355,6 +342,7 @@ class TempWebServer:
return redirect_response return redirect_response
elif mode_action == 'permanent_hold': elif mode_action == 'permanent_hold':
gc.collect()
# Enter permanent hold (disable schedules permanently) # Enter permanent hold (disable schedules permanently)
config['schedule_enabled'] = False config['schedule_enabled'] = False
config['permanent_hold'] = True config['permanent_hold'] = True
@@ -380,6 +368,7 @@ class TempWebServer:
return redirect_response return redirect_response
elif mode_action == 'save_schedules': elif mode_action == 'save_schedules':
gc.collect()
# Just fall through to schedule parsing below # Just fall through to schedule parsing below
pass pass
# ===== END: Handle mode actions ===== # ===== END: Handle mode actions =====
@@ -389,12 +378,6 @@ class TempWebServer:
prev_schedules = prev.get('schedules', []) prev_schedules = prev.get('schedules', [])
# ===== START: Handle schedule configuration save ===== # ===== START: Handle schedule configuration save =====
# DEBUG: Print what we received
print("DEBUG: Received POST body parameters:")
for key, value in params.items():
print(" {} = '{}'".format(key, value))
print("DEBUG: Total params received: {}".format(len(params)))
# Parse schedules (4 slots) # Parse schedules (4 slots)
schedules = [] schedules = []
has_any_schedule_data = False has_any_schedule_data = False
@@ -501,8 +484,6 @@ class TempWebServer:
'heater_target': heater_target 'heater_target': heater_target
} }
schedules.append(schedule) schedules.append(schedule)
print("DEBUG: Parsed schedule {}: time='{}', name='{}', heater={}, ac={}".format(
i, schedule_time, schedule_name, heater_target, ac_target))
# Only update schedules if user submitted schedule form data # Only update schedules if user submitted schedule form data
if has_any_schedule_data: if has_any_schedule_data:
@@ -556,7 +537,8 @@ class TempWebServer:
if heater_monitor: if heater_monitor:
heater_monitor.target_temp = config['heater_target'] heater_monitor.target_temp = config['heater_target']
heater_monitor.temp_swing = config['heater_swing'] heater_monitor.temp_swing = config['heater_swing']
del params, prev_schedules, prev
gc.collect()
# Send Discord notification # Send Discord notification
try: try:
mode = "automatic" if config.get('schedule_enabled') else "hold" mode = "automatic" if config.get('schedule_enabled') else "hold"
@@ -567,7 +549,8 @@ class TempWebServer:
except: except:
pass pass
# ===== END: Handle schedule configuration save ===== # ===== END: Handle schedule configuration save =====
del schedules
gc.collect()
# Redirect back to homepage with cache-busting headers # Redirect back to homepage with cache-busting headers
redirect_response = 'HTTP/1.1 303 See Other\r\n' redirect_response = 'HTTP/1.1 303 See Other\r\n'
redirect_response += 'Location: /\r\n' redirect_response += 'Location: /\r\n'
@@ -578,6 +561,7 @@ class TempWebServer:
redirect_response += 'Expires: 0\r\n' redirect_response += 'Expires: 0\r\n'
redirect_response += '\r\n' redirect_response += '\r\n'
print("DEBUG: Returning redirect to dashboard (with cache-busting)") print("DEBUG: Returning redirect to dashboard (with cache-busting)")
gc.collect()
return redirect_response return redirect_response
except Exception as e: except Exception as e:
@@ -1361,6 +1345,8 @@ document.addEventListener('DOMContentLoaded', function() {{
def _get_schedule_editor_page(self, sensors, ac_monitor, heater_monitor): def _get_schedule_editor_page(self, sensors, ac_monitor, heater_monitor):
"""Generate schedule editor page (no auto-refresh, schedules only).""" """Generate schedule editor page (no auto-refresh, schedules only)."""
# Get current temps (read if not cached) # Get current temps (read if not cached)
import gc # type: ignore
gc.collect()
inside_temp = getattr(sensors.get('inside'), 'last_temp', None) inside_temp = getattr(sensors.get('inside'), 'last_temp', None)
if inside_temp is None: if inside_temp is None:
inside_temps = sensors['inside'].read_all_temps(unit='F') inside_temps = sensors['inside'].read_all_temps(unit='F')
@@ -1400,8 +1386,6 @@ document.addEventListener('DOMContentLoaded', function() {{
# Build schedule inputs # Build schedule inputs
schedule_inputs = "" schedule_inputs = ""
for i, schedule in enumerate(schedules[:4]): for i, schedule in enumerate(schedules[:4]):
print("DEBUG: Building HTML for schedule {}...".format(i))
time_value = schedule.get('time', '') time_value = schedule.get('time', '')
name_value = schedule.get('name', '') name_value = schedule.get('name', '')
heater_value = schedule.get('heater_target', config.get('heater_target')) heater_value = schedule.get('heater_target', config.get('heater_target'))
@@ -1431,8 +1415,6 @@ document.addEventListener('DOMContentLoaded', function() {{
schedule_inputs += "<input type=\"number\" name=\"schedule_" + str(i) + "_ac\" value=\"" + str(ac_value) + "\" step=\"0.5\" min=\"60\" max=\"90\" required oninput=\"schedSync(" + str(i) + ", 'ac')\" onchange=\"schedSync(" + str(i) + ", 'ac')\">\n" schedule_inputs += "<input type=\"number\" name=\"schedule_" + str(i) + "_ac\" value=\"" + str(ac_value) + "\" step=\"0.5\" min=\"60\" max=\"90\" required oninput=\"schedSync(" + str(i) + ", 'ac')\" onchange=\"schedSync(" + str(i) + ", 'ac')\">\n"
schedule_inputs += '</div>\n' schedule_inputs += '</div>\n'
print("DEBUG: HTML generated, length now: {} bytes".format(len(schedule_inputs)))
html = """ html = """
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@@ -1636,7 +1618,8 @@ document.addEventListener('DOMContentLoaded', function() {{
def _get_settings_page(self, sensors, ac_monitor, heater_monitor): def _get_settings_page(self, sensors, ac_monitor, heater_monitor):
"""Generate advanced settings page.""" """Generate advanced settings page."""
config = self._load_config() config = self._load_config()
import gc # type: ignore
gc.collect()
# Get temperatures (read if not cached) # Get temperatures (read if not cached)
inside_temp = getattr(sensors.get('inside'), 'last_temp', None) inside_temp = getattr(sensors.get('inside'), 'last_temp', None)
if inside_temp is None: if inside_temp is None:
@@ -1811,6 +1794,8 @@ document.addEventListener('DOMContentLoaded', function() {{
def _handle_settings_update(self, request, sensors, ac_monitor, heater_monitor, schedule_monitor, config): def _handle_settings_update(self, request, sensors, ac_monitor, heater_monitor, schedule_monitor, config):
"""Handle advanced settings update.""" """Handle advanced settings update."""
import gc # type: ignore
gc.collect()
try: try:
body = request.split('\r\n\r\n')[1] if '\r\n\r\n' in request else '' body = request.split('\r\n\r\n')[1] if '\r\n\r\n' in request else ''
params = {} params = {}
@@ -1877,4 +1862,5 @@ document.addEventListener('DOMContentLoaded', function() {{
redirect_response += 'Content-Length: 0\r\n' redirect_response += 'Content-Length: 0\r\n'
redirect_response += 'Connection: close\r\n' redirect_response += 'Connection: close\r\n'
redirect_response += '\r\n' redirect_response += '\r\n'
gc.collect()
return redirect_response return redirect_response

224
main.py
View File

@@ -22,14 +22,6 @@ 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.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 # NEW: Import scheduler for time-based temp changes
from scripts.memory_check import check_memory_once # Just the function
# ===== NEW: NTP Sync Function (imports locally) ===== # ===== NEW: NTP Sync Function (imports locally) =====
def sync_ntp_time(timezone_offset): def sync_ntp_time(timezone_offset):
""" """
@@ -209,7 +201,7 @@ if wifi and wifi.isconnected():
gc.collect() gc.collect()
ram_free = gc.mem_free() ram_free = gc.mem_free()
print(f"DEBUG: Free RAM before Discord send: {ram_free // 1024} KB") print(f"DEBUG: Free RAM before Discord send: {ram_free // 1024} KB")
mem_ok = ram_free > 100000 mem_ok = ram_free > 95000
if mem_ok: if mem_ok:
ok = discord_webhook.send_discord_message("Pico W online at http://{}".format(ifconfig[0]), debug=False) ok = discord_webhook.send_discord_message("Pico W online at http://{}".format(ifconfig[0]), debug=False)
if ok: if ok:
@@ -224,6 +216,15 @@ if wifi and wifi.isconnected():
pending_discord_message = "Pico W online at http://{}".format(ifconfig[0]) pending_discord_message = "Pico W online at http://{}".format(ifconfig[0])
discord_send_attempts = 1 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()
@@ -360,47 +361,6 @@ 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, config=config),
# 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")
@@ -409,77 +369,119 @@ 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 last_ntp_sync = time.time() # Track when we last synced
# ===== START: Main Loop ===== try:
# Main monitoring loop (runs forever until Ctrl+C) while True:
while True:
try: # ===== START: Main Loop =====
# Try to send pending discord startup message when memory permits # Main monitoring loop (runs forever until Ctrl+C)
if not discord_sent and pending_discord_message and discord_send_attempts < 3: last_monitor_run = {
import gc as _gc # type: ignore "wifi": 0,
_gc.collect() "schedule": 0,
_gc.collect() "ac": 0,
mem_ok = getattr(_gc, 'mem_free', lambda: 0)() > 100000 "heater": 0,
if mem_ok: "inside_temp": 0,
"outside_temp": 0,
}
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: try:
ok = discord_webhook.send_discord_message(pending_discord_message, debug=False) wifi_monitor.run()
if ok: except Exception as e:
print("Discord startup notification sent") print("WiFiMonitor error:", e)
discord_sent = True del wifi_monitor
else: gc.collect()
discord_send_attempts += 1 last_monitor_run["wifi"] = now
if discord_send_attempts >= 3:
print("Discord startup notification failed after retries")
discord_sent = True
except Exception:
discord_send_attempts += 1
if discord_send_attempts >= 3:
discord_sent = True
# Run all monitors (each checks if it's time to run via should_run()) # Schedule monitor every 60 seconds (persistent)
run_monitors(monitors) if now - last_monitor_run["schedule"] >= 60:
try:
schedule_monitor.run()
except Exception as e:
print("ScheduleMonitor error:", e)
last_monitor_run["schedule"] = now
# Web requests # AC monitor every 30 seconds (persistent)
web_server.check_requests(sensors, ac_monitor, heater_monitor, schedule_monitor, config) if now - last_monitor_run["ac"] >= 30:
try:
ac_monitor.run()
except Exception as e:
print("ACMonitor error:", e)
last_monitor_run["ac"] = now
# ===== PERIODIC RE-SYNC (every 24 hours) ===== # Heater monitor every 30 seconds (persistent)
if ntp_synced and (time.time() - last_ntp_sync) > 86400: if now - last_monitor_run["heater"] >= 30:
print("24-hour re-sync due...") try:
if sync_ntp_time(TIMEZONE_OFFSET): heater_monitor.run()
last_ntp_sync = time.time() except Exception as e:
print("Daily NTP re-sync successful") print("HeaterMonitor error:", e)
else: last_monitor_run["heater"] = now
print("Daily NTP re-sync failed (will retry tomorrow)")
# ===== END: PERIODIC RE-SYNC ===== # 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)
# Aggressive GC without frequent console noise
current_time = time.time()
if int(current_time) % 5 == 0:
gc.collect() gc.collect()
# Print memory stats infrequently (every 10 minutes) time.sleep(0.1)
if int(current_time) % 600 == 0: # ===== END: Main Loop =====
print("Memory free: {} KB".format(gc.mem_free() // 1024)) except KeyboardInterrupt:
# ===== END: AGGRESSIVE GC ===== print("\n" + "="*50)
time.sleep(0.1) print("Shutting down gracefully...")
print("="*50)
except KeyboardInterrupt: try:
# Graceful shutdown on Ctrl+C
print("\n\n" + "="*50)
print("Shutting down gracefully...")
print("="*50)
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)
time.sleep(5) # Brief pause before retrying
# ===== END: Main Loop =====