Compare commits
80 Commits
81137a4c5e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cfa36e558 | |||
| c8102e62ee | |||
| d76b11430c | |||
| cb274545a3 | |||
| 6cd1349633 | |||
| bcecf2a81a | |||
| 621a48f011 | |||
| ce816af9e7 | |||
| 519cb25038 | |||
| f81d89980b | |||
| 7fc7661dad | |||
| 3b7982a3a3 | |||
| 697f0bf31e | |||
| b632a76d5a | |||
| d670067b89 | |||
| ac860207d9 | |||
| 03b26b5339 | |||
| 5a8d14eb4d | |||
| 79445bf879 | |||
| 4400fb5a74 | |||
| c6f46e097b | |||
| d2c0f68488 | |||
| 13e3a56fa6 | |||
| efea4a1384 | |||
| 73b5a5aefe | |||
| 03766d6b09 | |||
| e5f9331d30 | |||
| 6128e585b8 | |||
| 81174b78e4 | |||
| 70cc2cad81 | |||
| 6bc7b1da93 | |||
| eceee9c88d | |||
| 72eb3c2acf | |||
| eff69cfe52 | |||
| 63588ee3f1 | |||
| 8363406647 | |||
| df08692726 | |||
| 0030e0a932 | |||
| d95f212d2e | |||
| 0f7c4cc4d7 | |||
| a9641947ba | |||
| 63ff2cec77 | |||
| 6890d0570e | |||
| a20bbd7cdf | |||
| 7edd209abe | |||
| 2c39ebd985 | |||
| 1016e96b58 | |||
| b3c56864ac | |||
| 95e159ee5d | |||
| 5da44e1397 | |||
| b346be9431 | |||
| 229bde85e9 | |||
| dae6971112 | |||
| 3c2e936d56 | |||
| 9da21f7c89 | |||
| b6aae121bb | |||
| 24b53b9446 | |||
| 749eb956a5 | |||
| 5ce7cd43a4 | |||
| bb46a69eba | |||
| b018b427f6 | |||
| 99d92a6e90 | |||
| b712c19740 | |||
| 9c7ca86d86 | |||
| 6ac3407cc2 | |||
| 68b0351e9d | |||
| 74b0d80717 | |||
| baa6382fba | |||
| b9b67f685a | |||
| 64a5d0ae7e | |||
| 3e926f997b | |||
| 299a0abbc9 | |||
| 988bec521f | |||
| b93809946a | |||
| f4c9e20836 | |||
| 9fda192f0b | |||
| b8336f82c8 | |||
| a0fe76abc4 | |||
| 050841dd78 | |||
| 39a4952426 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
/__pycache__/
|
/__pycache__/
|
||||||
secrets.py
|
secrets.py
|
||||||
|
config.json
|
||||||
/pymakr-test/
|
/pymakr-test/
|
||||||
.gitignore
|
.gitignore
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
436
README.md
436
README.md
@@ -1,24 +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 AC control for climate management.
|
- 🆕 **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**
|
||||||
|
- ✅ WiFi connectivity with auto-reconnect and static IP
|
||||||
- ✅ Inside/Outside temperature monitoring (DS18B20 sensors)
|
- ✅ Inside/Outside temperature monitoring (DS18B20 sensors)
|
||||||
- ✅ Discord notifications for temperature readings
|
- ✅ Web interface for monitoring and configuration <http://192.168.x.x>
|
||||||
- ✅ Separate alert channel for critical temperatures
|
- ✅ Discord notifications for all system events
|
||||||
- ✅ Temperature logging to CSV file
|
- ✅ Temperature logging to CSV file
|
||||||
- ✅ Configurable alert thresholds
|
- ✅ Configurable alert thresholds
|
||||||
|
- ✅ Exception recovery (system won't crash permanently)
|
||||||
|
- ✅ Graceful shutdown with Ctrl+C
|
||||||
|
- ✅ **Aggressive garbage collection for stability**
|
||||||
|
|
||||||
|
- **Climate Control**
|
||||||
- ✅ Automated AC control with temperature swing logic
|
- ✅ Automated AC control with temperature swing logic
|
||||||
- ✅ Relay control via opto-coupler for 110V AC
|
- ✅ Automated heater control with separate swing settings
|
||||||
- 🚧 Humidity monitoring (planned)
|
- ✅ Short-cycle protection for both AC and heater
|
||||||
- 🚧 Soil moisture monitoring (planned)
|
- ✅ Dual relay control via opto-coupler for 110V AC
|
||||||
- 🚧 Additional relay control for fans, heaters (planned)
|
- ✅ 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 schedule’s 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
|
||||||
|
|
||||||
@@ -27,9 +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)
|
||||||
- Opto-coupler relay module (3.3V logic, 110V AC rated)
|
- 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.
|
||||||
@@ -49,27 +98,32 @@ 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. While it may work without it occasionally, you'll experience intermittent failures, communication errors, and unreliable readings.
|
**⚠️ Important:** The 4.7kΩ pull-up resistor is **required** for reliable 1-Wire communication.
|
||||||
|
|
||||||
**Opto-Coupler Relay Module:**
|
**2-Channel Opto-Coupler Relay Module:**
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Low Voltage Side (Pico):
|
Low Voltage Side (Pico):
|
||||||
GP15 (Pin 20) → IN (Signal)
|
GP15 (Pin 20) → IN1 (AC Control Signal)
|
||||||
|
GP14 (Pin 19) → IN2 (Heater Control Signal)
|
||||||
3.3V (Pin 36) → VCC
|
3.3V (Pin 36) → VCC
|
||||||
GND → GND
|
GND → GND
|
||||||
|
|
||||||
High Voltage Side (AC Unit):
|
High Voltage Side - Relay 1 (AC Unit):
|
||||||
NO (Normally Open) → AC Control Wire 1
|
NO (Normally Open) → AC Control Wire 1
|
||||||
COM (Common) → AC Control Wire 2
|
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:
|
**Note:** Most opto-coupler modules work with standard logic:
|
||||||
|
|
||||||
- `relay.on()` = relay energized (NO closes) = AC ON
|
- `relay.on()` = relay energized (NO closes) = Device ON
|
||||||
- `relay.off()` = relay de-energized (NC closes) = AC OFF
|
- `relay.off()` = relay de-energized (NC closes) = Device OFF
|
||||||
|
|
||||||
If your AC behavior is inverted (turns on when it should be off), your module may be active LOW—see troubleshooting section.
|
If behavior is inverted, your module may be active LOW—see troubleshooting.
|
||||||
|
|
||||||
**Optional Reset Button:**
|
**Optional Reset Button:**
|
||||||
|
|
||||||
@@ -77,8 +131,6 @@ If your AC behavior is inverted (turns on when it should be off), your module ma
|
|||||||
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:**
|
||||||
@@ -89,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
|
||||||
@@ -105,7 +157,7 @@ secrets = {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Configure sensors and AC in `main.py`:**
|
**Sensor Configuration in `main.py`:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Sensor configuration
|
# Sensor configuration
|
||||||
@@ -123,17 +175,33 @@ SENSOR_CONFIG = {
|
|||||||
'alert_low': 68.0
|
'alert_low': 68.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# AC Controller options
|
|
||||||
relay_pin = 15
|
|
||||||
min_run_time = 300 # Minimum 5 minutes run time
|
|
||||||
min_off_time = 180 # Minimum 3 minutes off time
|
|
||||||
|
|
||||||
# AC Monitor options
|
|
||||||
target_temp = 75.0 # Target temperature in °F
|
|
||||||
temp_swing = 2.0 # ±2°F swing (AC on at 77°F, off at 73°F)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**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:
|
||||||
@@ -142,190 +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
|
├── 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, configuration, monitor setup
|
├── 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 controller with short-cycle protection
|
├── air_conditioning.py # AC & Heater controllers with short-cycle protection
|
||||||
├── discord_webhook.py # Discord notification handling
|
├── discord_webhook.py # Discord notification handling
|
||||||
├── monitors.py # Monitor base class & implementations
|
├── monitors.py # Monitor base class & implementations
|
||||||
├── networking.py # WiFi connection management
|
├── networking.py # WiFi connection management
|
||||||
└── temperature_sensor.py # DS18B20 sensor interface
|
├── scheduler.py # Schedule system with temporary/permanent hold modes
|
||||||
|
├── temperature_sensor.py # DS18B20 sensor interface
|
||||||
|
└── web_server.py # Web interface for monitoring and control
|
||||||
```
|
```
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
### Temperature Monitoring
|
### Temperature Monitoring
|
||||||
|
|
||||||
- **Every 10 seconds:** Check temperatures, send alerts if out of range
|
- **Every 10 seconds:** Check temperatures
|
||||||
- **Every 30 seconds:** Regular temperature reports to Discord + log to file
|
- **Every 30 seconds:** Send temperature reports to Discord + log to CSV
|
||||||
|
- **Instant alerts:** High/low temperature warnings to separate Discord channel
|
||||||
|
|
||||||
**Discord Channels:**
|
**Discord Notifications:**
|
||||||
|
|
||||||
- `discord_webhook_url`: Regular temperature updates, connection status
|
- `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)
|
||||||
|
|
||||||
### AC Control Logic
|
**Example Discord Messages:**
|
||||||
|
|
||||||
- **Every 30 seconds:** Check inside temperature and decide AC state
|
```text
|
||||||
- **Temperature swing:** Creates a "dead band" to prevent rapid cycling
|
📊 Inside: 75.2°F | AC: OFF | Heater: OFF
|
||||||
- Example: Target 75°F with 2°F swing
|
📊 Outside: 68.5°F | AC: OFF | Heater: OFF
|
||||||
- AC turns **ON** when temp > 77°F
|
🔥 Inside temp HIGH: 81.0°F
|
||||||
- AC turns **OFF** when temp < 73°F
|
Schedule 'Morning' applied - AC: 75°F, Heater: 72°F
|
||||||
- Between 73-77°F: maintains current state
|
⏸️ 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:**
|
**Short-Cycle Protection:**
|
||||||
|
|
||||||
- Minimum run time (default 5 min) prevents AC from turning off too quickly
|
- Minimum run time: 30 seconds (prevents rapid off)
|
||||||
- Minimum off time (default 3 min) protects compressor from rapid restarts
|
- 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
|
### WiFi Monitoring
|
||||||
|
|
||||||
- **Every 5 seconds:** Check WiFi connection status
|
- **Every 5 seconds:** Check WiFi connection
|
||||||
- **LED Indicator:**
|
- **LED Indicator:**
|
||||||
- Slow blink (1 sec on/off): Connected
|
- Solid ON: Connected
|
||||||
- Fast blink (0.2 sec): Disconnected
|
- Blinking: Reconnecting
|
||||||
- **Auto-reconnect:** Attempts reconnection every 60 seconds if disconnected
|
- **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`
|
||||||
|
|
||||||
## Customization
|
## Customization
|
||||||
|
|
||||||
All configuration is centralized in `main.py`:
|
### Via Web Interface (Recommended)
|
||||||
|
|
||||||
**Sensor Settings:**
|
- Navigate to <http://192.168.x.x>
|
||||||
|
- Adjust AC/Heater targets and swing values
|
||||||
|
- Edit schedules (times, names, targets)
|
||||||
|
- Settings persist through reboots
|
||||||
|
|
||||||
- Pin assignments
|
### Via config.json
|
||||||
- Alert thresholds (high/low)
|
|
||||||
- Labels
|
|
||||||
|
|
||||||
**AC Settings:**
|
```json
|
||||||
|
{
|
||||||
|
"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": [ /* ... */ ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
- Relay pin
|
### Via main.py (Advanced)
|
||||||
- Target temperature
|
|
||||||
- Temperature swing (dead band)
|
|
||||||
- Minimum run/off times
|
|
||||||
|
|
||||||
**Monitor Intervals:**
|
```python
|
||||||
|
# Relay pins
|
||||||
|
ac_relay_pin = 15
|
||||||
|
heater_relay_pin = 14
|
||||||
|
|
||||||
- Temperature check/report intervals
|
# Sensor pins
|
||||||
- WiFi check interval
|
SENSOR_CONFIG = {
|
||||||
- AC control interval
|
'inside': {'pin': 10, ...},
|
||||||
|
'outside': {'pin': 11, ...}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Monitor intervals
|
||||||
|
check_interval=10 # Temperature check frequency
|
||||||
|
report_interval=30 # Discord report frequency
|
||||||
|
```
|
||||||
|
|
||||||
## Safety Notes
|
## Safety Notes
|
||||||
|
|
||||||
⚠️ **High Voltage Warning:**
|
⚠️ **High Voltage Warning:**
|
||||||
|
|
||||||
- Opto-couplers isolate the Pico from AC voltage
|
- Opto-couplers isolate Pico from AC voltage
|
||||||
- Never connect GPIO pins directly to 110V AC
|
- Never connect GPIO directly to 110V AC
|
||||||
- Ensure your opto-coupler module is rated for your voltage
|
- Ensure relay module is rated for your voltage
|
||||||
- Test relay switching with a multimeter before connecting AC
|
- Test with multimeter before connecting AC loads
|
||||||
- Consider hiring a licensed electrician if uncomfortable with AC wiring
|
- Consider hiring licensed electrician if uncomfortable
|
||||||
|
|
||||||
**Compressor Protection:**
|
**Compressor/Heater Protection:**
|
||||||
|
|
||||||
- Always use minimum run/off times (defaults are safe)
|
- Always use minimum run/off times
|
||||||
- Minimum 3 minutes off time protects compressor bearings
|
- Minimum 5s off time protects compressor bearings
|
||||||
- Minimum 5 minutes run time prevents short cycling
|
- Minimum 30s run time prevents short cycling
|
||||||
|
- AC and heater mutual exclusion prevents simultaneous operation
|
||||||
|
|
||||||
## Future Expansion
|
**System Reliability:**
|
||||||
|
|
||||||
### Planned Features
|
- Exception recovery prevents permanent crashes
|
||||||
|
- Graceful shutdown (Ctrl+C) safely turns off AC/heater
|
||||||
- **Humidity Sensors:** DHT22 or SHT31 for air humidity monitoring
|
- Hold modes reset on reboot (schedules always resume)
|
||||||
- **Soil Moisture:** Capacitive sensors for plant watering automation
|
- Static IP ensures web interface always accessible
|
||||||
- **Additional Relays:** Control for fans, heaters, grow lights
|
|
||||||
- **Smart Ventilation:** Auto-open windows when outside air is optimal
|
|
||||||
- **Light Monitoring:** LDR or BH1750 for day/night cycles
|
|
||||||
|
|
||||||
### Adding More Sensors
|
|
||||||
|
|
||||||
To add a new temperature sensor:
|
|
||||||
|
|
||||||
First Add to `SENSOR_CONFIG` in `main.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
'greenhouse': {
|
|
||||||
'pin': 12,
|
|
||||||
'label': 'Greenhouse',
|
|
||||||
'alert_high': 90.0,
|
|
||||||
'alert_low': 50.0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Second Add a `TemperatureMonitor` to the monitors list:
|
|
||||||
|
|
||||||
```python
|
|
||||||
TemperatureMonitor(
|
|
||||||
sensor=sensors['greenhouse'],
|
|
||||||
label=SENSOR_CONFIG['greenhouse']['label'],
|
|
||||||
check_interval=10,
|
|
||||||
report_interval=30,
|
|
||||||
alert_high=SENSOR_CONFIG['greenhouse']['alert_high'],
|
|
||||||
alert_low=SENSOR_CONFIG['greenhouse']['alert_low'],
|
|
||||||
log_file="/temp_logs.csv",
|
|
||||||
send_alerts_to_separate_channel=False
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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 between data line and 3.3V
|
- 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()` to detect connected sensors
|
- 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
|
||||||
- LED should blink slowly 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
|
- Check Pico has internet access (ping test)
|
||||||
|
- Look for error messages in serial console
|
||||||
|
|
||||||
**AC not switching:**
|
**AC/Heater not switching:**
|
||||||
|
|
||||||
- Verify relay pin number (default GP15)
|
- Verify relay pin numbers (default GP15/GP14)
|
||||||
- Test relay manually: `Pin(15, Pin.OUT).off()` should activate it
|
- Test relay manually in REPL: `Pin(15, Pin.OUT).on()`
|
||||||
- Check if module is active LOW or active HIGH
|
- Check if module is active LOW or active HIGH
|
||||||
- Ensure opto-coupler has 3.3V power
|
- Ensure opto-coupler has 3.3V power
|
||||||
- Look for LED on relay module (should light when active)
|
- Look for LED on relay module (should light when active)
|
||||||
- Verify minimum run/off times haven't locked out switching
|
- Check minimum run/off times haven't locked out switching
|
||||||
|
|
||||||
**AC behavior inverted:**
|
**AC/Heater behavior inverted:**
|
||||||
|
|
||||||
- Your opto-coupler is likely active LOW
|
- Your opto-coupler is active LOW
|
||||||
- In `air_conditioning.py`, swap `relay.on()` and `relay.off()` calls
|
- 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
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from machine import Pin
|
from machine import Pin # type: ignore
|
||||||
import time
|
import time # type: ignore
|
||||||
|
|
||||||
class HeaterController:
|
class HeaterController:
|
||||||
"""Control heater via opto-coupler relay."""
|
"""Control heater via opto-coupler relay."""
|
||||||
|
|||||||
@@ -1,71 +1,4 @@
|
|||||||
import gc
|
import gc # type: ignore
|
||||||
|
|
||||||
class MemoryMonitor:
|
|
||||||
"""Monitor and display Pico W memory usage."""
|
|
||||||
|
|
||||||
def __init__(self, interval=300):
|
|
||||||
"""
|
|
||||||
Initialize memory monitor.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
interval: Seconds between memory checks (default: 300 = 5 minutes)
|
|
||||||
"""
|
|
||||||
self.interval = interval
|
|
||||||
self.last_check = 0
|
|
||||||
self.initial_free = None # Track initial free memory
|
|
||||||
|
|
||||||
def should_run(self, current_time):
|
|
||||||
"""Check if it's time to run memory check."""
|
|
||||||
if current_time - self.last_check >= self.interval:
|
|
||||||
self.last_check = current_time
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
"""Check and display memory usage."""
|
|
||||||
# Force garbage collection before checking
|
|
||||||
gc.collect()
|
|
||||||
|
|
||||||
# Get memory stats
|
|
||||||
free = gc.mem_free()
|
|
||||||
allocated = gc.mem_alloc()
|
|
||||||
total = free + allocated
|
|
||||||
|
|
||||||
# Store initial free memory on first run
|
|
||||||
if self.initial_free is None:
|
|
||||||
self.initial_free = free
|
|
||||||
|
|
||||||
# Calculate memory leak (if any)
|
|
||||||
leaked = self.initial_free - free if self.initial_free else 0
|
|
||||||
|
|
||||||
# Print memory report
|
|
||||||
print("\n" + "="*50)
|
|
||||||
print("Pico W Memory (RAM):")
|
|
||||||
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
|
|
||||||
))
|
|
||||||
|
|
||||||
# Show memory leak warning if detected
|
|
||||||
if leaked > 5120: # More than 5 KB leaked
|
|
||||||
print("⚠️ Leaked: {:.1f} KB since startup".format(leaked / 1024))
|
|
||||||
|
|
||||||
print("="*50 + "\n")
|
|
||||||
|
|
||||||
# Return memory info for other uses (like web server)
|
|
||||||
return {
|
|
||||||
'total_kb': total / 1024,
|
|
||||||
'used_kb': allocated / 1024,
|
|
||||||
'free_kb': free / 1024,
|
|
||||||
'usage_percent': (allocated/total)*100,
|
|
||||||
'leaked_kb': leaked / 1024 if leaked > 0 else 0
|
|
||||||
}
|
|
||||||
|
|
||||||
def check_memory_once():
|
def check_memory_once():
|
||||||
"""One-time memory check (for startup diagnostics)."""
|
"""One-time memory check (for startup diagnostics)."""
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -55,6 +55,8 @@ class TemperatureMonitor(Monitor):
|
|||||||
self.last_report = 0
|
self.last_report = 0
|
||||||
self.alert_sent = False
|
self.alert_sent = False
|
||||||
self.alert_start_time = None # Track when alert started
|
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):
|
def should_run(self):
|
||||||
"""Check if it's time to run this monitor."""
|
"""Check if it's time to run this monitor."""
|
||||||
@@ -75,6 +77,16 @@ class TemperatureMonitor(Monitor):
|
|||||||
|
|
||||||
temp = list(temps.values())[0] # Get first temp reading
|
temp = list(temps.values())[0] # Get first temp reading
|
||||||
|
|
||||||
|
# ===== ADD THIS: Validate temperature is reasonable =====
|
||||||
|
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 =====
|
||||||
|
|
||||||
|
# Cache the reading for web server (avoid blocking reads)
|
||||||
|
self.last_temp = temp
|
||||||
|
self.last_read_time = current_time
|
||||||
|
|
||||||
# Check for alerts
|
# Check for alerts
|
||||||
alert_condition = False
|
alert_condition = False
|
||||||
alert_message = ""
|
alert_message = ""
|
||||||
@@ -96,13 +108,11 @@ class TemperatureMonitor(Monitor):
|
|||||||
self.alert_start_time = current_time
|
self.alert_start_time = current_time
|
||||||
print(alert_message)
|
print(alert_message)
|
||||||
|
|
||||||
# Send to appropriate Discord channel
|
# send alert (use module-level discord_webhook; set_config must be called in main)
|
||||||
if self.send_alerts_to_separate_channel:
|
if self.send_alerts_to_separate_channel:
|
||||||
from scripts.discord_webhook import send_alert_message
|
discord_webhook.send_discord_message(alert_message, is_alert=True)
|
||||||
send_alert_message(alert_message)
|
|
||||||
else:
|
else:
|
||||||
from scripts.discord_webhook import send_discord_message
|
discord_webhook.send_discord_message(alert_message)
|
||||||
send_discord_message(alert_message)
|
|
||||||
|
|
||||||
self.alert_sent = True
|
self.alert_sent = True
|
||||||
|
|
||||||
@@ -127,13 +137,11 @@ class TemperatureMonitor(Monitor):
|
|||||||
)
|
)
|
||||||
print(recovery_message)
|
print(recovery_message)
|
||||||
|
|
||||||
# Send to appropriate Discord channel
|
# send recovery message
|
||||||
if self.send_alerts_to_separate_channel:
|
if self.send_alerts_to_separate_channel:
|
||||||
from scripts.discord_webhook import send_alert_message
|
discord_webhook.send_discord_message(recovery_message, is_alert=True)
|
||||||
send_alert_message(recovery_message)
|
|
||||||
else:
|
else:
|
||||||
from scripts.discord_webhook import send_discord_message
|
discord_webhook.send_discord_message(recovery_message)
|
||||||
send_discord_message(recovery_message)
|
|
||||||
|
|
||||||
self.alert_sent = False
|
self.alert_sent = False
|
||||||
self.alert_start_time = None
|
self.alert_start_time = None
|
||||||
@@ -186,14 +194,14 @@ 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
|
self.last_notified_state = False
|
||||||
|
|
||||||
# Else: within temp_swing range, maintain current state
|
# Else: within temp_swing range, maintain current state
|
||||||
@@ -232,31 +240,32 @@ class HeaterMonitor(Monitor):
|
|||||||
# Too cold, turn heater on
|
# Too cold, turn heater on
|
||||||
if self.heater.turn_on():
|
if self.heater.turn_on():
|
||||||
if not self.last_notified_state:
|
if not self.last_notified_state:
|
||||||
send_discord_message(f"🔥 Heater turned ON - Current: {current_temp:.1f}°F, Target: {self.target_temp:.1f}°F")
|
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
|
self.last_notified_state = True
|
||||||
|
|
||||||
elif current_temp > (self.target_temp + self.temp_swing):
|
elif current_temp > (self.target_temp + self.temp_swing):
|
||||||
# Warm enough, turn heater off
|
# Warm enough, turn heater off
|
||||||
if self.heater.turn_off():
|
if self.heater.turn_off():
|
||||||
if self.last_notified_state:
|
if self.last_notified_state:
|
||||||
send_discord_message(f"✅ Heater turned OFF - Current: {current_temp:.1f}°F, Target: {self.target_temp:.1f}°F")
|
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
|
||||||
@@ -272,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
|
||||||
@@ -285,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):
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
import network
|
import network # type: ignore
|
||||||
import time
|
import time # type: ignore
|
||||||
from secrets import secrets
|
|
||||||
|
|
||||||
def connect_wifi(led=None, max_retries=3, timeout=20):
|
def connect_wifi(led=None, max_retries=3, timeout=20, config=None):
|
||||||
"""
|
"""
|
||||||
Connect to WiFi using credentials from secrets.py
|
Connect to WiFi using credentials from provided config dict.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
led: Optional LED pin for visual feedback
|
led: Optional LED pin for visual feedback
|
||||||
max_retries: Number of connection attempts (default: 3)
|
max_retries: Number of connection attempts (default: 3)
|
||||||
timeout: Seconds to wait for connection per attempt (default: 20)
|
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:
|
Returns:
|
||||||
WLAN object if connected, None if failed
|
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)
|
wlan = network.WLAN(network.STA_IF)
|
||||||
|
|
||||||
# Ensure clean state
|
# Ensure clean state
|
||||||
@@ -41,13 +54,13 @@ def connect_wifi(led=None, max_retries=3, timeout=20):
|
|||||||
# Try connecting with retries
|
# Try connecting with retries
|
||||||
for attempt in range(1, max_retries + 1):
|
for attempt in range(1, max_retries + 1):
|
||||||
if wlan.isconnected():
|
if wlan.isconnected():
|
||||||
print(f"Already connected to WiFi")
|
print("Already connected to WiFi")
|
||||||
break
|
break
|
||||||
|
|
||||||
print(f'Connecting to WiFi (attempt {attempt}/{max_retries})...')
|
print(f'Connecting to WiFi SSID: {ssid} (attempt {attempt}/{max_retries})...')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
wlan.connect(secrets['ssid'], secrets['password'])
|
wlan.connect(ssid, password)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Connection attempt failed: {e}")
|
print(f"Connection attempt failed: {e}")
|
||||||
if attempt < max_retries:
|
if attempt < max_retries:
|
||||||
@@ -62,7 +75,17 @@ def connect_wifi(led=None, max_retries=3, timeout=20):
|
|||||||
break
|
break
|
||||||
|
|
||||||
if led:
|
if led:
|
||||||
|
try:
|
||||||
|
# some LED wrappers use toggle(), others use on/off
|
||||||
|
if hasattr(led, "toggle"):
|
||||||
led.toggle()
|
led.toggle()
|
||||||
|
else:
|
||||||
|
# flash quickly to show activity
|
||||||
|
led.on()
|
||||||
|
time.sleep(0.05)
|
||||||
|
led.off()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
wait_time += 0.5
|
wait_time += 0.5
|
||||||
@@ -85,17 +108,24 @@ def connect_wifi(led=None, max_retries=3, timeout=20):
|
|||||||
if not wlan.isconnected():
|
if not wlan.isconnected():
|
||||||
print('WiFi connection failed after all attempts!')
|
print('WiFi connection failed after all attempts!')
|
||||||
if led:
|
if led:
|
||||||
|
try:
|
||||||
|
# prefer available method names
|
||||||
|
if hasattr(led, "off"):
|
||||||
led.off()
|
led.off()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Success feedback
|
# Success feedback
|
||||||
if led:
|
if led:
|
||||||
# Double pulse on successful connection
|
try:
|
||||||
for _ in range(2):
|
for _ in range(2):
|
||||||
led.on()
|
led.on()
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
led.off()
|
led.off()
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
print('Connected to WiFi successfully!')
|
print('Connected to WiFi successfully!')
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import time
|
import time # type: ignore
|
||||||
|
|
||||||
class ScheduleMonitor:
|
class ScheduleMonitor:
|
||||||
"""Monitor that checks and applies temperature schedules."""
|
"""Monitor that checks and applies temperature schedules."""
|
||||||
@@ -20,6 +20,7 @@ class ScheduleMonitor:
|
|||||||
self.last_check = 0
|
self.last_check = 0
|
||||||
self.current_schedule = None
|
self.current_schedule = None
|
||||||
self.last_applied_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):
|
def should_run(self):
|
||||||
"""Check if it's time to run this monitor."""
|
"""Check if it's time to run this monitor."""
|
||||||
@@ -84,25 +85,66 @@ class ScheduleMonitor:
|
|||||||
if not schedule:
|
if not schedule:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if this is a different schedule than last applied
|
|
||||||
schedule_id = schedule.get('time', '') + schedule.get('name', '')
|
schedule_id = schedule.get('time', '') + schedule.get('name', '')
|
||||||
if schedule_id == self.last_applied_schedule:
|
if schedule_id == self.last_applied_schedule:
|
||||||
return # Already applied
|
return # Already applied
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Track whether we changed persisted values to avoid unnecessary writes
|
||||||
|
changed = False
|
||||||
|
|
||||||
# Update AC settings if provided
|
# Update AC settings if provided
|
||||||
if 'ac_target' in schedule:
|
if 'ac_target' in schedule:
|
||||||
self.ac_monitor.target_temp = float(schedule['ac_target'])
|
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:
|
if 'ac_swing' in schedule:
|
||||||
self.ac_monitor.temp_swing = float(schedule['ac_swing'])
|
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
|
# Update heater settings if provided
|
||||||
if 'heater_target' in schedule:
|
if 'heater_target' in schedule:
|
||||||
self.heater_monitor.target_temp = float(schedule['heater_target'])
|
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:
|
if 'heater_swing' in schedule:
|
||||||
self.heater_monitor.temp_swing = float(schedule['heater_swing'])
|
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
|
# Log the change
|
||||||
schedule_name = schedule.get('name', 'Unnamed')
|
schedule_name = schedule.get('name', 'Unnamed')
|
||||||
@@ -113,16 +155,17 @@ class ScheduleMonitor:
|
|||||||
print("Heater Target: {}°F".format(self.heater_monitor.target_temp))
|
print("Heater Target: {}°F".format(self.heater_monitor.target_temp))
|
||||||
print("="*50 + "\n")
|
print("="*50 + "\n")
|
||||||
|
|
||||||
# Send Discord notification
|
# Send Discord notification (use discord_webhook if available)
|
||||||
try:
|
try:
|
||||||
from scripts.discord_webhook import send_discord_message
|
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(
|
message = "🕐 Schedule '{}' applied - AC: {}°F | Heater: {}°F".format(
|
||||||
schedule_name,
|
schedule_name,
|
||||||
self.ac_monitor.target_temp,
|
self.ac_monitor.target_temp,
|
||||||
self.heater_monitor.target_temp
|
self.heater_monitor.target_temp
|
||||||
)
|
)
|
||||||
send_discord_message(message)
|
discord_webhook.send_discord_message(message)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self.last_applied_schedule = schedule_id
|
self.last_applied_schedule = schedule_id
|
||||||
@@ -132,6 +175,46 @@ class ScheduleMonitor:
|
|||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Check if schedule needs to be updated."""
|
"""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
|
# Find and apply active schedule
|
||||||
active_schedule = self._find_active_schedule()
|
active_schedule = self._find_active_schedule()
|
||||||
if active_schedule:
|
if active_schedule:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,18 @@
|
|||||||
{
|
{
|
||||||
|
"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_target": 77.0,
|
||||||
"ac_swing": 1.0,
|
"ac_swing": 1.0,
|
||||||
"heater_target": 72.0,
|
"heater_target": 72.0,
|
||||||
"heater_swing": 2.0,
|
"heater_swing": 2.0,
|
||||||
|
"temp_hold_duration": 3600,
|
||||||
"schedules": [
|
"schedules": [
|
||||||
{
|
{
|
||||||
"time": "06:00",
|
"time": "06:00",
|
||||||
@@ -30,5 +40,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"schedule_enabled": true,
|
"schedule_enabled": true,
|
||||||
"permanent_hold": false
|
"permanent_hold": false,
|
||||||
|
"temp_hold_start_time": null
|
||||||
}
|
}
|
||||||
357
main.py
357
main.py
@@ -1,8 +1,9 @@
|
|||||||
from machine import Pin
|
from machine import Pin, RTC # type: ignore
|
||||||
import time
|
import time # type: ignore
|
||||||
import network
|
import network # type: ignore
|
||||||
import json
|
import json
|
||||||
import gc # ADD THIS - for garbage collection
|
import gc # type: ignore # ADD THIS - for garbage collection
|
||||||
|
import sys
|
||||||
|
|
||||||
# Initialize pins (LED light onboard)
|
# Initialize pins (LED light onboard)
|
||||||
led = Pin("LED", Pin.OUT)
|
led = Pin("LED", Pin.OUT)
|
||||||
@@ -20,14 +21,53 @@ except Exception as e:
|
|||||||
|
|
||||||
# Import after WiFi reset
|
# Import after WiFi reset
|
||||||
from scripts.networking import connect_wifi
|
from scripts.networking import connect_wifi
|
||||||
from scripts.discord_webhook import send_discord_message
|
|
||||||
from scripts.monitors import TemperatureMonitor, WiFiMonitor, ACMonitor, HeaterMonitor, run_monitors
|
# ===== NEW: NTP Sync Function (imports locally) =====
|
||||||
from scripts.temperature_sensor import TemperatureSensor
|
def sync_ntp_time(timezone_offset):
|
||||||
from scripts.air_conditioning import ACController
|
"""
|
||||||
from scripts.heating import HeaterController
|
Sync time with NTP server (imports modules locally to save RAM).
|
||||||
from scripts.web_server import TempWebServer
|
Returns True if successful, False otherwise.
|
||||||
from scripts.scheduler import ScheduleMonitor # NEW: Import scheduler for time-based temp changes
|
"""
|
||||||
from scripts.memory_check import MemoryMonitor, check_memory_once # NEW: Import memory checker
|
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 =====
|
# ===== START: Configuration Loading =====
|
||||||
# Load saved settings from config.json file on Pico
|
# Load saved settings from config.json file on Pico
|
||||||
@@ -43,10 +83,17 @@ def load_config():
|
|||||||
print("No saved config found, creating default config.json...")
|
print("No saved config found, creating default config.json...")
|
||||||
|
|
||||||
default_config = {
|
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_target': 75.0, # Default AC target temp
|
||||||
'ac_swing': 1.0, # Default AC tolerance (+/- degrees)
|
'ac_swing': 1.0, # Default AC tolerance (+/- degrees)
|
||||||
'heater_target': 72.0, # Default heater target temp
|
'heater_target': 72.0, # Default heater target temp
|
||||||
'heater_swing': 2.0, # Default heater tolerance (+/- degrees)
|
'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
|
'schedules': [ # Default 4 schedules
|
||||||
{
|
{
|
||||||
'time': '06:00',
|
'time': '06:00',
|
||||||
@@ -73,7 +120,7 @@ def load_config():
|
|||||||
'heater_target': 72.0
|
'heater_target': 72.0
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
'schedule_enabled': False, # Schedules disabled by default (user can enable via web)
|
'schedule_enabled': True, # Schedules disabled by default (user can enable via web)
|
||||||
'permanent_hold': False # Permanent hold disabled by default
|
'permanent_hold': False # Permanent hold disabled by default
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,21 +136,50 @@ def load_config():
|
|||||||
|
|
||||||
return default_config
|
return default_config
|
||||||
|
|
||||||
|
# global variables for Discord webhook status
|
||||||
|
discord_sent = False
|
||||||
|
discord_send_attempts = 0
|
||||||
|
pending_discord_message = None
|
||||||
|
|
||||||
# Load configuration from file
|
# Load configuration from file
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
import scripts.discord_webhook as discord_webhook
|
||||||
|
# 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 =====
|
# ===== END: Configuration Loading =====
|
||||||
|
|
||||||
# ===== START: WiFi Connection =====
|
# ===== START: WiFi Connection =====
|
||||||
# Connect to WiFi using credentials from secrets.py
|
# Connect to WiFi using credentials from config.json
|
||||||
wifi = connect_wifi(led)
|
wifi = connect_wifi(led, config=config)
|
||||||
|
|
||||||
# Set static IP and print WiFi details
|
# Set static IP and print WiFi details
|
||||||
if wifi and wifi.isconnected():
|
if wifi and wifi.isconnected():
|
||||||
# Configure static IP (easier to bookmark web interface)
|
# Get static IP settings from config
|
||||||
static_ip = '192.168.86.43' # Change this to match your network
|
static_ip = config.get('static_ip')
|
||||||
subnet = '255.255.255.0'
|
subnet = config.get('subnet')
|
||||||
gateway = '192.168.86.1' # Usually your router IP
|
gateway = config.get('gateway')
|
||||||
dns = '192.168.86.1' # Usually your router IP
|
dns = config.get('dns')
|
||||||
|
|
||||||
# Apply static IP configuration
|
# Apply static IP configuration
|
||||||
wifi.ifconfig((static_ip, subnet, gateway, dns))
|
wifi.ifconfig((static_ip, subnet, gateway, dns))
|
||||||
@@ -121,19 +197,49 @@ if wifi and wifi.isconnected():
|
|||||||
print(f"Web Interface: http://{ifconfig[0]}")
|
print(f"Web Interface: http://{ifconfig[0]}")
|
||||||
print("="*50 + "\n")
|
print("="*50 + "\n")
|
||||||
|
|
||||||
# Send startup notification to Discord
|
# Try sending Discord webhook NOW, before creating other objects
|
||||||
send_discord_message(f"Pico W online at http://{ifconfig[0]} ✅")
|
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
|
||||||
|
|
||||||
# ===== START: NTP Time Sync =====
|
# ===== Moved to later so discord could fire off startup message hopefully =====
|
||||||
# Sync time with internet time server (required for schedules to work correctly)
|
from scripts.monitors import TemperatureMonitor, WiFiMonitor, ACMonitor, HeaterMonitor, run_monitors
|
||||||
# Without this, the Pico's clock starts at 2021 on every reboot
|
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:
|
try:
|
||||||
import ntptime
|
ntp_synced = sync_ntp_time(TIMEZONE_OFFSET)
|
||||||
ntptime.settime() # Downloads current time from pool.ntp.org
|
if ntp_synced:
|
||||||
print("Time synced with NTP server")
|
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:
|
except Exception as e:
|
||||||
print("Failed to sync time: {}".format(e))
|
print("Initial NTP sync error: {}".format(e))
|
||||||
# ===== END: NTP Time Sync =====
|
# ===== END: INITIAL NTP SYNC =====
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# WiFi connection failed
|
# WiFi connection failed
|
||||||
@@ -142,11 +248,7 @@ else:
|
|||||||
print("="*50 + "\n")
|
print("="*50 + "\n")
|
||||||
# ===== END: WiFi Connection =====
|
# ===== END: WiFi Connection =====
|
||||||
|
|
||||||
# ===== START: Web Server Setup =====
|
|
||||||
# Start web server for monitoring and control (accessible at http://192.168.86.43)
|
|
||||||
web_server = TempWebServer(port=80)
|
|
||||||
web_server.start()
|
|
||||||
# ===== END: Web Server Setup =====
|
|
||||||
|
|
||||||
# ===== START: Sensor Configuration =====
|
# ===== START: Sensor Configuration =====
|
||||||
# Define all temperature sensors and their alert thresholds
|
# Define all temperature sensors and their alert thresholds
|
||||||
@@ -221,6 +323,24 @@ schedule_monitor = ScheduleMonitor(
|
|||||||
config=config, # Pass config with schedules
|
config=config, # Pass config with schedules
|
||||||
interval=60 # Check schedule every 60 seconds
|
interval=60 # Check schedule every 60 seconds
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ===== 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 =====
|
# ===== END: Schedule Monitor Setup =====
|
||||||
|
|
||||||
# ===== START: Print Current Settings =====
|
# ===== START: Print Current Settings =====
|
||||||
@@ -241,68 +361,127 @@ print("="*50 + "\n")
|
|||||||
check_memory_once()
|
check_memory_once()
|
||||||
# ===== END: Startup Memory Check =====
|
# ===== END: Startup Memory Check =====
|
||||||
|
|
||||||
# ===== START: Monitor Setup =====
|
|
||||||
# Set up all monitoring systems (run in order during main loop)
|
|
||||||
monitors = [
|
|
||||||
# WiFi monitor: Checks connection, reconnects if needed, blinks LED
|
|
||||||
WiFiMonitor(wifi, led, interval=5, reconnect_cooldown=60),
|
|
||||||
|
|
||||||
# Schedule monitor: Changes temp targets based on time of day
|
|
||||||
schedule_monitor,
|
|
||||||
|
|
||||||
# Memory monitor: Checks RAM usage every 5 minutes
|
|
||||||
MemoryMonitor(interval=300), # 300 seconds = 5 minutes
|
|
||||||
|
|
||||||
# AC monitor: Automatically turns AC on/off based on temperature
|
|
||||||
ac_monitor,
|
|
||||||
|
|
||||||
# Heater monitor: Automatically turns heater on/off based on temperature
|
|
||||||
heater_monitor,
|
|
||||||
|
|
||||||
# Inside temperature monitor: Logs temps, sends alerts if out of range
|
|
||||||
TemperatureMonitor(
|
|
||||||
sensor=sensors['inside'],
|
|
||||||
label=SENSOR_CONFIG['inside']['label'],
|
|
||||||
check_interval=10, # Check temp every 10 seconds
|
|
||||||
report_interval=30, # Log to CSV every 30 seconds
|
|
||||||
alert_high=SENSOR_CONFIG['inside']['alert_high'], # High temp alert threshold
|
|
||||||
alert_low=SENSOR_CONFIG['inside']['alert_low'], # Low temp alert threshold
|
|
||||||
log_file="/temp_logs.csv", # CSV file path
|
|
||||||
send_alerts_to_separate_channel=True # Use separate Discord channel
|
|
||||||
),
|
|
||||||
|
|
||||||
# Outside temperature monitor: Logs temps, sends alerts if out of range
|
|
||||||
TemperatureMonitor(
|
|
||||||
sensor=sensors['outside'],
|
|
||||||
label=SENSOR_CONFIG['outside']['label'],
|
|
||||||
check_interval=10, # Check temp every 10 seconds
|
|
||||||
report_interval=30, # Log to CSV every 30 seconds
|
|
||||||
alert_high=SENSOR_CONFIG['outside']['alert_high'], # High temp alert threshold
|
|
||||||
alert_low=SENSOR_CONFIG['outside']['alert_low'], # Low temp alert threshold
|
|
||||||
log_file="/temp_logs.csv", # CSV file path
|
|
||||||
send_alerts_to_separate_channel=False # Use main Discord channel
|
|
||||||
),
|
|
||||||
]
|
|
||||||
# ===== END: Monitor Setup =====
|
|
||||||
|
|
||||||
print("Starting monitoring loop...")
|
print("Starting monitoring loop...")
|
||||||
print("Press Ctrl+C to stop\n")
|
print("Press Ctrl+C to stop\n")
|
||||||
|
|
||||||
|
# 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 =====
|
# ===== START: Main Loop =====
|
||||||
# Main monitoring loop (runs forever until Ctrl+C)
|
# 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:
|
while True:
|
||||||
# Run all monitors (each checks if it's time to run via should_run())
|
now = time.time()
|
||||||
run_monitors(monitors)
|
|
||||||
|
|
||||||
# Check for incoming web requests (non-blocking)
|
# WiFi monitor every 5 seconds (can be stateless)
|
||||||
# Pass schedule_monitor so web interface can reload config when schedules change
|
if now - last_monitor_run["wifi"] >= 5:
|
||||||
web_server.check_requests(sensors, ac_monitor, heater_monitor, schedule_monitor)
|
from scripts.monitors import WiFiMonitor
|
||||||
|
wifi_monitor = WiFiMonitor(wifi, led, interval=5, reconnect_cooldown=60, config=config)
|
||||||
# ===== START: Garbage Collection =====
|
try:
|
||||||
# Free up unused memory to prevent fragmentation
|
wifi_monitor.run()
|
||||||
|
except Exception as e:
|
||||||
|
print("WiFiMonitor error:", e)
|
||||||
|
del wifi_monitor
|
||||||
gc.collect()
|
gc.collect()
|
||||||
# ===== END: Garbage Collection =====
|
last_monitor_run["wifi"] = now
|
||||||
|
|
||||||
# Small delay to prevent CPU overload (0.1 seconds = 10 loops per second)
|
# Schedule monitor every 60 seconds (persistent)
|
||||||
|
if now - last_monitor_run["schedule"] >= 60:
|
||||||
|
try:
|
||||||
|
schedule_monitor.run()
|
||||||
|
except Exception as e:
|
||||||
|
print("ScheduleMonitor error:", e)
|
||||||
|
last_monitor_run["schedule"] = now
|
||||||
|
|
||||||
|
# AC monitor every 30 seconds (persistent)
|
||||||
|
if now - last_monitor_run["ac"] >= 30:
|
||||||
|
try:
|
||||||
|
ac_monitor.run()
|
||||||
|
except Exception as e:
|
||||||
|
print("ACMonitor error:", e)
|
||||||
|
last_monitor_run["ac"] = now
|
||||||
|
|
||||||
|
# Heater monitor every 30 seconds (persistent)
|
||||||
|
if now - last_monitor_run["heater"] >= 30:
|
||||||
|
try:
|
||||||
|
heater_monitor.run()
|
||||||
|
except Exception as e:
|
||||||
|
print("HeaterMonitor error:", e)
|
||||||
|
last_monitor_run["heater"] = now
|
||||||
|
|
||||||
|
# Inside temperature monitor every 10 seconds (can be stateless)
|
||||||
|
if now - last_monitor_run["inside_temp"] >= 10:
|
||||||
|
from scripts.monitors import TemperatureMonitor
|
||||||
|
inside_monitor = TemperatureMonitor(
|
||||||
|
sensor=sensors['inside'],
|
||||||
|
label=SENSOR_CONFIG['inside']['label'],
|
||||||
|
check_interval=10,
|
||||||
|
report_interval=30,
|
||||||
|
alert_high=SENSOR_CONFIG['inside']['alert_high'],
|
||||||
|
alert_low=SENSOR_CONFIG['inside']['alert_low'],
|
||||||
|
log_file="/temp_logs.csv",
|
||||||
|
send_alerts_to_separate_channel=True
|
||||||
|
)
|
||||||
|
inside_monitor.run()
|
||||||
|
del inside_monitor
|
||||||
|
gc.collect()
|
||||||
|
last_monitor_run["inside_temp"] = now
|
||||||
|
|
||||||
|
# Outside temperature monitor every 10 seconds (can be stateless)
|
||||||
|
if now - last_monitor_run["outside_temp"] >= 10:
|
||||||
|
from scripts.monitors import TemperatureMonitor
|
||||||
|
outside_monitor = TemperatureMonitor(
|
||||||
|
sensor=sensors['outside'],
|
||||||
|
label=SENSOR_CONFIG['outside']['label'],
|
||||||
|
check_interval=10,
|
||||||
|
report_interval=30,
|
||||||
|
alert_high=SENSOR_CONFIG['outside']['alert_high'],
|
||||||
|
alert_low=SENSOR_CONFIG['outside']['alert_low'],
|
||||||
|
log_file="/temp_logs.csv",
|
||||||
|
send_alerts_to_separate_channel=False
|
||||||
|
)
|
||||||
|
outside_monitor.run()
|
||||||
|
del outside_monitor
|
||||||
|
gc.collect()
|
||||||
|
last_monitor_run["outside_temp"] = now
|
||||||
|
|
||||||
|
# Web requests (keep web server loaded if needed)
|
||||||
|
web_server.check_requests(sensors, ac_monitor, heater_monitor, schedule_monitor, config)
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user