Compare commits
34 Commits
eff69cfe52
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e90e56c3f8 | |||
| 5cfa36e558 | |||
| c8102e62ee | |||
| d76b11430c | |||
| cb274545a3 | |||
| 6cd1349633 | |||
| bcecf2a81a | |||
| 621a48f011 | |||
| ce816af9e7 | |||
| 519cb25038 | |||
| f81d89980b | |||
| 7fc7661dad | |||
| 3b7982a3a3 | |||
| 697f0bf31e | |||
| b632a76d5a | |||
| d670067b89 | |||
| ac860207d9 | |||
| 03b26b5339 | |||
| 5a8d14eb4d | |||
| 79445bf879 | |||
| 4400fb5a74 | |||
| c6f46e097b | |||
| d2c0f68488 | |||
| 13e3a56fa6 | |||
| efea4a1384 | |||
| 73b5a5aefe | |||
| 03766d6b09 | |||
| e5f9331d30 | |||
| 6128e585b8 | |||
| 81174b78e4 | |||
| 70cc2cad81 | |||
| 6bc7b1da93 | |||
| eceee9c88d | |||
| 72eb3c2acf |
67
README.md
67
README.md
@@ -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
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -209,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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 =====
|
||||||
|
|
||||||
# ===== START: Handle schedule configuration save =====
|
# Load previous schedules to compute deltas
|
||||||
# DEBUG: Print what we received
|
prev = self._load_config()
|
||||||
print("DEBUG: Received POST body parameters:")
|
prev_schedules = prev.get('schedules', [])
|
||||||
for key, value in params.items():
|
|
||||||
print(" {} = '{}'".format(key, value))
|
|
||||||
print("DEBUG: Total params received: {}".format(len(params)))
|
|
||||||
|
|
||||||
|
# ===== START: Handle schedule configuration save =====
|
||||||
# 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):
|
||||||
|
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_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,12 +599,27 @@ 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
|
||||||
|
if new_ac_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
|
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
|
params['heater_target'] = new_heater_target
|
||||||
# ===== END: Validate Heat <= AC =====
|
# ===== END: Validate Heat <= AC =====
|
||||||
|
|
||||||
@@ -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,6 +1400,8 @@ 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'
|
||||||
@@ -1343,14 +1409,12 @@ document.addEventListener('DOMContentLoaded', function() {{
|
|||||||
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>
|
||||||
<html>
|
<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
|
||||||
222
main.py
222
main.py
@@ -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
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
|
||||||
# ===== START: Main Loop =====
|
# ===== START: Main Loop =====
|
||||||
# Main monitoring loop (runs forever until Ctrl+C)
|
# Main monitoring loop (runs forever until Ctrl+C)
|
||||||
while True:
|
last_monitor_run = {
|
||||||
try:
|
"wifi": 0,
|
||||||
# Run all monitors (each checks if it's time to run via should_run())
|
"schedule": 0,
|
||||||
run_monitors(monitors)
|
"ac": 0,
|
||||||
|
"heater": 0,
|
||||||
|
"inside_temp": 0,
|
||||||
|
"outside_temp": 0,
|
||||||
|
}
|
||||||
|
|
||||||
# Web requests
|
while True:
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# WiFi monitor every 5 seconds (can be stateless)
|
||||||
|
if now - last_monitor_run["wifi"] >= 5:
|
||||||
|
from scripts.monitors import WiFiMonitor
|
||||||
|
wifi_monitor = WiFiMonitor(wifi, led, interval=5, reconnect_cooldown=60, config=config)
|
||||||
|
try:
|
||||||
|
wifi_monitor.run()
|
||||||
|
except Exception as e:
|
||||||
|
print("WiFiMonitor error:", e)
|
||||||
|
del wifi_monitor
|
||||||
|
gc.collect()
|
||||||
|
last_monitor_run["wifi"] = now
|
||||||
|
|
||||||
|
# Schedule monitor every 60 seconds (persistent)
|
||||||
|
if now - last_monitor_run["schedule"] >= 60:
|
||||||
|
try:
|
||||||
|
schedule_monitor.run()
|
||||||
|
except Exception as e:
|
||||||
|
print("ScheduleMonitor error:", e)
|
||||||
|
last_monitor_run["schedule"] = now
|
||||||
|
|
||||||
|
# AC monitor every 30 seconds (persistent)
|
||||||
|
if now - last_monitor_run["ac"] >= 30:
|
||||||
|
try:
|
||||||
|
ac_monitor.run()
|
||||||
|
except Exception as e:
|
||||||
|
print("ACMonitor error:", e)
|
||||||
|
last_monitor_run["ac"] = now
|
||||||
|
|
||||||
|
# Heater monitor every 30 seconds (persistent)
|
||||||
|
if now - last_monitor_run["heater"] >= 30:
|
||||||
|
try:
|
||||||
|
heater_monitor.run()
|
||||||
|
except Exception as e:
|
||||||
|
print("HeaterMonitor error:", e)
|
||||||
|
last_monitor_run["heater"] = now
|
||||||
|
|
||||||
|
# Inside temperature monitor every 10 seconds (can be stateless)
|
||||||
|
if now - last_monitor_run["inside_temp"] >= 10:
|
||||||
|
from scripts.monitors import TemperatureMonitor
|
||||||
|
inside_monitor = TemperatureMonitor(
|
||||||
|
sensor=sensors['inside'],
|
||||||
|
label=SENSOR_CONFIG['inside']['label'],
|
||||||
|
check_interval=10,
|
||||||
|
report_interval=30,
|
||||||
|
alert_high=SENSOR_CONFIG['inside']['alert_high'],
|
||||||
|
alert_low=SENSOR_CONFIG['inside']['alert_low'],
|
||||||
|
log_file="/temp_logs.csv",
|
||||||
|
send_alerts_to_separate_channel=True
|
||||||
|
)
|
||||||
|
inside_monitor.run()
|
||||||
|
del inside_monitor
|
||||||
|
gc.collect()
|
||||||
|
last_monitor_run["inside_temp"] = now
|
||||||
|
|
||||||
|
# Outside temperature monitor every 10 seconds (can be stateless)
|
||||||
|
if now - last_monitor_run["outside_temp"] >= 10:
|
||||||
|
from scripts.monitors import TemperatureMonitor
|
||||||
|
outside_monitor = TemperatureMonitor(
|
||||||
|
sensor=sensors['outside'],
|
||||||
|
label=SENSOR_CONFIG['outside']['label'],
|
||||||
|
check_interval=10,
|
||||||
|
report_interval=30,
|
||||||
|
alert_high=SENSOR_CONFIG['outside']['alert_high'],
|
||||||
|
alert_low=SENSOR_CONFIG['outside']['alert_low'],
|
||||||
|
log_file="/temp_logs.csv",
|
||||||
|
send_alerts_to_separate_channel=False
|
||||||
|
)
|
||||||
|
outside_monitor.run()
|
||||||
|
del outside_monitor
|
||||||
|
gc.collect()
|
||||||
|
last_monitor_run["outside_temp"] = now
|
||||||
|
|
||||||
|
# Web requests (keep web server loaded if needed)
|
||||||
web_server.check_requests(sensors, ac_monitor, heater_monitor, schedule_monitor, config)
|
web_server.check_requests(sensors, ac_monitor, heater_monitor, schedule_monitor, config)
|
||||||
|
|
||||||
# ===== PERIODIC RE-SYNC (every 24 hours) =====
|
|
||||||
if ntp_synced and (time.time() - last_ntp_sync) > 86400:
|
|
||||||
print("24-hour re-sync due...")
|
|
||||||
if sync_ntp_time(TIMEZONE_OFFSET):
|
|
||||||
last_ntp_sync = time.time()
|
|
||||||
print("Daily NTP re-sync successful")
|
|
||||||
else:
|
|
||||||
print("Daily NTP re-sync failed (will retry tomorrow)")
|
|
||||||
# ===== END: PERIODIC RE-SYNC =====
|
|
||||||
|
|
||||||
# ===== 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
|
|
||||||
if int(current_time) % 60 == 0: # Every minute
|
|
||||||
print("💾 Memory free: {} KB".format(gc.mem_free() // 1024))
|
|
||||||
# ===== END: AGGRESSIVE GC =====
|
|
||||||
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)
|
|
||||||
time.sleep(5) # Brief pause before retrying
|
|
||||||
# ===== END: Main Loop =====
|
|
||||||
Reference in New Issue
Block a user