Compare commits

..

34 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
4 changed files with 376 additions and 266 deletions

View File

@@ -146,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
] ]
} }
``` ```
@@ -207,9 +179,8 @@ All settings can be changed via the web interface and persist through reboots.
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
@@ -228,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
@@ -447,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,7 @@
# Minimal module-level state (only what we need) # Minimal module-level state (only what we need)
_CONFIG = {"discord_webhook_url": None, "discord_alert_webhook_url": None} _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): def set_config(cfg: dict):
"""Initialize module with minimal values from loaded config (call from main).""" """Initialize module with minimal values from loaded config (call from main)."""
@@ -25,39 +27,79 @@ 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. Import urequests locally to avoid occupying RAM when idle. 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. Returns True on success, False otherwise.
""" """
global _NEXT_ALLOWED_SEND_TS
resp = None resp = None
url = _get_webhook_url(is_alert=is_alert) url = _get_webhook_url(is_alert=is_alert)
if not url: if not url:
if debug: print("DBG: no webhook URL configured")
return False return False
# Respect cooldown if we recently saw ENOMEM
try: try:
# local import to save RAM import time # type: ignore
import urequests as requests # type: ignore now = time.time()
import gc # type: ignore 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
try:
# Lightweight local imports and GC
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('\'"') url = str(url).strip().strip('\'"')
content = _escape_json_str(message) content = _escape_json_str(str(message)[:140])
user = _escape_json_str(username) 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; charset=utf-8"} headers = {"Content-Type": "application/json"}
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))
success = bool(status and 200 <= status < 300) return bool(status and 200 <= status < 300)
if not success:
# optional: print status for debugging, but avoid spamming
print("Discord webhook failed, status:", status)
return success
except Exception as e: except Exception as e:
# avoid raising to prevent crashing monitors; print minimal info print("Discord send failed:", e)
print("Discord webhook exception:", e) 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 return False
finally: finally:
@@ -66,12 +108,29 @@ def send_discord_message(message, username="Auto Garden Bot", is_alert=False):
resp.close() resp.close()
except: except:
pass pass
# free large objects and modules, then force GC # remove local refs and unload heavy modules to free peak RAM (urequests, ussl/ssl)
try: try:
del resp 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: except:
pass pass
try: 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() gc.collect()
except: except:
pass pass

View File

@@ -50,7 +50,7 @@ class TempWebServer:
content_length = int(line.split(':')[1].strip()) content_length = int(line.split(':')[1].strip())
break break
# If POST request with body, read remaining data # If POST request with body, read remaining data
if 'POST' in request and content_length > 0: if 'POST' in request and content_length > 0:
# Check how much body we already have # Check how much body we already have
header_end = request.find('\r\n\r\n') + 4 header_end = request.find('\r\n\r\n') + 4
@@ -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,20 +71,9 @@ 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)
@@ -109,16 +92,15 @@ class TempWebServer:
# Send headers # Send headers
conn.sendall(b'HTTP/1.1 200 OK\r\n') conn.sendall(b'HTTP/1.1 200 OK\r\n')
conn.sendall(b'Content-Type: text/html; charset=utf-8\r\n') conn.sendall(b'Content-Type: text/html; charset=utf-8\r\n')
conn.send('Content-Length: {}\r\n'.format(len(response_bytes))) conn.sendall('Content-Length: {}\r\n'.format(len(response_bytes)).encode('utf-8'))
conn.send('Connection: close\r\n') conn.sendall(b'Connection: close\r\n')
conn.send('\r\n') conn.sendall(b'\r\n')
# Send body in chunks (MicroPython has small socket buffer) # Send body in chunks (MicroPython has small socket buffer)
chunk_size = 1024 # Send 1KB at a time chunk_size = 1024 # Send 1KB at a time
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.send(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)))
@@ -128,16 +110,16 @@ class TempWebServer:
response = self._get_settings_page(sensors, ac_monitor, heater_monitor) response = self._get_settings_page(sensors, ac_monitor, heater_monitor)
response_bytes = response.encode('utf-8') response_bytes = response.encode('utf-8')
conn.send('HTTP/1.1 200 OK\r\n') conn.sendall(b'HTTP/1.1 200 OK\r\n')
conn.send('Content-Type: text/html; charset=utf-8\r\n') conn.sendall(b'Content-Type: text/html; charset=utf-8\r\n')
conn.send('Content-Length: {}\r\n'.format(len(response_bytes))) conn.sendall('Content-Length: {}\r\n'.format(len(response_bytes)).encode('utf-8'))
conn.send('Connection: close\r\n') conn.sendall(b'Connection: close\r\n')
conn.send('\r\n') conn.sendall(b'\r\n')
chunk_size = 1024 chunk_size = 1024
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.send(chunk) conn.sendall(chunk)
conn.close() conn.close()
print("DEBUG: Settings page sent successfully ({} bytes total)".format(len(response_bytes))) print("DEBUG: Settings page sent successfully ({} bytes total)".format(len(response_bytes)))
@@ -161,10 +143,27 @@ class TempWebServer:
print("DEBUG: Redirect sent, connection closed") print("DEBUG: Redirect sent, connection closed")
return return
elif 'GET /sched.js' in request:
js = self._build_sched_js() # bytes
conn.sendall(b'HTTP/1.1 200 OK\r\n')
conn.sendall(b'Content-Type: application/javascript; charset=utf-8\r\n')
conn.sendall('Content-Length: {}\r\n'.format(len(js)).encode('utf-8'))
conn.sendall(b'Cache-Control: max-age=300\r\n')
conn.sendall(b'Connection: close\r\n')
conn.sendall(b'\r\n')
conn.sendall(js)
conn.close()
return
elif 'GET /ping' in request: elif 'GET /ping' in request:
# Quick health check endpoint (no processing) # Quick health check endpoint (no processing)
conn.send('HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n') body = b'OK'
conn.sendall(b'OK') conn.sendall(b'HTTP/1.1 200 OK\r\n')
conn.sendall(b'Content-Type: text/plain\r\n')
conn.sendall(b'Content-Length: 2\r\n')
conn.sendall(b'Connection: close\r\n')
conn.sendall(b'\r\n')
conn.sendall(body)
conn.close() conn.close()
return return
@@ -175,7 +174,7 @@ class TempWebServer:
response = self._get_status_page(sensors, ac_monitor, heater_monitor, schedule_monitor) response = self._get_status_page(sensors, ac_monitor, heater_monitor, schedule_monitor)
# ===== START: Send response with proper HTTP headers ===== # ===== START: Send response with proper HTTP headers =====
print("DEBUG: Sending response ({} bytes)".format(len(response))) print("DEBUG: Sending response ({} bytes)".format(len(response.encode('utf-8'))))
try: try:
# Check if response already has HTTP headers (like redirects) # Check if response already has HTTP headers (like redirects)
if response.startswith('HTTP/1.1'): if response.startswith('HTTP/1.1'):
@@ -183,11 +182,11 @@ class TempWebServer:
conn.sendall(response.encode('utf-8')) conn.sendall(response.encode('utf-8'))
else: else:
# HTML response needs headers added first # HTML response needs headers added first
conn.send(b'HTTP/1.1 200 OK\r\n') conn.sendall(b'HTTP/1.1 200 OK\r\n')
conn.send(b'Content-Type: text/html; charset=utf-8\r\n') conn.sendall(b'Content-Type: text/html; charset=utf-8\r\n')
conn.send('Content-Length: {}\r\n'.format(len(response.encode('utf-8'))).encode('utf-8')) conn.sendall('Content-Length: {}\r\n'.format(len(response.encode('utf-8'))).encode('utf-8'))
conn.send(b'Connection: close\r\n') conn.sendall(b'Connection: close\r\n')
conn.send(b'\r\n') # Blank line separates headers from body conn.sendall(b'\r\n') # Blank line separates headers from body
conn.sendall(response.encode('utf-8')) conn.sendall(response.encode('utf-8'))
print("DEBUG: Response sent successfully") print("DEBUG: Response sent successfully")
@@ -195,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 =====
@@ -205,6 +206,23 @@ class TempWebServer:
import sys import sys
sys.print_exception(e) sys.print_exception(e)
def _build_sched_js(self):
# Keep this as bytes; no .format() so no brace escaping and less RAM churn
return (b"// schedule page sync\n"
b"window.schedSync=function(i,w){var h=document.querySelector('input[name=\"schedule_'+i+'_heater\"]');"
b"var a=document.querySelector('input[name=\"schedule_'+i+'_ac\"]');"
b"var l=document.getElementById('schedule_'+i+'_last_changed');"
b"if(!h||!a)return;var hv=parseFloat(h.value),av=parseFloat(a.value);"
b"if(w==='heater'){if(!isNaN(hv)&&!isNaN(av)&&hv>av){a.value=hv;} if(l)l.value='heater';}"
b"else{if(!isNaN(hv)&&!isNaN(av)&&av<hv){h.value=av;} if(l)l.value='ac';}};"
b"document.addEventListener('DOMContentLoaded',function(){var f=document.querySelector('form[action=\"/schedule\"]');"
b"if(!f)return;f.addEventListener('submit',function(){for(var i=0;i<4;i++){"
b"var h=document.querySelector('input[name=\"schedule_'+i+'_heater\"]');"
b"var a=document.querySelector('input[name=\"schedule_'+i+'_ac\"]');"
b"var l=document.getElementById('schedule_'+i+'_last_changed');"
b"if(!h||!a)continue;var hv=parseFloat(h.value),av=parseFloat(a.value);"
b"if(isNaN(hv)||isNaN(av))continue;if(hv>av){if(l&&l.value==='ac'){h.value=av;}else{a.value=hv;}}}});});")
def _save_config_to_file(self, config): def _save_config_to_file(self, config):
"""Save configuration to config.json file (atomic write).""" """Save configuration to config.json file (atomic write)."""
try: try:
@@ -250,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 = {}
@@ -264,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
@@ -297,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
@@ -321,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
@@ -346,17 +368,16 @@ 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 =====
# Load previous schedules to compute deltas
prev = self._load_config()
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
@@ -427,11 +448,34 @@ class TempWebServer:
"Schedule {}: Temperature values must be numbers".format(i+1), "Schedule {}: Temperature values must be numbers".format(i+1),
sensors, ac_monitor, heater_monitor sensors, ac_monitor, heater_monitor
) )
# Auto-sync both ways # Sync using direction of change (no dependency on last_changed)
if heater_target > ac_target: prev_h = None
ac_target = heater_target prev_a = None
elif ac_target < heater_target: if i < len(prev_schedules):
heater_target = ac_target try:
prev_h = float(prev_schedules[i].get('heater_target', heater_target))
except:
prev_h = None
try:
prev_a = float(prev_schedules[i].get('ac_target', ac_target))
except:
prev_a = None
delta_h = (heater_target - prev_h) if prev_h is not None else None
delta_a = (ac_target - prev_a) if prev_a is not None else None
if ac_target < heater_target:
# AC moved down -> lower heater
if delta_a is not None and delta_a < 0 and (delta_h is None or abs(delta_h) < 1e-9):
heater_target = ac_target
# Heater moved up -> raise AC
elif delta_h is not None and delta_h > 0 and (delta_a is None or abs(delta_a) < 1e-9):
ac_target = heater_target
else:
# Fallback preference: if AC decreased more, lower heater; else raise AC
if delta_a is not None and delta_h is not None and abs(delta_a) > abs(delta_h):
heater_target = ac_target
else:
ac_target = heater_target
# Create schedule entry # Create schedule entry
schedule = { schedule = {
'time': schedule_time, 'time': schedule_time,
@@ -440,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:
@@ -495,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"
@@ -506,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'
@@ -517,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:
@@ -554,13 +599,28 @@ class TempWebServer:
new_heater_target = params.get('heater_target', config.get('heater_target', 80.0)) new_heater_target = params.get('heater_target', config.get('heater_target', 80.0))
new_ac_target = params.get('ac_target', config.get('ac_target', 77.0)) new_ac_target = params.get('ac_target', config.get('ac_target', 77.0))
# Auto-sync both ways # Use previous values to detect direction of change
if new_heater_target > new_ac_target: old_heater = float(config.get('heater_target', new_heater_target))
new_ac_target = new_heater_target old_ac = float(config.get('ac_target', new_ac_target))
params['ac_target'] = new_ac_target
elif new_ac_target < new_heater_target: # If AC is below heater, sync based on the field that moved
new_heater_target = new_ac_target if new_ac_target < new_heater_target:
params['heater_target'] = new_heater_target # AC moved down: lower heater to AC
if new_ac_target < old_ac and new_heater_target == old_heater:
new_heater_target = new_ac_target
# Heater moved up: raise AC to heater
elif new_heater_target > old_heater and new_ac_target == old_ac:
new_ac_target = new_heater_target
else:
# Fallback: prefer AC drop rule, else heater raise
if new_ac_target < old_ac:
new_heater_target = new_ac_target
else:
new_ac_target = new_heater_target
# Reflect adjusted values back to params
params['ac_target'] = new_ac_target
params['heater_target'] = new_heater_target
# ===== END: Validate Heat <= AC ===== # ===== END: Validate Heat <= AC =====
# ===== START: Update AC Settings ===== # ===== START: Update AC Settings =====
@@ -643,7 +703,11 @@ class TempWebServer:
# ===== FORCE GARBAGE COLLECTION BEFORE BIG ALLOCATION ===== # ===== FORCE GARBAGE COLLECTION BEFORE BIG ALLOCATION =====
import gc # type: ignore import gc # type: ignore
gc.collect() gc.collect()
print("DEBUG: Memory freed, {} bytes available".format(gc.mem_free())) try:
mf = gc.mem_free() # type: ignore
print("DEBUG: Memory freed, {} bytes available".format(mf))
except Exception:
print("DEBUG: Memory collected")
# ===== END GARBAGE COLLECTION ===== # ===== END GARBAGE COLLECTION =====
try: try:
@@ -1281,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')
@@ -1304,8 +1370,8 @@ document.addEventListener('DOMContentLoaded', function() {{
schedules.append({ schedules.append({
'time': '', 'time': '',
'name': '', 'name': '',
'ac_target': config.get('ac_target', 75.0), # ✅ Uses 78°F from config 'ac_target': config.get('ac_target', 75.0), # default if not set
'heater_target': config.get('heater_target', 72.0) # ✅ Uses 70°F from config 'heater_target': config.get('heater_target', 72.0) # default if not set
}) })
# ===== DEBUG: Verify we have 4 schedules ===== # ===== DEBUG: Verify we have 4 schedules =====
@@ -1320,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'))
@@ -1336,20 +1400,20 @@ document.addEventListener('DOMContentLoaded', function() {{
# Hidden input to mark this schedule exists (always sent) # Hidden input to mark this schedule exists (always sent)
schedule_inputs += '<input type="hidden" name="schedule_' + str(i) + '_exists" value="1">\n' schedule_inputs += '<input type="hidden" name="schedule_' + str(i) + '_exists" value="1">\n'
# Hidden marker to record which input was changed last for this row
schedule_inputs += '<input type="hidden" name="schedule_' + str(i) + '_last_changed" id="schedule_' + str(i) + '_last_changed" value="">\n'
schedule_inputs += '<label>Time</label>\n' schedule_inputs += '<label>Time</label>\n'
schedule_inputs += '<input type="time" name="schedule_' + str(i) + '_time" value="' + str(time_value) + '">\n' schedule_inputs += '<input type="time" name="schedule_' + str(i) + '_time" value="' + str(time_value) + '">\n'
schedule_inputs += '<label>Name</label>\n' schedule_inputs += '<label>Name</label>\n'
schedule_inputs += '<input type="text" name="schedule_' + str(i) + '_name" value="' + str(name_value) + '" placeholder="Schedule ' + str(i+1) + '">\n' schedule_inputs += '<input type="text" name="schedule_' + str(i) + '_name" value="' + str(name_value) + '" placeholder="Schedule ' + str(i+1) + '">\n'
schedule_inputs += '<label>Heater (°F)</label>\n' schedule_inputs += '<label>Heater (°F)</label>\n'
# Add required attribute to force validation # Add required attribute to force validation
schedule_inputs += '<input type="number" name="schedule_' + str(i) + '_heater" value="' + str(heater_value) + '" step="0.5" min="60" max="85" required>\n' schedule_inputs += "<input type=\"number\" name=\"schedule_" + str(i) + "_heater\" value=\"" + str(heater_value) + "\" step=\"0.5\" min=\"60\" max=\"85\" required oninput=\"schedSync(" + str(i) + ", 'heater')\" onchange=\"schedSync(" + str(i) + ", 'heater')\">\n"
schedule_inputs += '<label>AC (°F)</label>\n' schedule_inputs += '<label>AC (°F)</label>\n'
# Add required attribute to force validation # Add required attribute to force validation
schedule_inputs += '<input type="number" name="schedule_' + str(i) + '_ac" value="' + str(ac_value) + '" step="0.5" min="60" max="90" required>\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>
@@ -1425,6 +1489,7 @@ document.addEventListener('DOMContentLoaded', function() {{
}} }}
.btn:hover {{ transform: translateY(-2px); }} .btn:hover {{ transform: translateY(-2px); }}
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
@@ -1461,34 +1526,7 @@ document.addEventListener('DOMContentLoaded', function() {{
To change modes (Automatic/Hold), return to the dashboard To change modes (Automatic/Hold), return to the dashboard
</div> </div>
</div> </div>
<script> <script defer src="/sched.js"></script>
document.addEventListener('DOMContentLoaded', function() {{
for (var i = 0; i < 4; i++) {{
var heaterInput = document.querySelector('input[name="schedule_' + i + '_heater"]');
var acInput = document.querySelector('input[name="schedule_' + i + '_ac"]');
if (heaterInput && acInput) {{
heaterInput.addEventListener('input', function() {{
var idx = this.name.match(/\\d+/)[0];
var acInput = document.querySelector('input[name="schedule_' + idx + '_ac"]');
var heaterVal = parseFloat(this.value);
var acVal = parseFloat(acInput.value);
if (!isNaN(heaterVal) && heaterVal > acVal) {{
acInput.value = heaterVal;
}}
}});
acInput.addEventListener('input', function() {{
var idx = this.name.match(/\\d+/)[0];
var heaterInput = document.querySelector('input[name="schedule_' + idx + '_heater"]');
var heaterVal = parseFloat(heaterInput.value);
var acVal = parseFloat(this.value);
if (!isNaN(acVal) && acVal < heaterVal) {{
heaterInput.value = acVal;
}}
}});
}}
}}
}});
</script>
</body> </body>
</html> </html>
""".format( """.format(
@@ -1580,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:
@@ -1755,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 = {}
@@ -1821,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

238
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):
""" """
@@ -144,6 +136,11 @@ 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 import scripts.discord_webhook as discord_webhook
@@ -200,15 +197,33 @@ if wifi and wifi.isconnected():
print(f"Web Interface: http://{ifconfig[0]}") print(f"Web Interface: http://{ifconfig[0]}")
print("="*50 + "\n") print("="*50 + "\n")
# Send startup notification to Discord (with timeout, non-blocking) # Try sending Discord webhook NOW, before creating other objects
try: gc.collect()
success = discord_webhook.send_discord_message(f"Pico W online at http://{ifconfig[0]}") ram_free = gc.mem_free()
if success: print(f"DEBUG: Free RAM before Discord send: {ram_free // 1024} KB")
mem_ok = ram_free > 95000
if mem_ok:
ok = discord_webhook.send_discord_message("Pico W online at http://{}".format(ifconfig[0]), debug=False)
if ok:
print("Discord startup notification sent") print("Discord startup notification sent")
discord_sent = True
else: else:
print("Discord startup notification failed (continuing anyway)") print("Discord startup notification failed")
except Exception as e: pending_discord_message = "Pico W online at http://{}".format(ifconfig[0])
print("Discord notification error: {}".format(e)) discord_send_attempts = 1
else:
print("Not enough memory for Discord startup notification")
pending_discord_message = "Pico W online at http://{}".format(ifconfig[0])
discord_send_attempts = 1
# ===== Moved to later so discord could fire off startup message hopefully =====
from scripts.monitors import TemperatureMonitor, WiFiMonitor, ACMonitor, HeaterMonitor, run_monitors
from scripts.temperature_sensor import TemperatureSensor
from scripts.air_conditioning import ACController
from scripts.heating import HeaterController
from scripts.web_server import TempWebServer
from scripts.scheduler import ScheduleMonitor
from scripts.memory_check import check_memory_once
# Start web server early so page can load even if time sync is slow # Start web server early so page can load even if time sync is slow
web_server = TempWebServer(port=80) web_server = TempWebServer(port=80)
@@ -233,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 = {
@@ -344,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")
@@ -393,55 +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 =====
# Run all monitors (each checks if it's time to run via should_run()) # Main monitoring loop (runs forever until Ctrl+C)
run_monitors(monitors) last_monitor_run = {
"wifi": 0,
# Web requests "schedule": 0,
web_server.check_requests(sensors, ac_monitor, heater_monitor, schedule_monitor, config) "ac": 0,
"heater": 0,
# ===== PERIODIC RE-SYNC (every 24 hours) ===== "inside_temp": 0,
if ntp_synced and (time.time() - last_ntp_sync) > 86400: "outside_temp": 0,
print("24-hour re-sync due...") }
if sync_ntp_time(TIMEZONE_OFFSET):
last_ntp_sync = time.time() while True:
print("Daily NTP re-sync successful") now = time.time()
else:
print("Daily NTP re-sync failed (will retry tomorrow)") # WiFi monitor every 5 seconds (can be stateless)
# ===== END: PERIODIC RE-SYNC ===== if now - last_monitor_run["wifi"] >= 5:
from scripts.monitors import WiFiMonitor
wifi_monitor = WiFiMonitor(wifi, led, interval=5, reconnect_cooldown=60, config=config)
try:
wifi_monitor.run()
except Exception as e:
print("WiFiMonitor error:", e)
del wifi_monitor
gc.collect()
last_monitor_run["wifi"] = now
# Schedule monitor every 60 seconds (persistent)
if now - last_monitor_run["schedule"] >= 60:
try:
schedule_monitor.run()
except Exception as e:
print("ScheduleMonitor error:", e)
last_monitor_run["schedule"] = now
# AC monitor every 30 seconds (persistent)
if now - last_monitor_run["ac"] >= 30:
try:
ac_monitor.run()
except Exception as e:
print("ACMonitor error:", e)
last_monitor_run["ac"] = now
# Heater monitor every 30 seconds (persistent)
if now - last_monitor_run["heater"] >= 30:
try:
heater_monitor.run()
except Exception as e:
print("HeaterMonitor error:", e)
last_monitor_run["heater"] = now
# Inside temperature monitor every 10 seconds (can be stateless)
if now - last_monitor_run["inside_temp"] >= 10:
from scripts.monitors import TemperatureMonitor
inside_monitor = TemperatureMonitor(
sensor=sensors['inside'],
label=SENSOR_CONFIG['inside']['label'],
check_interval=10,
report_interval=30,
alert_high=SENSOR_CONFIG['inside']['alert_high'],
alert_low=SENSOR_CONFIG['inside']['alert_low'],
log_file="/temp_logs.csv",
send_alerts_to_separate_channel=True
)
inside_monitor.run()
del inside_monitor
gc.collect()
last_monitor_run["inside_temp"] = now
# Outside temperature monitor every 10 seconds (can be stateless)
if now - last_monitor_run["outside_temp"] >= 10:
from scripts.monitors import TemperatureMonitor
outside_monitor = TemperatureMonitor(
sensor=sensors['outside'],
label=SENSOR_CONFIG['outside']['label'],
check_interval=10,
report_interval=30,
alert_high=SENSOR_CONFIG['outside']['alert_high'],
alert_low=SENSOR_CONFIG['outside']['alert_low'],
log_file="/temp_logs.csv",
send_alerts_to_separate_channel=False
)
outside_monitor.run()
del outside_monitor
gc.collect()
last_monitor_run["outside_temp"] = now
# Web requests (keep web server loaded if needed)
web_server.check_requests(sensors, ac_monitor, heater_monitor, schedule_monitor, config)
# ===== ADD THIS: AGGRESSIVE GARBAGE COLLECTION =====
current_time = time.time()
if int(current_time) % 5 == 0: # Every 5 seconds
gc.collect() gc.collect()
# Optional: Print memory stats occasionally time.sleep(0.1)
if int(current_time) % 60 == 0: # Every minute # ===== 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 =====