Compare commits

...

8 Commits

5 changed files with 159 additions and 176 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
] ]
} }
``` ```
@@ -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

View File

@@ -55,12 +55,12 @@ def send_discord_message(message, username="Auto Garden Bot", is_alert=False, de
import gc # type: ignore import gc # type: ignore
import time # type: ignore import time # type: ignore
gc.collect(); gc.collect() gc.collect()
# Quick mem check before importing urequests/SSL # Quick mem check before importing urequests/SSL
mem = getattr(gc, "mem_free", lambda: None)() mem = getattr(gc, "mem_free", lambda: None)()
# Require larger headroom based on device testing (adjust if you re-test) # Require larger headroom based on device testing (adjust if you re-test)
if mem is not None and mem < 105000: if mem is not None and mem < 95000:
print("Discord send skipped: ENOMEM ({} bytes free)".format(mem)) print("Discord send skipped: ENOMEM ({} bytes free)".format(mem))
return False return False
@@ -131,6 +131,6 @@ def send_discord_message(message, username="Auto Garden Bot", is_alert=False, de
pass pass
try: try:
import gc # type: ignore import gc # type: ignore
gc.collect(); gc.collect() gc.collect()
except: except:
pass pass

View File

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

View File

@@ -194,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 =====
@@ -266,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 = {}
@@ -280,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
@@ -313,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
@@ -337,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
@@ -362,6 +368,7 @@ class TempWebServer:
return redirect_response return redirect_response
elif mode_action == 'save_schedules': elif mode_action == 'save_schedules':
gc.collect()
# Just fall through to schedule parsing below # Just fall through to schedule parsing below
pass pass
# ===== END: Handle mode actions ===== # ===== END: Handle mode actions =====
@@ -371,12 +378,6 @@ class TempWebServer:
prev_schedules = prev.get('schedules', []) prev_schedules = prev.get('schedules', [])
# ===== START: Handle schedule configuration save ===== # ===== START: Handle schedule configuration save =====
# DEBUG: Print what we received
print("DEBUG: Received POST body parameters:")
for key, value in params.items():
print(" {} = '{}'".format(key, value))
print("DEBUG: Total params received: {}".format(len(params)))
# Parse schedules (4 slots) # Parse schedules (4 slots)
schedules = [] schedules = []
has_any_schedule_data = False has_any_schedule_data = False
@@ -483,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:
@@ -538,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"
@@ -549,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'
@@ -560,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:
@@ -1343,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')
@@ -1411,8 +1415,6 @@ document.addEventListener('DOMContentLoaded', function() {{
schedule_inputs += "<input type=\"number\" name=\"schedule_" + str(i) + "_ac\" value=\"" + str(ac_value) + "\" step=\"0.5\" min=\"60\" max=\"90\" required oninput=\"schedSync(" + str(i) + ", 'ac')\" onchange=\"schedSync(" + str(i) + ", 'ac')\">\n" schedule_inputs += "<input type=\"number\" name=\"schedule_" + str(i) + "_ac\" value=\"" + str(ac_value) + "\" step=\"0.5\" min=\"60\" max=\"90\" required oninput=\"schedSync(" + str(i) + ", 'ac')\" onchange=\"schedSync(" + str(i) + ", 'ac')\">\n"
schedule_inputs += '</div>\n' schedule_inputs += '</div>\n'
print("DEBUG: HTML generated, length now: {} bytes".format(len(schedule_inputs)))
html = """ html = """
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@@ -1616,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:
@@ -1791,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 = {}
@@ -1857,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

38
main.py
View File

@@ -201,7 +201,7 @@ if wifi and wifi.isconnected():
gc.collect() gc.collect()
ram_free = gc.mem_free() ram_free = gc.mem_free()
print(f"DEBUG: Free RAM before Discord send: {ram_free // 1024} KB") print(f"DEBUG: Free RAM before Discord send: {ram_free // 1024} KB")
mem_ok = ram_free > 105000 mem_ok = ram_free > 95000
if mem_ok: if mem_ok:
ok = discord_webhook.send_discord_message("Pico W online at http://{}".format(ifconfig[0]), debug=False) ok = discord_webhook.send_discord_message("Pico W online at http://{}".format(ifconfig[0]), debug=False)
if ok: if ok:
@@ -369,18 +369,21 @@ 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:
last_monitor_run = {
# ===== START: Main Loop =====
# Main monitoring loop (runs forever until Ctrl+C)
last_monitor_run = {
"wifi": 0, "wifi": 0,
"schedule": 0, "schedule": 0,
"ac": 0, "ac": 0,
"heater": 0, "heater": 0,
"inside_temp": 0, "inside_temp": 0,
"outside_temp": 0, "outside_temp": 0,
} }
while True: while True:
now = time.time() now = time.time()
# WiFi monitor every 5 seconds (can be stateless) # WiFi monitor every 5 seconds (can be stateless)
@@ -460,4 +463,25 @@ while True:
gc.collect() gc.collect()
time.sleep(0.1) time.sleep(0.1)
# ===== END: Main Loop ===== # ===== END: Main Loop =====
except KeyboardInterrupt:
print("\n" + "="*50)
print("Shutting down gracefully...")
print("="*50)
try:
print("Turning off AC...")
ac_controller.turn_off()
except Exception as e:
print("AC shutdown error:", e)
try:
print("Turning off heater...")
heater_controller.turn_off()
except Exception as e:
print("Heater shutdown error:", e)
try:
print("Turning off LED...")
led.low()
except Exception as e:
print("LED shutdown error:", e)
print("Shutdown complete!")
print("="*50)