Compare commits

...

116 Commits

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

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

didn't rename it that way but have done quite a bit already want to save working point
2025-11-09 10:48:49 -05:00
9da21f7c89 feat: Implement temporary hold management with config integration and improved notifications
fixes #19
2025-11-09 10:11:00 -05:00
b6aae121bb feat: Add dynamic config reload and timezone offset handling in web server
Fixes #18
Already Fixed #13
after long hours of tedious back and forth coding to figure out wtf was happening jesus
2025-11-09 09:24:21 -05:00
24b53b9446 Removed stuff from the try block so it doesn't loop. just initialize in the beginning 2025-11-09 01:18:16 -05:00
749eb956a5 feat: Enhance request handling with improved header parsing and error management
Fixes #16
Fixes #15
Fixes #14
Fixes #6
2025-11-09 00:54:07 -05:00
5ce7cd43a4 feat: Improve HTTP response handling in web server with proper headers 2025-11-08 19:28:11 -05:00
bb46a69eba feat: Update Discord message function with improved comments and error handling 2025-11-08 19:28:06 -05:00
b018b427f6 feat: Enhance NTP synchronization with timeout and error handling 2025-11-08 19:28:00 -05:00
99d92a6e90 feat: Add type ignore comments for imports in multiple scripts 2025-11-08 18:38:11 -05:00
b712c19740 feat: Refactor request handling to include config parameter and improve error logging 2025-11-08 18:38:01 -05:00
9c7ca86d86 feat: Improve schedule parsing and validation in web server
Fixes #12
2025-11-08 17:51:19 -05:00
6ac3407cc2 feat: Revise README for clarity and detail on features, setup, and configuration 2025-11-08 17:35:48 -05:00
68b0351e9d feat: Update button labels for temporary and permanent hold actions in the web interface
Fixes #11
2025-11-08 17:24:26 -05:00
74b0d80717 feat: Enhance status page with temporary hold countdown timer and update method signatures 2025-11-08 17:11:46 -05:00
baa6382fba feat: Implement temporary hold expiration logic with notifications 2025-11-08 17:11:38 -05:00
b9b67f685a feat: Add temp_hold_duration to configuration for better control 2025-11-08 17:11:24 -05:00
64a5d0ae7e feat: Reset hold modes to automatic on boot and save configuration 2025-11-08 16:39:49 -05:00
3e926f997b feat: Update scheduling logic to disable only on manual temperature changes and send notifications accordingly 2025-11-08 16:36:05 -05:00
299a0abbc9 feat: Enhance main loop with error handling and graceful shutdown 2025-11-08 16:15:22 -05:00
988bec521f fix: Update watchdog timer configuration and enable garbage collection 2025-11-08 16:12:59 -05:00
b93809946a feat: Add caching for last temperature reading in TemperatureMonitor 2025-11-08 15:48:00 -05:00
f4c9e20836 feat: Implement watchdog timer and enhance NTP time synchronization with retry logic 2025-11-08 15:47:55 -05:00
9fda192f0b Bug: Enhance schedule handling with improved request processing and validation
Sometimes page loads, sometimes doesn't trying to implement something to figure out why the page isn't loading. In python everything loads in certain order so if something hangs, it could prevent something else from running. (Like web page from loading :()
2025-11-08 15:47:39 -05:00
b8336f82c8 fix: Update response handling to redirect to homepage after mode actions 2025-11-06 18:29:57 -05:00
a0fe76abc4 fix: Correct order of temperature display and adjust HTML structure for schedule form 2025-11-06 18:26:15 -05:00
050841dd78 refactor: Remove MemoryMonitor class and related methods from memory_check.py 2025-11-06 18:12:44 -05:00
39a4952426 refactor: Remove MemoryMonitor import and instance from scheduler setup 2025-11-06 18:12:37 -05:00
81137a4c5e feat: Add validation for heater and AC target temperatures in schedule configuration 2025-11-06 18:01:52 -05:00
954cd144b9 feat: Add MemoryMonitor class for Pico W memory usage tracking and reporting 2025-11-06 18:01:46 -05:00
1c9f1d731e feat: Enhance configuration loading with default schedules and add memory check on startup 2025-11-06 18:01:36 -05:00
37be801270 fix: Update heater target temperatures in configuration for consistency 2025-11-06 18:01:26 -05:00
52562bd8e6 feat: Implement HOLD mode functionality with temporary and permanent options 2025-11-06 17:26:53 -05:00
f8269f8f9d feat: Add debug logging and fix schedule display encoding
- Log monitor values after settings update for debugging
- Decode URL-encoded time values in schedule display (%3A → :)
- Add detailed comments explaining settings flow
2025-11-06 16:34:35 -05:00
9e2674187c Add HOLD mode banner to status page when schedules are disabled 2025-11-05 23:36:42 -05:00
6482965edc Add garbage collection to main loop for memory management 2025-11-05 23:36:16 -05:00
5d162f3971 Enhance configuration loading and WiFi setup with detailed comments; implement NTP time synchronization for accurate scheduling 2025-11-05 22:55:05 -05:00
20910d5fda Refactor schedule resume button in TempWebServer to simplify form structure 2025-11-05 22:54:25 -05:00
db34c25bb4 Add NTP time synchronization after WiFi connection 2025-11-05 22:54:17 -05:00
2c10fdff62 Refactor TemperatureMonitor logging to remove sensor ID and simplify log format; update ScheduleMonitor to indicate HOLD mode when scheduling is disabled. 2025-11-05 22:31:49 -05:00
33e2944fd8 Add functionality to resume schedule in TempWebServer with Discord notification 2025-11-05 22:31:27 -05:00
2c375eef72 Implement HOLD mode functionality in TempWebServer to disable scheduling and update status display 2025-11-05 22:22:38 -05:00
101e577035 Refactor TemperatureMonitor to improve alert handling and logging functionality 2025-11-05 22:00:33 -05:00
2817273ba4 Enhance web server to handle schedule updates and configuration loading 2025-11-05 21:42:27 -05:00
f4be1a7f7d Add schedule management to web server and configuration loading 2025-11-05 21:33:19 -05:00
94fb7d3081 Implement should_run method to check monitor execution timing 2025-11-05 21:33:00 -05:00
f50f4baff0 Add ScheduleMonitor class to manage temperature schedules and apply settings 2025-11-05 21:32:15 -05:00
1fb3511ed5 Add initial configuration for AC and heater settings in config.json 2025-11-05 21:11:06 -05:00
5f8223fbe1 Implement configuration saving for AC and heater settings to config.json 2025-11-05 21:10:58 -05:00
02db62725d Add form handling for AC and heater settings with Discord notification 2025-11-05 20:34:18 -05:00
a4329da607 Enhance web interface layout by increasing max-width and implementing a responsive grid for temperature cards 2025-11-05 16:52:04 -05:00
25e48407c2 Remove connection details printout from connect_wifi function 2025-11-05 16:51:39 -05:00
8889831615 trying to fix up web page 2025-11-05 16:45:49 -05:00
121bb31f6e assign the static IP so it don't change and we can access web page for it. 2025-11-05 16:35:23 -05:00
3dd565537f Change connect_wifi function to include max_retries and timeout parameters for improved connection handling and feedback 2025-11-05 16:27:05 -05:00
eb34922da6 Add TempWebServer class for serving temperature data via a web interface 2025-11-05 16:25:16 -05:00
6156f87b05 Improve connect_wifi function for better error handling and connection logic 2025-11-05 16:25:06 -05:00
e82fcf46aa was having issues trying to pull wifi data, but I think it was stupid vs code issue 2025-11-05 16:23:32 -05:00
f53ae05842 Add HeaterMonitor class for automatic temperature control and notifications 2025-11-05 16:07:11 -05:00
8c92f86842 Add HeaterController class for managing heater operations with safety timers 2025-11-05 16:07:04 -05:00
93b68098ea Add HeaterController and HeaterMonitor for improved climate control 2025-11-05 16:06:55 -05:00
99afba25c4 Update README.md for improved clarity on AC control and sensor configuration 2025-11-05 15:37:29 -05:00
5618f07113 Remove sensor configuration registry and associated function for cleaner code 2025-11-05 15:24:08 -05:00
5694ed18c9 Refactor sensor configuration and initialization for improved clarity and maintainability 2025-11-05 15:24:02 -05:00
14 changed files with 3494 additions and 266 deletions

1
.gitignore vendored
View File

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

408
README.md
View File

@@ -1,22 +1,73 @@
# 🌱 Auto Garden # 🌱 Auto Garden
> Automated garden monitoring and control system using Raspberry Pi Pico W > Automated climate control system using Raspberry Pi Pico W with web interface and scheduling
## Overview ## Recent Updates
This project automates garden monitoring using a Raspberry Pi Pico W with temperature sensors, Discord notifications, and planned expansion for humidity, soil moisture, and environmental controls. - 🆕 **Immediate schedule application:** When resuming scheduling from hold mode, the system now instantly applies the current schedule targets (no delay).
- 🆕 **Aggressive memory management:** Garbage collection runs every 5 seconds for improved reliability.
- 🆕 **Manual hold settings:** `ac_target` and `heater_target` in `config.json` now only store your last manual hold settings, not schedule targets.
- 🆕 **NTP sync optimization:** NTP modules are loaded only when needed, saving RAM.
- 🆕 **Temperature validation:** Impossible sensor readings are ignored for safety.
- 🆕 **Improved config persistence:** All changes are saved and reloaded immediately.
## Features ## Features
- ✅ WiFi connectivity with auto-reconnect - **Core Features**
-Inside/Outside temperature monitoring (DS18B20 sensors) -WiFi connectivity with auto-reconnect and static IP
-Discord notifications for temperature readings -Inside/Outside temperature monitoring (DS18B20 sensors)
-Separate alert channel for critical temperatures -Web interface for monitoring and configuration <http://192.168.x.x>
-Temperature logging to CSV file -Discord notifications for all system events
-Configurable alert thresholds -Temperature logging to CSV file
- 🚧 Humidity monitoring (planned) - ✅ Configurable alert thresholds
- 🚧 Soil moisture monitoring (planned) - ✅ Exception recovery (system won't crash permanently)
- 🚧 Relay control for fans, AC, heaters (planned) - ✅ Graceful shutdown with Ctrl+C
-**Aggressive garbage collection for stability**
- **Climate Control**
- ✅ Automated AC control with temperature swing logic
- ✅ Automated heater control with separate swing settings
- ✅ Short-cycle protection for both AC and heater
- ✅ Dual relay control via opto-coupler for 110V AC
- ✅ Mutual exclusion (AC and heater never run simultaneously)
-**Manual hold settings are preserved and not overwritten by schedules**
- **Scheduling System**
- ✅ 4 configurable time-based schedules per day
- ✅ Each schedule sets different AC/heater targets
- ✅ Automatic mode with schedule following
- ✅ Temporary hold mode (auto-resumes after configurable time)
- ✅ Permanent hold mode (manual control until restart)
- ✅ Schedule configuration persists through reboots
- ✅ Hold modes reset to Automatic on restart (safety feature)
-**Immediate schedule application after resuming from hold**
- **Web Interface**
- ✅ Real-time temperature display
- ✅ AC/Heater status indicators
- ✅ Manual temperature override
- ✅ Schedule editor (4 time slots)
- ✅ Mode control buttons (Automatic/Temp Hold/Perm Hold)
- ✅ Countdown timer for temporary holds
- ✅ Mobile-responsive design
- ✅ Auto-refresh dashboard (30 seconds)
-**Settings and schedule changes are reflected instantly**
## Configuration Notes
- **AC/Heater target settings:**
- `ac_target` and `heater_target` in `config.json` are updated whenever you use Temp Hold, Perm Hold, or when a schedule is applied.
- When schedules are active, these values are updated to match the current schedules targets.
- This ensures the config file always reflects the current operating temperatures, whether in hold mode or schedule mode.
- **Immediate schedule application:**
- When you click "Resume Scheduling," the system applies the current schedule targets instantly, so the dashboard updates without delay.
- **Memory management:**
- Garbage collection runs every 5 seconds to prevent memory fragmentation and crashes.
- **Sensor validation:**
- Temperatures outside the range -50°F to 150°F are ignored to prevent false readings.
## Quick Start ## Quick Start
@@ -25,8 +76,9 @@ This project automates garden monitoring using a Raspberry Pi Pico W with temper
**Required Components:** **Required Components:**
- Raspberry Pi Pico W - Raspberry Pi Pico W
- DS18B20 temperature sensors (waterproof recommended) - 2x DS18B20 temperature sensors (waterproof recommended)
- 4.7kΩ resistor (pull-up for 1-Wire bus) - 4.7kΩ resistor (pull-up for 1-Wire bus)
- 2-channel opto-coupler relay module (3.3V logic, 110V AC rated)
- Momentary button (optional, for easy reset) - Momentary button (optional, for easy reset)
See the [Items Needed Wiki](https://gitea.rcs1.top/sickprodigy/Auto-Garden/wiki/Items-Needed-for-the-Project) for full parts list. See the [Items Needed Wiki](https://gitea.rcs1.top/sickprodigy/Auto-Garden/wiki/Items-Needed-for-the-Project) for full parts list.
@@ -46,14 +98,39 @@ Yellow (Data) → GP10 (Inside) - Pin 14
Add 4.7kΩ resistor between Data line and 3.3V Add 4.7kΩ resistor between Data line and 3.3V
``` ```
**⚠️ Important:** The 4.7kΩ pull-up resistor is **required** for reliable 1-Wire communication.
**2-Channel Opto-Coupler Relay Module:**
```text
Low Voltage Side (Pico):
GP15 (Pin 20) → IN1 (AC Control Signal)
GP14 (Pin 19) → IN2 (Heater Control Signal)
3.3V (Pin 36) → VCC
GND → GND
High Voltage Side - Relay 1 (AC Unit):
NO (Normally Open) → AC Control Wire 1
COM (Common) → AC Control Wire 2
High Voltage Side - Relay 2 (Heater):
NO (Normally Open) → Heater Control Wire 1
COM (Common) → Heater Control Wire 2
```
**Note:** Most opto-coupler modules work with standard logic:
- `relay.on()` = relay energized (NO closes) = Device ON
- `relay.off()` = relay de-energized (NC closes) = Device OFF
If behavior is inverted, your module may be active LOW—see troubleshooting.
**Optional Reset Button:** **Optional Reset Button:**
```text ```text
RUN pin → Button → GND RUN pin → Button → GND
``` ```
Pressing button grounds RUN and resets the Pico.
### 3. Software Setup ### 3. Software Setup
**Install MicroPython:** **Install MicroPython:**
@@ -64,7 +141,7 @@ Pressing button grounds RUN and resets the Pico.
**IDE Setup:** **IDE Setup:**
- Recommended: VS Code with [MicroPico extension](https://marketplace.visualstudio.com/items?itemName=paulober.pico-w-go) by paulober - Recommended: VS Code with [MicroPico extension](https://marketplace.visualstudio.com/items?itemName=paulober.pico-w-go)
- Alternative: Thonny IDE - Alternative: Thonny IDE
### 4. Configuration ### 4. Configuration
@@ -80,25 +157,51 @@ secrets = {
} }
``` ```
**Configure sensors in `scripts/temperature_sensor.py`:** **Sensor Configuration in `main.py`:**
```python ```python
# Sensor configuration
SENSOR_CONFIG = { SENSOR_CONFIG = {
'inside': { 'inside': {
'pin': 10, 'pin': 10,
'label': 'Inside', 'label': 'Inside',
'alert_high': 85.0, 'alert_high': 80.0,
'alert_low': 32.0 'alert_low': 70.0
}, },
'outside': { 'outside': {
'pin': 11, 'pin': 11,
'label': 'Outside', 'label': 'Outside',
'alert_high': 100.0, 'alert_high': 85.0,
'alert_low': 20.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)
"ac_swing": 1.0, # AC turns on at 78°F, off at 76°F
"heater_target": 72.0, # Heater target temperature (°F)
"heater_swing": 2.0, # Heater turns on at 70°F, off at 74°F
"temp_hold_duration": 3600, # Temporary hold lasts 1 hour (3600 seconds)
"schedule_enabled": true, # Schedules active by default
"schedules": [ # 4 time-based schedules
{
"time": "06:00",
"name": "Morning",
"ac_target": 75.0,
"heater_target": 72.0
},
# ... 3 more schedules
]
}
```
All settings can be changed via the web interface and persist through reboots.
### 5. Upload & Run ### 5. Upload & Run
Upload all files to your Pico: Upload all files to your Pico:
@@ -107,88 +210,296 @@ Upload all files to your Pico:
/ /
├── main.py ├── main.py
├── secrets.py ├── secrets.py
├── config.json # Auto-generated on first boot
└── scripts/ └── scripts/
├── air_conditioning.py # AC/Heater controller classes
├── discord_webhook.py ├── discord_webhook.py
├── monitors.py ├── monitors.py
├── networking.py ├── networking.py
── temperature_sensor.py ── scheduler.py # Schedule system with hold timer
├── temperature_sensor.py
└── web_server.py # Web interface
``` ```
The Pico will auto-start `main.py` on boot. The Pico will auto-start `main.py` on boot and be accessible at **<http://192.168.x.x>**
## Project Structure ## Project Structure
```text ```text
Auto-Garden/ Auto-Garden/
├── main.py # Entry point, sets up monitors ├── main.py # Entry point, configuration, system initialization
├── secrets.py # WiFi & Discord credentials (gitignored) ├── secrets.py # WiFi & Discord credentials (gitignored)
├── secrets.example.py # Template for secrets.py ├── 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
├── discord_webhook.py # Discord notification handling ├── discord_webhook.py # Discord notification handling
├── monitors.py # Monitor base class & implementations ├── monitors.py # Monitor base class & implementations
├── networking.py # WiFi connection management ├── networking.py # WiFi connection management
── temperature_sensor.py # DS18B20 sensor interface & config ── scheduler.py # Schedule system with temporary/permanent hold modes
├── temperature_sensor.py # DS18B20 sensor interface
└── web_server.py # Web interface for monitoring and control
``` ```
## Monitoring Behavior ## How It Works
- **Every 10 seconds:** Check temperatures, send alerts if out of range ### Temperature Monitoring
- **Every 30 seconds:** Regular temperature reports to Discord + log to file
- **Every 5 seconds:** WiFi connection check with auto-reconnect
**Discord Channels:** - **Every 10 seconds:** Check temperatures
- **Every 30 seconds:** Send temperature reports to Discord + log to CSV
- **Instant alerts:** High/low temperature warnings to separate Discord channel
- `discord_webhook_url`: Regular temperature updates, connection status **Discord Notifications:**
- `discord_webhook_url`: Regular updates, status changes, system events
- `discord_alert_webhook_url`: Critical temperature alerts (Inside sensor only) - `discord_alert_webhook_url`: Critical temperature alerts (Inside sensor only)
**Example Discord Messages:**
```text
📊 Inside: 75.2°F | AC: OFF | Heater: OFF
📊 Outside: 68.5°F | AC: OFF | Heater: OFF
🔥 Inside temp HIGH: 81.0°F
Schedule 'Morning' applied - AC: 75°F, Heater: 72°F
⏸️ HOLD Mode - Manual override: AC: 77F +/- 1F | Heater: 72F +/- 2F
⏰ Temporary hold expired - Automatic mode resumed
```
### Climate Control Logic
**AC Control:**
- Target: 77°F with 1°F swing
- AC turns **ON** when temp > 78°F (77 + 1)
- AC turns **OFF** when temp < 76°F (77 - 1)
- Between 76-78°F: maintains current state (dead band prevents cycling)
**Heater Control:**
- Target: 72°F with 2°F swing
- Heater turns **ON** when temp < 70°F (72 - 2)
- Heater turns **OFF** when temp > 74°F (72 + 2)
- Between 70-74°F: maintains current state
**Short-Cycle Protection:**
- Minimum run time: 30 seconds (prevents rapid off)
- Minimum off time: 5 seconds (protects compressor/heater elements)
- AC and heater never run simultaneously (mutual exclusion)
### Scheduling System
**Automatic Mode (Default):**
- Schedules apply at configured times (e.g., 06:00, 12:00, 18:00, 22:00)
- AC and heater targets update automatically
- System follows the most recent schedule until next one applies
**Temporary Hold Mode:**
- Activated by manual temperature changes or "⏸️ Temp Hold" button
- Pauses schedules for configurable duration (default: 1 hour)
- Web UI shows countdown timer: "45 min remaining"
- Auto-resumes to Automatic mode when timer expires
- Can be manually resumed with "▶️ Resume" button
**Permanent Hold Mode:**
- Activated by "🛑 Perm Hold" button
- Completely disables schedules (manual control only)
- Stays disabled until "▶️ Enable Schedules" clicked or Pico reboots
- No countdown timer
**Hold Reset on Boot:**
- All hold modes reset to Automatic on Pico restart/power cycle
- Safety feature ensures schedules always resume after power loss
- Temperature targets, swing values, and schedules persist
### Web Interface
Access at **<http://192.168.x.x>**
**Dashboard (auto-refreshes every 30s):**
- Current inside/outside temperatures
- AC/Heater status indicators
- Next scheduled temperature change
- Current mode banner with countdown (if in Temporary Hold)
- Manual temperature override form
- Mode control buttons
**Schedule Editor:**
- Configure 4 time-based schedules
- Set time (HH:MM format), name, AC target, heater target for each
- Form validation (prevents heater > AC, invalid times)
- No auto-refresh (prevents losing edits)
**Mode Control:**
- **✅ Automatic Mode:** Schedules active, temps adjust based on time
- Buttons: [⏸️ Temp Hold] [🛑 Perm Hold]
- **⏸️ Temporary Hold:** Manual override with countdown timer
- Buttons: [▶️ Resume] [🛑 Perm Hold]
- **🛑 Permanent Hold:** Manual control only, schedules disabled
- Button: [▶️ Enable Schedules]
### WiFi Monitoring
- **Every 5 seconds:** Check WiFi connection
- **LED Indicator:**
- Solid ON: Connected
- Blinking: Reconnecting
- **Auto-reconnect:** Attempts every 60 seconds if disconnected
- **Static IP:** Always accessible at <http://192.168.x.x>
## Temperature Logs ## Temperature Logs
Logs are saved to `/temp_logs.csv` on the Pico: Logs are saved to `/temp_logs.csv` on the Pico:
```csv ```csv
2025-11-05 14:30:00,Inside,28000012,72.50 2025-11-08 14:30:00,Inside,28000012,72.50
2025-11-05 14:30:00,Outside,28000034,45.30 2025-11-08 14:30:00,Outside,28000034,45.30
``` ```
Format: `timestamp,location,sensor_id,temperature_f` Format: `timestamp,location,sensor_id,temperature_f`
## Future Expansion ## Customization
### Planned Features ### Via Web Interface (Recommended)
- **Humidity Sensors:** DHT22 or SHT31 for air humidity monitoring - Navigate to <http://192.168.x.x>
- **Soil Moisture:** Capacitive sensors for plant watering automation - Adjust AC/Heater targets and swing values
- **Relay Control:** 3V-32VDC SSR relays for switching AC, fans, heaters - Edit schedules (times, names, targets)
- **Smart Ventilation:** Auto-open windows when outside air is optimal - Settings persist through reboots
- **Light Monitoring:** LDR or BH1750 for grow light automation
### Relay Wiring (Future) ### Via config.json
```text ```json
Pico 3.3V output → SSR relay input → High voltage device (120V/240V) {
"ac_target": 77.0,
"ac_swing": 1.5, // Change swing range
"heater_target": 72.0,
"heater_swing": 2.5,
"temp_hold_duration": 7200, // 2 hours (in seconds)
"schedules": [ /* ... */ ]
}
``` ```
Use solid-state relays (SSR) rated for your voltage/current needs. ### Via main.py (Advanced)
```python
# Relay pins
ac_relay_pin = 15
heater_relay_pin = 14
# Sensor pins
SENSOR_CONFIG = {
'inside': {'pin': 10, ...},
'outside': {'pin': 11, ...}
}
# Monitor intervals
check_interval=10 # Temperature check frequency
report_interval=30 # Discord report frequency
```
## Safety Notes
⚠️ **High Voltage Warning:**
- Opto-couplers isolate Pico from AC voltage
- Never connect GPIO directly to 110V AC
- Ensure relay module is rated for your voltage
- Test with multimeter before connecting AC loads
- Consider hiring licensed electrician if uncomfortable
**Compressor/Heater Protection:**
- Always use minimum run/off times
- Minimum 5s off time protects compressor bearings
- Minimum 30s run time prevents short cycling
- AC and heater mutual exclusion prevents simultaneous operation
**System Reliability:**
- Exception recovery prevents permanent crashes
- Graceful shutdown (Ctrl+C) safely turns off AC/heater
- Hold modes reset on reboot (schedules always resume)
- Static IP ensures web interface always accessible
## Troubleshooting ## Troubleshooting
**Web interface not loading:**
- Verify Pico is connected to WiFi (LED should be solid)
- Check static IP is <http://192.168.x.x>
- Look for "Web Interface: <http://192.168.x.x>" in serial console
- Try accessing from same WiFi network
**No temperature readings:** **No temperature readings:**
- Check 4.7kΩ pull-up resistor is connected - Check 4.7kΩ pull-up resistor between data line and 3.3V
- Verify sensor wiring (VDD to 3.3V, not 5V) - Verify sensor wiring (VDD to 3.3V, not 5V)
- Check GPIO pin numbers in `SENSOR_CONFIG` - Check GPIO pin numbers in `SENSOR_CONFIG`
- Run `sensor.scan_sensors()` in REPL to detect sensors
**WiFi not connecting:** **WiFi not connecting:**
- Verify SSID/password in `secrets.py` - Verify SSID/password in `secrets.py`
- Check 2.4GHz WiFi (Pico W doesn't support 5GHz) - Check 2.4GHz WiFi (Pico W doesn't support 5GHz)
- Look for connection messages in serial console - LED should be solid when connected
- Check serial console for connection status
**Discord messages not sending:** **Discord messages not sending:**
- Verify webhook URLs are correct - Verify webhook URLs in `secrets.py`
- 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
**AC/Heater not switching:**
- Verify relay pin numbers (default GP15/GP14)
- Test relay manually in REPL: `Pin(15, Pin.OUT).on()`
- Check if module is active LOW or active HIGH
- Ensure opto-coupler has 3.3V power
- Look for LED on relay module (should light when active)
- Check minimum run/off times haven't locked out switching
**AC/Heater behavior inverted:**
- Your opto-coupler is active LOW
- In `air_conditioning.py`, swap `relay.on()` and `relay.off()` calls in both ACController and HeaterController classes
**Schedules not applying:**
- Check NTP time sync: "Time synced with NTP server" in serial
- Verify schedule times in HH:MM format (24-hour)
- Ensure "✅ Automatic Mode" is active (not in hold)
- Check serial console for "Schedule Applied: [name]" messages
**Temporary hold not auto-resuming:**
- Check `temp_hold_duration` in config.json (in seconds)
- Look for "⏰ Temporary hold expired" in serial console
- ScheduleMonitor runs every 60 seconds, may take up to 1 min extra
- Verify timer countdown appears in web UI banner
**System keeps crashing:**
- Check for recent code changes
- Look for exception messages in serial console
- System should auto-recover from most errors (5s pause, then retry)
- If persistent, check memory usage with `gc.mem_free()`
**Config changes not saving:**
- Verify web form submissions redirect to dashboard/schedule page
- Check for "Settings persisted to disk" in serial console
- Ensure config.json has write permissions
- Try manual edit of config.json and reboot
## Contributing ## Contributing
@@ -204,7 +515,8 @@ MIT License - See LICENSE file for details
- [MicroPython Documentation](https://docs.micropython.org/) - [MicroPython Documentation](https://docs.micropython.org/)
- [DS18B20 Datasheet](https://www.analog.com/media/en/technical-documentation/data-sheets/DS18B20.pdf) - [DS18B20 Datasheet](https://www.analog.com/media/en/technical-documentation/data-sheets/DS18B20.pdf)
- [Discord Webhooks Guide](https://discord.com/developers/docs/resources/webhook) - [Discord Webhooks Guide](https://discord.com/developers/docs/resources/webhook)
- [1-Wire Protocol Guide](https://www.analog.com/en/technical-articles/guide-to-1wire-communication.html)
--- ---
**Note:** Always ensure proper electrical safety when working with high-voltage relays and AC power. Consult a licensed electrician if unsure. **Note:** Always ensure proper electrical safety when working with high-voltage relays and AC power. Test thoroughly before leaving unattended.

View File

@@ -1,5 +1,5 @@
from machine import Pin from machine import Pin # type: ignore
import time import time # type: ignore
class ACController: class ACController:
"""Control AC unit via opto-coupler relay.""" """Control AC unit via opto-coupler relay."""

View File

@@ -1,8 +1,25 @@
import urequests as requests # Minimal module-level state (only what we need)
from secrets import secrets _CONFIG = {"discord_webhook_url": None, "discord_alert_webhook_url": None}
# Cooldown after low-memory failures (epoch seconds)
_NEXT_ALLOWED_SEND_TS = 0
def set_config(cfg: dict):
"""Initialize module with minimal values from loaded config (call from main)."""
global _CONFIG
if not cfg:
_CONFIG = {"discord_webhook_url": None, "discord_alert_webhook_url": None}
return
_CONFIG = {
"discord_webhook_url": cfg.get("discord_webhook_url"),
"discord_alert_webhook_url": cfg.get("discord_alert_webhook_url"),
}
def _get_webhook_url(is_alert: bool = False):
if is_alert:
return _CONFIG.get("discord_alert_webhook_url") or _CONFIG.get("discord_webhook_url")
return _CONFIG.get("discord_webhook_url")
def _escape_json_str(s: str) -> str: def _escape_json_str(s: str) -> str:
# minimal JSON string escaper for quotes/backslashes and control chars
s = s.replace("\\", "\\\\") s = s.replace("\\", "\\\\")
s = s.replace('"', '\\"') s = s.replace('"', '\\"')
s = s.replace("\n", "\\n") s = s.replace("\n", "\\n")
@@ -10,46 +27,110 @@ def _escape_json_str(s: str) -> str:
s = s.replace("\t", "\\t") s = s.replace("\t", "\\t")
return s return s
def send_discord_message(message, username="Auto Garden Bot", is_alert=False): def send_discord_message(message, username="Auto Garden Bot", is_alert=False, debug: bool = False):
"""
Send Discord message with aggressive GC and low-memory guard to avoid ENOMEM.
When debug=True prints mem_free at important steps so you can see peak usage.
Returns True on success, False otherwise.
"""
global _NEXT_ALLOWED_SEND_TS
resp = None resp = None
url = _get_webhook_url(is_alert=is_alert)
# Use alert webhook if specified, otherwise normal webhook
if is_alert:
url = secrets.get('discord_alert_webhook_url') or secrets.get('discord_webhook_url')
else:
url = secrets.get('discord_webhook_url')
try:
if not url: if not url:
# print("DEBUG: no webhook URL in secrets") if debug: print("DBG: no webhook URL configured")
return False return False
url = url.strip().strip('\'"') # Respect cooldown if we recently saw ENOMEM
try:
import time # type: ignore
now = time.time()
if _NEXT_ALLOWED_SEND_TS and now < _NEXT_ALLOWED_SEND_TS:
if debug: print("DBG: backing off until", _NEXT_ALLOWED_SEND_TS)
return False
except:
pass
# build JSON by hand so emoji (and other unicode) are preserved as UTF-8 bytes try:
content = _escape_json_str(message) # Lightweight local imports and GC
user = _escape_json_str(username) import gc # type: ignore
import time # type: ignore
gc.collect()
# Quick mem check before importing urequests/SSL
mem = getattr(gc, "mem_free", lambda: None)()
# Require larger headroom based on device testing (adjust if you re-test)
if mem is not None and mem < 95000:
print("Discord send skipped: ENOMEM ({} bytes free)".format(mem))
return False
# Import urequests only when we plan to send
try:
import urequests as requests # type: ignore
except Exception as e:
print("Discord send failed: urequests import error:", e)
try:
_NEXT_ALLOWED_SEND_TS = time.time() + 60
except:
pass
return False
gc.collect()
if debug:
try: print("DBG: mem after import:", gc.mem_free() // 1024, "KB")
except: pass
# Build tiny payload
url = str(url).strip().strip('\'"')
content = _escape_json_str(str(message)[:140])
user = _escape_json_str(str(username)[:32])
body_bytes = ('{"content":"%s","username":"%s"}' % (content, user)).encode("utf-8") body_bytes = ('{"content":"%s","username":"%s"}' % (content, user)).encode("utf-8")
headers = {"Content-Type": "application/json"}
headers = {"Content-Type": "application/json; charset=utf-8"}
resp = requests.post(url, data=body_bytes, headers=headers) resp = requests.post(url, data=body_bytes, headers=headers)
status = getattr(resp, "status", getattr(resp, "status_code", None)) status = getattr(resp, "status", getattr(resp, "status_code", None))
return bool(status and 200 <= status < 300)
if status and 200 <= status < 300:
# print("Discord message sent")
return True
else:
# print(f"Discord webhook failed with status {status}")
return False
except Exception as e: except Exception as e:
# print("Failed to send Discord message:", e) print("Discord send failed:", e)
return False
finally:
if resp:
try: try:
if ("ENOMEM" in str(e)) or isinstance(e, MemoryError):
import time # type: ignore
_NEXT_ALLOWED_SEND_TS = time.time() + 60
except:
pass
return False
finally:
try:
if resp:
resp.close() resp.close()
except: except:
pass pass
# remove local refs and unload heavy modules to free peak RAM (urequests, ussl/ssl)
try:
if 'resp' in locals(): del resp
if 'body_bytes' in locals(): del body_bytes
if 'content' in locals(): del content
if 'user' in locals(): del user
if 'headers' in locals(): del headers
if 'requests' in locals(): del requests
except:
pass
try:
import sys
# remove urequests and SSL modules from module cache so their memory can be reclaimed
for m in ('urequests', 'ussl', 'ssl'):
if m in sys.modules:
try:
del sys.modules[m]
except:
pass
except:
pass
try:
import gc # type: ignore
gc.collect()
except:
pass

68
Scripts/heating.py Normal file
View File

@@ -0,0 +1,68 @@
from machine import Pin # type: ignore
import time # type: ignore
class HeaterController:
"""Control heater via opto-coupler relay."""
def __init__(self, relay_pin=16, min_run_time=300, min_off_time=180):
"""
relay_pin: GPIO pin connected to opto-coupler input
min_run_time: Minimum seconds heater must run before turning off (prevent short cycling)
min_off_time: Minimum seconds heater must be off before turning on (element protection)
"""
self.relay = Pin(relay_pin, Pin.OUT)
self.relay.off() # Start with heater off (relay normally open)
self.min_run_time = min_run_time
self.min_off_time = min_off_time
self.is_on = False
self.last_state_change = time.ticks_ms()
def turn_on(self):
"""Turn heater on if minimum off time has elapsed."""
if self.is_on:
return True # Already on
now = time.ticks_ms()
time_since_change = time.ticks_diff(now, self.last_state_change) / 1000
if time_since_change < self.min_off_time:
remaining = int(self.min_off_time - time_since_change)
print(f"Heater cooldown: {remaining}s remaining before can turn on")
return False
self.relay.on()
self.is_on = True
self.last_state_change = now
print("Heater turned ON")
return True
def turn_off(self):
"""Turn heater off if minimum run time has elapsed."""
if not self.is_on:
return True # Already off
now = time.ticks_ms()
time_since_change = time.ticks_diff(now, self.last_state_change) / 1000
if time_since_change < self.min_run_time:
remaining = int(self.min_run_time - time_since_change)
print(f"Heater minimum runtime: {remaining}s remaining before can turn off")
return False
self.relay.off()
self.is_on = False
self.last_state_change = now
print("Heater turned OFF")
return True
def get_state(self):
"""Return current heater state."""
return self.is_on
def force_off(self):
"""Emergency shut off (bypasses timers)."""
self.relay.off()
self.is_on = False
self.last_state_change = time.ticks_ms()
print("Heater FORCE OFF")

29
Scripts/memory_check.py Normal file
View File

@@ -0,0 +1,29 @@
import gc # type: ignore
def check_memory_once():
"""One-time memory check (for startup diagnostics)."""
gc.collect()
free = gc.mem_free()
allocated = gc.mem_alloc()
total = free + allocated
print("\n" + "="*50)
print("Startup Memory Check:")
print("="*50)
print("Total: {:.1f} KB".format(total / 1024))
print("Used: {:.1f} KB ({:.1f}%)".format(
allocated / 1024,
(allocated/total)*100
))
print("Free: {:.1f} KB ({:.1f}%)".format(
free / 1024,
(free/total)*100
))
print("="*50 + "\n")
return {
'total_kb': total / 1024,
'used_kb': allocated / 1024,
'free_kb': free / 1024,
'usage_percent': (allocated/total)*100
}

View File

@@ -1,5 +1,5 @@
import time import time # type: ignore
from scripts.discord_webhook import send_discord_message import scripts.discord_webhook as discord_webhook
from scripts.temperature_sensor import TemperatureSensor from scripts.temperature_sensor import TemperatureSensor
class Monitor: class Monitor:
@@ -24,9 +24,24 @@ class Monitor:
pass pass
class TemperatureMonitor(Monitor): class TemperatureMonitor(Monitor):
"""Monitor temperature sensors and report to Discord.""" """Monitor for tracking temperature readings and alerts."""
def __init__(self, sensor, label="Temp", check_interval=10, report_interval=30, alert_high=None, alert_low=None, log_file=None, send_alerts_to_separate_channel=False):
super().__init__(check_interval) # Check interval for temp reading def __init__(self, sensor, label, check_interval=10, report_interval=60,
alert_high=None, alert_low=None, log_file="/temp_logs.csv",
send_alerts_to_separate_channel=False):
"""
Initialize temperature monitor.
Args:
sensor: TemperatureSensor instance
label: Label for this sensor
check_interval: How often to check temp (seconds)
report_interval: How often to report/log temp (seconds)
alert_high: High temp threshold for alerts
alert_low: Low temp threshold for alerts
log_file: Path to CSV log file
send_alerts_to_separate_channel: Use separate Discord channel for alerts
"""
self.sensor = sensor self.sensor = sensor
self.label = label self.label = label
self.check_interval = check_interval self.check_interval = check_interval
@@ -35,74 +50,123 @@ class TemperatureMonitor(Monitor):
self.alert_low = alert_low self.alert_low = alert_low
self.log_file = log_file self.log_file = log_file
self.send_alerts_to_separate_channel = send_alerts_to_separate_channel self.send_alerts_to_separate_channel = send_alerts_to_separate_channel
self.last_report_ms = 0
self.last_alert_state = None # Track if we were in alert state self.last_check = 0
self.last_report = 0
self.alert_sent = False
self.alert_start_time = None # Track when alert started
self.last_temp = None # Cached Last temperature reading
self.last_read_time = 0 # Timestamp of last reading
def should_run(self):
"""Check if it's time to run this monitor."""
current_time = time.time()
if current_time - self.last_check >= self.check_interval:
self.last_check = current_time
return True
return False
def run(self): def run(self):
"""Read sensors every check_interval, report/log every report_interval.""" """Check temperature and handle alerts/logging."""
current_time = time.time()
# Read temperature
temps = self.sensor.read_all_temps(unit='F') temps = self.sensor.read_all_temps(unit='F')
if not temps: if not temps:
# print(f"No temperature readings available for {self.label}")
return return
now = time.ticks_ms() temp = list(temps.values())[0] # Get first temp reading
should_report = time.ticks_diff(now, self.last_report_ms) >= (self.report_interval * 1000)
for rom, temp in temps.items(): # ===== ADD THIS: Validate temperature is reasonable =====
sensor_id = rom.hex()[:8] if temp < -50 or temp > 150: # Sanity check (outside normal range)
print("⚠️ Warning: {} sensor returned invalid temp: {:.1f}°F".format(self.label, temp))
return # Don't cache invalid reading
# ===== END: Validation =====
# Check if in alert state # Cache the reading for web server (avoid blocking reads)
has_alert = False self.last_temp = temp
alert_type = None self.last_read_time = current_time
# Check for alerts
alert_condition = False
alert_message = ""
if self.alert_high and temp > self.alert_high: if self.alert_high and temp > self.alert_high:
has_alert = True alert_condition = True
alert_type = "HIGH" alert_message = "⚠️ {} temperature HIGH: {:.1f}°F (threshold: {:.1f}°F)".format(
self.label, temp, self.alert_high
)
elif self.alert_low and temp < self.alert_low: elif self.alert_low and temp < self.alert_low:
has_alert = True alert_condition = True
alert_type = "LOW" alert_message = "⚠️ {} temperature LOW: {:.1f}°F (threshold: {:.1f}°F)".format(
self.label, temp, self.alert_low
)
# Send alert immediately to alert channel (every check_interval, only if configured) # Handle alert state changes
if has_alert and self.send_alerts_to_separate_channel: if alert_condition and not self.alert_sent:
alert_msg = f"🚨 {self.label} Temperature: {temp:.1f}°F ⚠️ {alert_type} (threshold: {self.alert_high if alert_type == 'HIGH' else self.alert_low}°F)" # Alert triggered
send_discord_message(alert_msg, is_alert=True) self.alert_start_time = current_time
self.last_alert_state = True print(alert_message)
# Send normal report at report_interval to regular channel (regardless of alert state) # send alert (use module-level discord_webhook; set_config must be called in main)
if should_report: if self.send_alerts_to_separate_channel:
temp_msg = f"🌡️ {self.label} Temperature: {temp:.1f}°F" discord_webhook.send_discord_message(alert_message, is_alert=True)
else:
discord_webhook.send_discord_message(alert_message)
# Add alert indicator to regular report if in alert self.alert_sent = True
if has_alert:
temp_msg += f" ⚠️ {alert_type}"
send_discord_message(temp_msg, is_alert=False) elif not alert_condition and self.alert_sent:
# Alert resolved
duration = current_time - self.alert_start_time if self.alert_start_time else 0
# Send recovery message if we were in alert and now normal # Format duration
if not has_alert and self.last_alert_state: if duration >= 3600:
recovery_msg = f"{self.label} Temperature back to normal: {temp:.1f}°F" hours = int(duration / 3600)
send_discord_message(recovery_msg, is_alert=False) minutes = int((duration % 3600) / 60)
self.last_alert_state = False duration_str = "{}h {}m".format(hours, minutes)
elif duration >= 60:
minutes = int(duration / 60)
seconds = int(duration % 60)
duration_str = "{}m {}s".format(minutes, seconds)
else:
duration_str = "{}s".format(int(duration))
# Log to file at report_interval recovery_message = "{} temperature back to normal: {:.1f}°F (was out of range for {})".format(
if should_report and self.log_file: self.label, temp, duration_str
self._log_temp(sensor_id, temp) )
print(recovery_message)
# Update last report time # send recovery message
if should_report: if self.send_alerts_to_separate_channel:
self.last_report_ms = now discord_webhook.send_discord_message(recovery_message, is_alert=True)
else:
discord_webhook.send_discord_message(recovery_message)
def _log_temp(self, sensor_id, temp): self.alert_sent = False
"""Log temperature reading to file.""" self.alert_start_time = None
# Log temperature at report interval
if current_time - self.last_report >= self.report_interval:
self.last_report = current_time
self._log_temperature(temp)
def _log_temperature(self, temp):
"""Log temperature to CSV file."""
try: try:
import time # Get timestamp
timestamp = time.localtime() t = time.localtime()
log_entry = f"{timestamp[0]}-{timestamp[1]:02d}-{timestamp[2]:02d} {timestamp[3]:02d}:{timestamp[4]:02d}:{timestamp[5]:02d},{self.label},{sensor_id},{temp:.2f}\n" timestamp = "{:04d}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}".format(
t[0], t[1], t[2], t[3], t[4], t[5]
)
# Append to log file
with open(self.log_file, 'a') as f: with open(self.log_file, 'a') as f:
f.write(log_entry) f.write("{},{},{:.2f}\n".format(
timestamp, self.label, temp
))
except Exception as e: except Exception as e:
print(f"Error logging temperature: {e}") print("Error logging temperature: {}".format(e))
class ACMonitor(Monitor): class ACMonitor(Monitor):
def __init__(self, ac_controller, temp_sensor, target_temp=75.0, temp_swing=2.0, interval=30): def __init__(self, ac_controller, temp_sensor, target_temp=75.0, temp_swing=2.0, interval=30):
@@ -130,31 +194,78 @@ class ACMonitor(Monitor):
# Too hot, turn AC on # Too hot, turn AC on
if self.ac.turn_on(): if self.ac.turn_on():
if not self.last_notified_state: if not self.last_notified_state:
send_discord_message(f"❄️ AC turned ON - Current: {current_temp:.1f}°F, Target: {self.target_temp:.1f}°F") discord_webhook.send_discord_message(f"❄️ AC turned ON - Current: {current_temp:.1f}°F, Target: {self.target_temp:.1f}°F")
self.last_notified_state = True self.last_notified_state = True
elif current_temp < (self.target_temp - self.temp_swing): elif current_temp < (self.target_temp - self.temp_swing):
# Cool enough, turn AC off # Cool enough, turn AC off
if self.ac.turn_off(): if self.ac.turn_off():
if self.last_notified_state: if self.last_notified_state:
send_discord_message(f"✅ AC turned OFF - Current: {current_temp:.1f}°F, Target: {self.target_temp:.1f}°F") discord_webhook.send_discord_message(f"✅ AC turned OFF - Current: {current_temp:.1f}°F, Target: {self.target_temp:.1f}°F")
self.last_notified_state = False
# Else: within temp_swing range, maintain current state
class HeaterMonitor(Monitor):
"""Monitor temperature and control heater automatically."""
def __init__(self, heater_controller, temp_sensor, target_temp=70.0, temp_swing=2.0, interval=30):
"""
heater_controller: HeaterController instance
temp_sensor: TemperatureSensor instance (inside temp)
target_temp: Target temperature in °F
temp_swing: Temperature swing allowed (prevents rapid cycling)
interval: Seconds between checks
"""
super().__init__(interval)
self.heater = heater_controller
self.sensor = temp_sensor
self.target_temp = target_temp
self.temp_swing = temp_swing
self.last_notified_state = None
def run(self):
"""Check temperature and control heater."""
temps = self.sensor.read_all_temps(unit='F')
if not temps:
return
# Use first sensor reading (assuming single inside sensor)
current_temp = list(temps.values())[0]
# Heating logic with temperature swing
# Turn ON if: temp < target - temp_swing
# Turn OFF if: temp > target + temp_swing
if current_temp < (self.target_temp - self.temp_swing):
# Too cold, turn heater on
if self.heater.turn_on():
if not self.last_notified_state:
discord_webhook.send_discord_message(f"🔥 Heater turned ON - Current: {current_temp:.1f}°F, Target: {self.target_temp:.1f}°F")
self.last_notified_state = True
elif current_temp > (self.target_temp + self.temp_swing):
# Warm enough, turn heater off
if self.heater.turn_off():
if self.last_notified_state:
discord_webhook.send_discord_message(f"✅ Heater turned OFF - Current: {current_temp:.1f}°F, Target: {self.target_temp:.1f}°F")
self.last_notified_state = False self.last_notified_state = False
# Else: within temp_swing range, maintain current state # Else: within temp_swing range, maintain current state
class WiFiMonitor(Monitor): class WiFiMonitor(Monitor):
"""Monitor WiFi connection and handle reconnection.""" """Monitor WiFi connection and handle reconnection."""
def __init__(self, wifi, led, interval=5, reconnect_cooldown=60): def __init__(self, wifi, led, interval=5, reconnect_cooldown=60, config=None):
super().__init__(interval) super().__init__(interval)
self.wifi = wifi self.wifi = wifi
self.led = led self.led = led
self.reconnect_cooldown = reconnect_cooldown self.reconnect_cooldown = reconnect_cooldown
self.last_reconnect_attempt = 0 self.last_reconnect_attempt = 0
self.was_connected = wifi.isconnected() if wifi else False self.was_connected = wifi.isconnected() if wifi else False
self.config = config
def run(self): def run(self):
"""Check WiFi status, blink LED, attempt reconnect if needed.""" """Check WiFi status, blink LED, attempt reconnect if needed."""
import network import network # type: ignore
from scripts.networking import connect_wifi from scripts.networking import connect_wifi
is_connected = self.wifi.isconnected() if self.wifi else False is_connected = self.wifi.isconnected() if self.wifi else False
@@ -170,10 +281,10 @@ class WiFiMonitor(Monitor):
if time.ticks_diff(now, self.last_reconnect_attempt) >= (self.reconnect_cooldown * 1000): if time.ticks_diff(now, self.last_reconnect_attempt) >= (self.reconnect_cooldown * 1000):
self.last_reconnect_attempt = now self.last_reconnect_attempt = now
# print("Attempting WiFi reconnect...") # print("Attempting WiFi reconnect...")
self.wifi = connect_wifi(self.led) self.wifi = connect_wifi(self.led, config=self.config)
if self.wifi and self.wifi.isconnected(): if self.wifi and self.wifi.isconnected():
send_discord_message("WiFi connection restored 🔄") discord_webhook.send_discord_message("WiFi connection restored 🔄")
self.was_connected = True self.was_connected = True
else: else:
# Slow blink when connected # Slow blink when connected
@@ -183,7 +294,7 @@ class WiFiMonitor(Monitor):
# Notify if connection was just restored # Notify if connection was just restored
if not self.was_connected: if not self.was_connected:
send_discord_message("WiFi connection restored 🔄") discord_webhook.send_discord_message("WiFi connection restored 🔄")
self.was_connected = True self.was_connected = True
def run_monitors(monitors): def run_monitors(monitors):

View File

@@ -1,37 +1,132 @@
import network import network # type: ignore
import time import time # type: ignore
from secrets import secrets
RECONNECT_COOLDOWN_MS = 60000 # 60 seconds def connect_wifi(led=None, max_retries=3, timeout=20, config=None):
def connect_wifi(led=None, timeout=10):
""" """
Connect to WiFi using secrets['ssid'] / secrets['password']. Connect to WiFi using credentials from provided config dict.
If `led` (machine.Pin) is provided, pulse it once on successful connect.
Returns the WLAN object or None on failure.
"""
wifi = network.WLAN(network.STA_IF)
wifi.active(True)
# print("Connecting to WiFi...", end="") Args:
wifi.connect(secrets['ssid'], secrets['password']) led: Optional LED pin for visual feedback
max_retries: Number of connection attempts (default: 3)
timeout: Seconds to wait for connection per attempt (default: 20)
config: Dict loaded from config.json, must contain config['wifi'] with 'ssid' and 'password'
Returns:
WLAN object if connected, None if failed
"""
if config is None:
print("connect_wifi: config is required")
return None
wifi_cfg = config.get('wifi') or {}
# support either config['wifi'] = {'ssid','password'} OR top-level 'ssid'/'password'
ssid = wifi_cfg.get('ssid') or config.get('ssid')
password = wifi_cfg.get('password') or config.get('password')
if not ssid or not password:
print("connect_wifi: missing wifi credentials in config['wifi']")
return None
wlan = network.WLAN(network.STA_IF)
# Ensure clean state
try:
if wlan.active():
wlan.active(False)
time.sleep(1)
wlan.active(True)
time.sleep(1)
except OSError as e:
print(f"WiFi activation error: {e}")
print("Attempting reset...")
try:
wlan.deinit()
time.sleep(2)
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
time.sleep(1)
except Exception as e2:
print(f"WiFi reset failed: {e2}")
return None
# Try connecting with retries
for attempt in range(1, max_retries + 1):
if wlan.isconnected():
print("Already connected to WiFi")
break
print(f'Connecting to WiFi SSID: {ssid} (attempt {attempt}/{max_retries})...')
try:
wlan.connect(ssid, password)
except Exception as e:
print(f"Connection attempt failed: {e}")
if attempt < max_retries:
print("Retrying in 3 seconds...")
time.sleep(3)
continue
# Wait for connection with timeout # Wait for connection with timeout
max_wait = timeout wait_time = 0
while max_wait > 0: while wait_time < timeout:
if wifi.status() < 0 or wifi.status() >= 3: if wlan.isconnected():
break break
max_wait -= 1
# print(".", end="")
time.sleep(1)
if wifi.isconnected():
# print("\nConnected! Network config:", wifi.ifconfig())
if led: if led:
led.on() try:
time.sleep(1) # some LED wrappers use toggle(), others use on/off
led.off() if hasattr(led, "toggle"):
return wifi led.toggle()
else: else:
# print("\nConnection failed!") # flash quickly to show activity
led.on()
time.sleep(0.05)
led.off()
except Exception:
pass
time.sleep(0.5)
wait_time += 0.5
# Print progress dots every 2 seconds
if int(wait_time * 2) % 4 == 0:
print('.', end='')
print() # New line after dots
if wlan.isconnected():
break
print(f'Connection attempt {attempt} failed')
if attempt < max_retries:
print("Retrying in 3 seconds...")
time.sleep(3)
# Final connection check
if not wlan.isconnected():
print('WiFi connection failed after all attempts!')
if led:
try:
# prefer available method names
if hasattr(led, "off"):
led.off()
except Exception:
pass
return None return None
# Success feedback
if led:
try:
for _ in range(2):
led.on()
time.sleep(0.2)
led.off()
time.sleep(0.2)
except Exception:
pass
print('Connected to WiFi successfully!')
return wlan

227
Scripts/scheduler.py Normal file
View File

@@ -0,0 +1,227 @@
import time # type: ignore
class ScheduleMonitor:
"""Monitor that checks and applies temperature schedules."""
def __init__(self, ac_monitor, heater_monitor, config, interval=60):
"""
Initialize schedule monitor.
Args:
ac_monitor: ACMonitor instance
heater_monitor: HeaterMonitor instance
config: Configuration dict with schedules
interval: How often to check schedule (seconds)
"""
self.ac_monitor = ac_monitor
self.heater_monitor = heater_monitor
self.config = config
self.interval = interval
self.last_check = 0
self.current_schedule = None
self.last_applied_schedule = None
self.temp_hold_duration = config.get('temp_hold_duration', 3600) # Use config value, default 1 hour
def should_run(self):
"""Check if it's time to run this monitor."""
current_time = time.time()
if current_time - self.last_check >= self.interval:
self.last_check = current_time
return True
return False
def _parse_time(self, time_str):
"""Convert time string 'HH:MM' to minutes since midnight."""
try:
parts = time_str.split(':')
hours = int(parts[0])
minutes = int(parts[1])
return hours * 60 + minutes
except:
return None
def _get_current_minutes(self):
"""Get current time in minutes since midnight."""
t = time.localtime()
return t[3] * 60 + t[4] # hours * 60 + minutes
def _find_active_schedule(self):
"""Find which schedule should be active right now."""
if not self.config.get('schedule_enabled', False):
# Schedule is disabled (HOLD mode)
return None
schedules = self.config.get('schedules', [])
if not schedules:
return None
current_minutes = self._get_current_minutes()
# Sort schedules by time
sorted_schedules = []
for schedule in schedules:
schedule_minutes = self._parse_time(schedule['time'])
if schedule_minutes is not None:
sorted_schedules.append((schedule_minutes, schedule))
sorted_schedules.sort()
# Find the most recent schedule that has passed
active_schedule = None
for schedule_minutes, schedule in sorted_schedules:
if current_minutes >= schedule_minutes:
active_schedule = schedule
else:
break
# If no schedule found (before first schedule), use last schedule from yesterday
if active_schedule is None and sorted_schedules:
active_schedule = sorted_schedules[-1][1]
return active_schedule
def _apply_schedule(self, schedule):
"""Apply a schedule's settings to the monitors."""
if not schedule:
return
schedule_id = schedule.get('time', '') + schedule.get('name', '')
if schedule_id == self.last_applied_schedule:
return # Already applied
try:
# Track whether we changed persisted values to avoid unnecessary writes
changed = False
# Update AC settings if provided
if 'ac_target' in schedule:
new_ac = float(schedule['ac_target'])
if self.config.get('ac_target') != new_ac:
self.config['ac_target'] = new_ac
changed = True
self.ac_monitor.target_temp = new_ac
if 'ac_swing' in schedule:
new_ac_swing = float(schedule['ac_swing'])
if self.config.get('ac_swing') != new_ac_swing:
self.config['ac_swing'] = new_ac_swing
changed = True
self.ac_monitor.temp_swing = new_ac_swing
# Update heater settings if provided
if 'heater_target' in schedule:
new_ht = float(schedule['heater_target'])
if self.config.get('heater_target') != new_ht:
self.config['heater_target'] = new_ht
changed = True
self.heater_monitor.target_temp = new_ht
if 'heater_swing' in schedule:
new_ht_swing = float(schedule['heater_swing'])
if self.config.get('heater_swing') != new_ht_swing:
self.config['heater_swing'] = new_ht_swing
changed = True
self.heater_monitor.temp_swing = new_ht_swing
# Save updated config only if something changed
if changed:
try:
import json
with open('config.json', 'w') as f:
json.dump(self.config, f)
except Exception as e:
print("⚠️ Could not save config: {}".format(e))
else:
# import once and update module-level webhook config
try:
import scripts.discord_webhook as discord_webhook
discord_webhook.set_config(self.config)
except Exception:
pass
print("✅ Config updated with active schedule targets")
else:
# nothing to persist
try:
import scripts.discord_webhook as discord_webhook
except Exception:
discord_webhook = None
# Log the change
schedule_name = schedule.get('name', 'Unnamed')
print("\n" + "="*50)
print("Schedule Applied: {}".format(schedule_name))
print("="*50)
print("AC Target: {}°F".format(self.ac_monitor.target_temp))
print("Heater Target: {}°F".format(self.heater_monitor.target_temp))
print("="*50 + "\n")
# Send Discord notification (use discord_webhook if available)
try:
if 'discord_webhook' not in locals() or discord_webhook is None:
import scripts.discord_webhook as discord_webhook
message = "🕐 Schedule '{}' applied - AC: {}°F | Heater: {}°F".format(
schedule_name,
self.ac_monitor.target_temp,
self.heater_monitor.target_temp
)
discord_webhook.send_discord_message(message)
except Exception:
pass
self.last_applied_schedule = schedule_id
except Exception as e:
print("Error applying schedule: {}".format(e))
def run(self):
"""Check if schedule needs to be updated."""
# ===== START: Check if temporary hold has expired =====
if not self.config.get('schedule_enabled', False) and not self.config.get('permanent_hold', False):
# In temporary hold mode - check if timer expired
temp_hold_start = self.config.get('temp_hold_start_time') # <-- READ FROM CONFIG NOW
if temp_hold_start is not None:
elapsed = time.time() - temp_hold_start
if elapsed >= self.temp_hold_duration:
# Timer expired - resume automatic scheduling
print("⏰ Temporary hold expired - resuming schedule")
self.config['schedule_enabled'] = True
self.config['temp_hold_start_time'] = None
# Save updated config
try:
import json
with open('config.json', 'w') as f:
json.dump(self.config, f)
except Exception as e:
print("⚠️ Could not save config: {}".format(e))
else:
# ensure in-memory webhook config updated
try:
import scripts.discord_webhook as discord_webhook
discord_webhook.set_config(self.config)
except Exception:
pass
print("✅ Config updated - automatic mode resumed")
# Notify user
try:
import scripts.discord_webhook as discord_webhook
discord_webhook.send_discord_message("⏰ Temporary hold expired - Schedule resumed automatically")
except Exception:
pass
# ===== END: Check if temporary hold has expired =====
# Find and apply active schedule
active_schedule = self._find_active_schedule()
if active_schedule:
self._apply_schedule(active_schedule)
def reload_config(self, new_config):
"""Reload configuration (called when settings are updated via web)."""
self.config = new_config
self.last_applied_schedule = None # Force re-application
print("Schedule configuration reloaded")

View File

@@ -1,7 +1,7 @@
import machine import machine # type: ignore
import onewire import onewire # type: ignore
import ds18x20 import ds18x20 # type: ignore
import time import time # type: ignore
class TemperatureSensor: class TemperatureSensor:
def __init__(self, pin=10, label=None): def __init__(self, pin=10, label=None):
@@ -63,26 +63,3 @@ class TemperatureSensor:
print(f'Error reading temperatures: {e}') print(f'Error reading temperatures: {e}')
return results return results
# Sensor configuration registry
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
}
}
def get_configured_sensors():
"""Return dictionary of configured sensor instances."""
sensors = {}
for key, config in SENSOR_CONFIG.items():
sensors[key] = TemperatureSensor(pin=config['pin'], label=config['label'])
return sensors

1866
Scripts/web_server.py Normal file

File diff suppressed because it is too large Load Diff

45
config.json.Example Normal file
View File

@@ -0,0 +1,45 @@
{
"static_ip": "192.168.1.69",
"subnet": "255.255.255.0",
"gateway": "192.168.1.1",
"dns": "192.168.1.1",
"timezone_offset": -5,
"ssid": " Change_to_wifi_SSID",
"password": "Change_to_wifi_Pasword",
"discord_webhook_url": "https://discord.com/api/webhooks/key/long-Combination_1234-ChangeMe",
"discord_alert_webhook_url": "https://discord.com/api/webhooks/key/long-Combination_1234-ChangeMe",
"ac_target": 77.0,
"ac_swing": 1.0,
"heater_target": 72.0,
"heater_swing": 2.0,
"temp_hold_duration": 3600,
"schedules": [
{
"time": "06:00",
"ac_target": 75.0,
"heater_target": 72.0,
"name": "Morning"
},
{
"time": "12:00",
"ac_target": 75.0,
"heater_target": 72.0,
"name": "Midday"
},
{
"time": "18:00",
"ac_target": 75.0,
"heater_target": 72.0,
"name": "Evening"
},
{
"time": "22:00",
"ac_target": 75.0,
"heater_target": 72.0,
"name": "Night"
}
],
"schedule_enabled": true,
"permanent_hold": false,
"temp_hold_start_time": null
}

516
main.py
View File

@@ -1,65 +1,487 @@
from machine import Pin from machine import Pin, RTC # type: ignore
import time import time # type: ignore
from scripts.networking import connect_wifi import network # type: ignore
from scripts.discord_webhook import send_discord_message import json
from scripts.monitors import TemperatureMonitor, WiFiMonitor, ACMonitor, run_monitors import gc # type: ignore # ADD THIS - for garbage collection
from scripts.temperature_sensor import get_configured_sensors, SENSOR_CONFIG import sys
from scripts.air_conditioning import ACController
# Initialize pins (LED light onboard) # Initialize pins (LED light onboard)
led = Pin("LED", Pin.OUT) led = Pin("LED", Pin.OUT)
led.low() led.low()
# Connect to WiFi # Hard reset WiFi interface before connecting
wifi = connect_wifi(led) print("Initializing WiFi...")
try:
wlan = network.WLAN(network.STA_IF)
wlan.deinit()
time.sleep(2)
print("WiFi interface reset complete")
except Exception as e:
print(f"WiFi reset warning: {e}")
# Send startup message if connected # Import after WiFi reset
from scripts.networking import connect_wifi
# ===== NEW: NTP Sync Function (imports locally) =====
def sync_ntp_time(timezone_offset):
"""
Sync time with NTP server (imports modules locally to save RAM).
Returns True if successful, False otherwise.
"""
try:
# Import ONLY when needed (freed by GC after function ends)
import socket # type: ignore
import struct # type: ignore
NTP_DELTA = 2208988800
host = "pool.ntp.org"
NTP_QUERY = bytearray(48)
NTP_QUERY[0] = 0x1B
# Create socket with timeout
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(3.0) # 3-second timeout
try:
addr = socket.getaddrinfo(host, 123)[0][-1]
s.sendto(NTP_QUERY, addr)
msg = s.recv(48)
val = struct.unpack("!I", msg[40:44])[0]
utc_timestamp = val - NTP_DELTA
# Apply timezone offset
local_timestamp = utc_timestamp + (timezone_offset * 3600)
# Set RTC with local time
tm = time.gmtime(local_timestamp)
RTC().datetime((tm[0], tm[1], tm[2], tm[6] + 1, tm[3], tm[4], tm[5], 0))
return True
finally:
s.close()
except Exception as e:
print("NTP sync failed: {}".format(e))
return False
finally:
# Force garbage collection to free socket/struct modules
gc.collect()
# ===== END: NTP Sync Function =====
# ===== START: Configuration Loading =====
# Load saved settings from config.json file on Pico
def load_config():
"""Load configuration from config.json file."""
try:
with open('config.json', 'r') as f:
config = json.load(f)
print("Loaded saved settings from config.json")
return config
except:
# If file doesn't exist or is corrupted, create default config
print("No saved config found, creating default config.json...")
default_config = {
'static_ip': '192.168.86.43',
'subnet': '255.255.255.0',
'gateway': '192.168.86.1',
'dns': '192.168.86.1',
'timezone_offset': -6, # Timezone offset from UTC (CST=-6, EST=-5, MST=-7, PST=-8, add 1 for DST)
'ac_target': 75.0, # Default AC target temp
'ac_swing': 1.0, # Default AC tolerance (+/- degrees)
'heater_target': 72.0, # Default heater target temp
'heater_swing': 2.0, # Default heater tolerance (+/- degrees)
'temp_hold_duration': 3600, # Default hold duration in seconds (1 hour)
'temp_hold_start_time': None, # No hold active at startup
'schedules': [ # Default 4 schedules
{
'time': '06:00',
'name': 'Morning',
'ac_target': 75.0,
'heater_target': 72.0
},
{
'time': '12:00',
'name': 'Midday',
'ac_target': 75.0,
'heater_target': 72.0
},
{
'time': '18:00',
'name': 'Evening',
'ac_target': 75.0,
'heater_target': 72.0
},
{
'time': '22:00',
'name': 'Night',
'ac_target': 75.0,
'heater_target': 72.0
}
],
'schedule_enabled': True, # Schedules disabled by default (user can enable via web)
'permanent_hold': False # Permanent hold disabled by default
}
# ===== START: Save default config to file =====
try:
with open('config.json', 'w') as f:
json.dump(default_config, f)
print("✅ Default config.json created successfully with 4 sample schedules")
except Exception as e:
print("⚠️ Warning: Could not create config.json: {}".format(e))
print(" (Program will continue with defaults in memory)")
# ===== END: Save default config to file =====
return default_config
# global variables for Discord webhook status
discord_sent = False
discord_send_attempts = 0
pending_discord_message = None
# Load configuration from file
config = load_config()
import scripts.discord_webhook as discord_webhook
# Initialize discord webhook module with loaded config (must be done BEFORE any send_discord_message calls)
discord_webhook.set_config(config)
# Get timezone offset from config (with fallback to -6 if not present)
TIMEZONE_OFFSET = config.get('timezone_offset', -6)
# ===== START: Reset hold modes on startup =====
# Always reset to automatic mode on boot (don't persist hold states)
if 'schedule_enabled' in config:
config['schedule_enabled'] = True # Always enable schedules on boot
if 'permanent_hold' in config:
config['permanent_hold'] = False # Always clear permanent hold on boot
if 'temp_hold_start_time' in config:
config['temp_hold_start_time'] = None # Clear temp hold start time
# Save the reset config immediately
try:
with open('config.json', 'w') as f:
json.dump(config, f)
print("✅ Hold modes reset - Automatic mode active")
except Exception as e:
print("⚠️ Warning: Could not save config reset: {}".format(e))
# ===== END: Reset hold modes on startup =====
# ===== END: Configuration Loading =====
# ===== START: WiFi Connection =====
# Connect to WiFi using credentials from config.json
wifi = connect_wifi(led, config=config)
# Set static IP and print WiFi details
if wifi and wifi.isconnected(): if wifi and wifi.isconnected():
send_discord_message("Pico W online and connected ✅") # Get static IP settings from config
static_ip = config.get('static_ip')
subnet = config.get('subnet')
gateway = config.get('gateway')
dns = config.get('dns')
# Get configured sensors # Apply static IP configuration
sensors = get_configured_sensors() # returns a dict, e.g. {'inside': ..., 'outside': ...} wifi.ifconfig((static_ip, subnet, gateway, dns))
time.sleep(1)
# Print WiFi details for debugging
ifconfig = wifi.ifconfig()
print("\n" + "="*50)
print("WiFi Connected Successfully!")
print("="*50)
print(f"IP Address: {ifconfig[0]}")
print(f"Subnet Mask: {ifconfig[1]}")
print(f"Gateway: {ifconfig[2]}")
print(f"DNS Server: {ifconfig[3]}")
print(f"Web Interface: http://{ifconfig[0]}")
print("="*50 + "\n")
# Try sending Discord webhook NOW, before creating other objects
gc.collect()
ram_free = gc.mem_free()
print(f"DEBUG: Free RAM before Discord send: {ram_free // 1024} KB")
mem_ok = ram_free > 95000
if mem_ok:
ok = discord_webhook.send_discord_message("Pico W online at http://{}".format(ifconfig[0]), debug=False)
if ok:
print("Discord startup notification sent")
discord_sent = True
else:
print("Discord startup notification failed")
pending_discord_message = "Pico W online at http://{}".format(ifconfig[0])
discord_send_attempts = 1
else:
print("Not enough memory for Discord startup notification")
pending_discord_message = "Pico W online at http://{}".format(ifconfig[0])
discord_send_attempts = 1
# ===== Moved to later so discord could fire off startup message hopefully =====
from scripts.monitors import TemperatureMonitor, WiFiMonitor, ACMonitor, HeaterMonitor, run_monitors
from scripts.temperature_sensor import TemperatureSensor
from scripts.air_conditioning import ACController
from scripts.heating import HeaterController
from scripts.web_server import TempWebServer
from scripts.scheduler import ScheduleMonitor
from scripts.memory_check import check_memory_once
# Start web server early so page can load even if time sync is slow
web_server = TempWebServer(port=80)
web_server.start()
# ===== INITIAL NTP SYNC (using function) =====
ntp_synced = False
try:
ntp_synced = sync_ntp_time(TIMEZONE_OFFSET)
if ntp_synced:
print("Time synced with NTP server (UTC{:+d})".format(TIMEZONE_OFFSET))
else:
print("Initial NTP sync failed, will retry in background...")
except Exception as e:
print("Initial NTP sync error: {}".format(e))
# ===== END: INITIAL NTP SYNC =====
else:
# WiFi connection failed
print("\n" + "="*50)
print("WiFi Connection Failed!")
print("="*50 + "\n")
# ===== END: WiFi Connection =====
# ===== START: Sensor Configuration =====
# Define all temperature sensors and their alert thresholds
SENSOR_CONFIG = {
'inside': {
'pin': 10, # GPIO pin for DS18B20 sensor
'label': 'Inside', # Display name
'alert_high': 80.0, # Send alert if temp > 80°F
'alert_low': 70.0 # Send alert if temp < 70°F
},
'outside': {
'pin': 11, # GPIO pin for DS18B20 sensor
'label': 'Outside', # Display name
'alert_high': 85.0, # Send alert if temp > 85°F
'alert_low': 68.0 # Send alert if temp < 68°F
}
}
# Initialize sensors based on configuration
def get_configured_sensors():
"""Return dictionary of configured sensor instances."""
sensors = {}
for key, config in SENSOR_CONFIG.items():
sensors[key] = TemperatureSensor(pin=config['pin'], label=config['label'])
return sensors
# Create sensor instances
sensors = get_configured_sensors()
# ===== END: Sensor Configuration =====
# ===== START: AC Controller Setup =====
# Set up air conditioning relay controller
ac_controller = ACController( ac_controller = ACController(
relay_pin=15, relay_pin=15, # GPIO pin connected to AC relay
min_run_time=30, min_run_time=30, # Minimum seconds AC must run before turning off
min_off_time=5 min_off_time=5 # Minimum seconds AC must be off before turning on again
)
ac_monitor = ACMonitor(
ac_controller=ac_controller,
temp_sensor=sensors['inside'], # <-- This is your inside temperature sensor
target_temp=77.0,
temp_swing=1.0,
interval=30
) )
# Set up monitors # Create AC monitor (automatically controls AC based on temperature)
monitors = [ ac_monitor = ACMonitor(
WiFiMonitor(wifi, led, interval=5, reconnect_cooldown=60), ac_controller=ac_controller,
ac_monitor, temp_sensor=sensors['inside'], # Use inside sensor for AC control
] target_temp=config['ac_target'], # Target temp from config.json
temp_swing=config['ac_swing'], # Tolerance (+/- degrees)
interval=30 # Check temperature every 30 seconds
)
# ===== END: AC Controller Setup =====
# Add temperature monitors from config # ===== START: Heater Controller Setup =====
for key, sensor in sensors.items(): # Set up heating relay controller
config = SENSOR_CONFIG[key] heater_controller = HeaterController(
relay_pin=16, # GPIO pin connected to heater relay
min_run_time=30, # Minimum seconds heater must run before turning off
min_off_time=5 # Minimum seconds heater must be off before turning on again
)
# Inside temp alerts go to separate channel # Create heater monitor (automatically controls heater based on temperature)
send_to_alert_channel = (key == 'inside') heater_monitor = HeaterMonitor(
heater_controller=heater_controller,
temp_sensor=sensors['inside'], # Use inside sensor for heater control
target_temp=config['heater_target'], # Target temp from config.json
temp_swing=config['heater_swing'], # Tolerance (+/- degrees)
interval=30 # Check temperature every 30 seconds
)
# ===== END: Heater Controller Setup =====
monitors.append( # ===== START: Schedule Monitor Setup =====
TemperatureMonitor( # Create schedule monitor (automatically changes temp targets based on time of day)
sensor=sensor, schedule_monitor = ScheduleMonitor(
label=config['label'], ac_monitor=ac_monitor, # Pass AC monitor to control
check_interval=10, # Check temp every 10 seconds heater_monitor=heater_monitor, # Pass heater monitor to control
report_interval=30, # Report/log every 30 seconds config=config, # Pass config with schedules
alert_high=config['alert_high'], interval=60 # Check schedule every 60 seconds
alert_low=config['alert_low'], )
# ===== APPLY ACTIVE SCHEDULE IMMEDIATELY ON STARTUP =====
if config.get('schedule_enabled', False):
try:
# Find and apply the current active schedule
active_schedule = schedule_monitor._find_active_schedule()
if active_schedule:
schedule_monitor._apply_schedule(active_schedule)
print("✅ Active schedule applied on startup: {}".format(
active_schedule.get('name', 'Unnamed')
))
else:
print(" No active schedule found (using manual targets)")
except Exception as e:
print("⚠️ Warning: Could not apply startup schedule: {}".format(e))
else:
print(" Schedules disabled - using manual targets")
# ===== END: APPLY ACTIVE SCHEDULE ON STARTUP =====
# ===== END: Schedule Monitor Setup =====
# ===== START: Print Current Settings =====
# Display loaded configuration for debugging
print("\n" + "="*50)
print("Current Climate Control Settings:")
print("="*50)
print(f"AC Target: {config['ac_target']}°F ± {config['ac_swing']}°F")
print(f"Heater Target: {config['heater_target']}°F ± {config['heater_swing']}°F")
print(f"Schedule: {'Enabled' if config.get('schedule_enabled') else 'Disabled'}")
if config.get('schedules'):
print(f"Schedules: {len(config.get('schedules', []))} configured")
print("="*50 + "\n")
# ===== END: Print Current Settings =====
# ===== START: Startup Memory Check =====
# Check memory usage after all imports and initialization
check_memory_once()
# ===== END: Startup Memory Check =====
print("Starting monitoring loop...")
print("Press Ctrl+C to stop\n")
# Add NTP retry flags (before main loop)
retry_ntp_attempts = 0
max_ntp_attempts = 5 # Try up to 5 times after initial failure
last_ntp_sync = time.time() # Track when we last synced
try:
while True:
# ===== START: Main Loop =====
# Main monitoring loop (runs forever until Ctrl+C)
last_monitor_run = {
"wifi": 0,
"schedule": 0,
"ac": 0,
"heater": 0,
"inside_temp": 0,
"outside_temp": 0,
}
while True:
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", log_file="/temp_logs.csv",
send_alerts_to_separate_channel=send_to_alert_channel send_alerts_to_separate_channel=True
)
) )
inside_monitor.run()
del inside_monitor
gc.collect()
last_monitor_run["inside_temp"] = now
# Main monitoring loop # Outside temperature monitor every 10 seconds (can be stateless)
while True: if now - last_monitor_run["outside_temp"] >= 10:
run_monitors(monitors) from scripts.monitors import TemperatureMonitor
outside_monitor = TemperatureMonitor(
sensor=sensors['outside'],
label=SENSOR_CONFIG['outside']['label'],
check_interval=10,
report_interval=30,
alert_high=SENSOR_CONFIG['outside']['alert_high'],
alert_low=SENSOR_CONFIG['outside']['alert_low'],
log_file="/temp_logs.csv",
send_alerts_to_separate_channel=False
)
outside_monitor.run()
del outside_monitor
gc.collect()
last_monitor_run["outside_temp"] = now
# Web requests (keep web server loaded if needed)
web_server.check_requests(sensors, ac_monitor, heater_monitor, schedule_monitor, config)
gc.collect()
time.sleep(0.1) time.sleep(0.1)
# ===== 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)

View File

@@ -1,6 +0,0 @@
secrets = {
'ssid': ' Change_to_wifi_SSID',
'password': 'Change_to_wifi_Pasword',
'discord_webhook_url': 'https://discord.com/api/webhooks/key/long-Combination_1234-ChangeMe', # normal updates
'discord_alert_webhook_url': 'https://discord.com/api/webhooks/key/long-Combination_1234-ChangeMe', # alerts only
}