Complete project to control an automatic chicken door and feeder with a Raspberry Pi. Includes physical buttons, mobile web interface, scheduling, reed-switch protection and software safety features.
| Function | GPIO | Notes |
|---|---|---|
| Door Open Relay | 17 | Active Low |
| Door Close Relay | 27 | Active Low |
| Feeder Relay | 22 | Active Low |
| Open Button | 5 | To GND when pressed |
| Close Button | 12 | To GND when pressed |
| Feed Button | 16 | To GND when pressed |
| Reed Switch | 26 | LOW when fully open |
sudo apt update sudo apt install python3-gpiozero python3-flask -y pip3 install requests --break-system-packages
nano /home/pi/door_service_web.py nano /home/pi/index.html
Paste the contents from the sections below, then:
chmod +x /home/pi/door_service_web.py
sudo nano /etc/systemd/system/door_service_web.service
[Unit] Description=Door & Feeder Web Control Service After=network.target [Service] Type=simple User=pi WorkingDirectory=/home/pi ExecStart=/usr/bin/python3 /home/pi/door_service_web.py Restart=always RestartSec=5 [Install] WantedBy=multi-user.target
sudo systemctl daemon-reload sudo systemctl enable door_service_web sudo systemctl start door_service_web sudo systemctl status door_service_web
These small scripts call the running service so you never have GPIO conflicts.
# Example crontab (crontab -e) 30 6 * * * /usr/bin/python3 /home/pi/open_door_cron.py >> /home/pi/door_service.log 2>&1 0 20 * * * /usr/bin/python3 /home/pi/close_door_cron.py >> /home/pi/door_service.log 2>&1 0 8,17 * * * /usr/bin/python3 /home/pi/feed_cron.py >> /home/pi/door_service.log 2>&1
#!/usr/bin/python3
import time
import datetime
import signal
import sys
import threading
from flask import Flask, jsonify, send_from_directory, send_file
from gpiozero import OutputDevice, Button
# ================== PIN CONFIGURATION ==================
RELAY_OPEN = 17
RELAY_CLOSE = 27
RELAY_FEED = 22
BUTTON_OPEN = 5
BUTTON_CLOSE = 12
BUTTON_FEED = 16
REED_PIN = 26
OPEN_TIME = 12.0
CLOSE_TIME = 12.0
FEED_TIME = 1.0
IGNORE_TIME = 3.0
HOLD_LIMIT = 1.5
LOG_FILE = "/home/pi/door_service.log"
# =======================================================
pending_web_action = None
current_action = "idle"
status_message = "Ready"
app = Flask(__name__)
relay_open = OutputDevice(RELAY_OPEN, active_high=False, initial_value=False)
relay_close = OutputDevice(RELAY_CLOSE, active_high=False, initial_value=False)
relay_feed = OutputDevice(RELAY_FEED, active_high=False, initial_value=False)
btn_open = Button(BUTTON_OPEN, pull_up=True, bounce_time=0.05)
btn_close = Button(BUTTON_CLOSE, pull_up=True, bounce_time=0.05)
btn_feed = Button(BUTTON_FEED, pull_up=True, bounce_time=0.05)
reed = Button(REED_PIN, pull_up=True, bounce_time=0.05)
def log(msg):
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
line = f"{timestamp} - {msg}"
print(line)
try:
with open(LOG_FILE, "a") as f:
f.write(line + "\n")
except:
pass
def is_reed_closed(samples=5):
count = 0
for _ in range(samples):
if reed.is_pressed:
count += 1
time.sleep(0.01)
return count >= 3
def get_last_door_action():
try:
with open(LOG_FILE, "r") as f:
lines = f.readlines()[-150:]
for line in reversed(lines):
if "Door Open completed" in line:
return "open"
if "Door Close completed" in line:
return "closed"
if "Over-travel detected on close" in line:
return "closed"
if "Open requested but door is already fully open" in line:
return "open"
if "Close requested but door appears already closed" in line:
return "closed"
except Exception:
pass
return "unknown"
def do_open(force=False):
if not force:
if is_reed_closed():
log("Open requested but door is already fully open – aborted")
return
if get_last_door_action() == "open":
log("Open requested but door appears already open (from log) – aborted")
return
log("Activating Door Open..." + (" (FORCE)" if force else ""))
relay_open.on()
elapsed = 0.0
while elapsed < OPEN_TIME:
if is_reed_closed():
log("Reed switch triggered – fully open reached. Stopping.")
break
time.sleep(0.05)
elapsed += 0.05
relay_open.off()
log("Door Open completed")
def do_close(force=False):
if not force:
if get_last_door_action() == "closed":
log("Close requested but door appears already closed (from log) – aborted for safety")
return
log("Activating Door Close..." + (" (FORCE)" if force else ""))
relay_close.on()
elapsed = 0.0
while elapsed < CLOSE_TIME:
if elapsed >= IGNORE_TIME and is_reed_closed():
log("Over-travel detected on close – stopping for safety")
break
time.sleep(0.05)
elapsed += 0.05
relay_close.off()
log("Door Close completed")
def do_feed():
log("Starting Feeder")
relay_feed.on()
time.sleep(FEED_TIME)
relay_feed.off()
log("Feeder finished")
class PollingButton:
def __init__(self, gpio_button, name, action_name):
self.button = gpio_button
self.name = name
self.action_name = action_name
self.last_state = False
self.press_start = None
def update(self):
global pending_web_action, current_action
pressed = self.button.is_pressed
if pressed and not self.last_state:
self.press_start = time.time()
elif not pressed and self.last_state:
if self.press_start is not None:
held = time.time() - self.press_start
self.press_start = None
if held < HOLD_LIMIT:
if pending_web_action is None and current_action == "idle":
pending_web_action = self.action_name
current_action = self.action_name
log(f"{self.name} button pressed ({held:.2f}s) – queued")
else:
log(f"{self.name} button ignored – service busy")
else:
log(f"{self.name} button held too long ({held:.2f}s) – ignored")
self.last_state = pressed
buttons = [
PollingButton(btn_open, "Open", "open"),
PollingButton(btn_close, "Close", "close"),
PollingButton(btn_feed, "Feed", "feed"),
]
def cleanup(signum=None, frame=None):
log("Shutting down – forcing all relays OFF")
for r in [relay_open, relay_close, relay_feed]:
try: r.off()
except: pass
log("Service stopped cleanly")
sys.exit(0)
signal.signal(signal.SIGTERM, cleanup)
signal.signal(signal.SIGINT, cleanup)
log("=== Door & Feeder Service Starting ===")
@app.route('/')
def index():
return send_from_directory('.', 'index.html')
@app.route('/status')
def get_status():
global current_action, status_message
try:
door_pos = "Fully Open ✓" if is_reed_closed() else "Not fully open"
return jsonify({
"timestamp": datetime.datetime.now().isoformat(),
"current_action": current_action,
"status_message": status_message,
"door_position": door_pos,
"last_action": get_last_door_action(),
"gpios": {
RELAY_OPEN: {"name": "Open Relay", "mode": "OUT", "display": "ON" if relay_open.value else "OFF"},
RELAY_CLOSE: {"name": "Close Relay", "mode": "OUT", "display": "ON" if relay_close.value else "OFF"},
RELAY_FEED: {"name": "Feed Relay", "mode": "OUT", "display": "ON" if relay_feed.value else "OFF"},
BUTTON_OPEN: {"name": "Open Button", "mode": "IN", "display": "Pressed" if btn_open.is_pressed else "Released"},
BUTTON_CLOSE:{"name": "Close Button","mode": "IN", "display": "Pressed" if btn_close.is_pressed else "Released"},
BUTTON_FEED: {"name": "Feed Button", "mode": "IN", "display": "Pressed" if btn_feed.is_pressed else "Released"},
REED_PIN: {"name": "Reed Switch", "mode": "IN", "display": "Fully Open" if is_reed_closed() else "Not triggered"},
}
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/action/<name>', methods=['POST'])
def trigger_action(name):
global pending_web_action, current_action, status_message
valid = ['open', 'close', 'feed', 'force_open', 'force_close']
if name not in valid:
return jsonify({"error": "invalid action"}), 400
if pending_web_action is not None or current_action != "idle":
return jsonify({"status": "busy"}), 409
pending_web_action = name
current_action = name
log(f"Web triggered: {name}")
return jsonify({"status": "started"})
@app.route('/log')
def get_log():
try:
with open(LOG_FILE, 'r') as f:
return jsonify({"lines": [l.rstrip('\n') for l in f.readlines()[-80:]]})
except Exception as e:
return jsonify({"lines": [str(e)]})
@app.route('/log/download')
def download_log():
return send_file(LOG_FILE, as_attachment=True, download_name="door_service.log")
def run_flask():
app.run(host='0.0.0.0', port=5000, debug=False, threaded=True, use_reloader=False)
flask_thread = threading.Thread(target=run_flask, daemon=True)
flask_thread.start()
try:
while True:
for btn in buttons:
btn.update()
if pending_web_action is not None:
action = pending_web_action
pending_web_action = None
current_action = action
if action == "open":
do_open(force=False)
elif action == "close":
do_close(force=False)
elif action == "force_open":
do_open(force=True)
elif action == "force_close":
do_close(force=True)
elif action == "feed":
do_feed()
current_action = "idle"
time.sleep(0.02)
except Exception as e:
log(f"Fatal error: {e}")
finally:
cleanup()
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Door & Feeder Control</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
</style>
</head>
<body class="bg-zinc-950 text-zinc-200">
<div class="max-w-2xl mx-auto p-4">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-bold">Door & Feeder</h1>
<p class="text-zinc-400 text-sm">Raspberry Pi Control</p>
</div>
<div id="door-badge" class="px-3 py-1 rounded-full text-xs font-medium bg-zinc-900 border border-zinc-700">
<span id="door-position">Loading...</span>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-5 gap-6">
<div class="md:col-span-3">
<h2 class="text-xl font-semibold mb-3 flex items-center gap-2">🎮 Controls</h2>
<div class="space-y-3">
<button onclick="triggerAction('open')" class="w-full py-6 text-xl font-bold bg-emerald-600 hover:bg-emerald-500 rounded-3xl flex items-center justify-center gap-3">
🔓 OPEN DOOR
</button>
<button onclick="triggerAction('close')" class="w-full py-6 text-xl font-bold bg-red-600 hover:bg-red-500 rounded-3xl flex items-center justify-center gap-3">
🔒 CLOSE DOOR
</button>
<button onclick="triggerAction('feed')" class="w-full py-6 text-xl font-bold bg-amber-500 hover:bg-amber-400 text-black rounded-3xl flex items-center justify-center gap-3">
🌾 FEED
</button>
<div class="pt-4 border-t border-zinc-700">
<p class="text-xs text-zinc-500 mb-2 text-center">Force (only if log is wrong)</p>
<div class="grid grid-cols-2 gap-3">
<button onclick="triggerAction('force_open')" class="py-3 text-sm font-semibold bg-zinc-700 hover:bg-zinc-600 rounded-2xl">
Force Open
</button>
<button onclick="triggerAction('force_close')" class="py-3 text-sm font-semibold bg-zinc-700 hover:bg-zinc-600 rounded-2xl">
Force Close
</button>
</div>
</div>
</div>
</div>
<div class="md:col-span-2">
<div class="flex justify-between items-center mb-3">
<h2 class="text-xl font-semibold flex items-center gap-2">📡 GPIO Status</h2>
<button onclick="fetchStatus()" class="text-xs px-3 py-1 bg-zinc-800 rounded-xl">Refresh</button>
</div>
<div id="gpio-grid" class="space-y-2 text-sm"></div>
<div class="text-xs text-zinc-500 text-right mt-1">Updated: <span id="status-time"></span></div>
</div>
</div>
<div class="mt-8">
<div class="flex justify-between items-center mb-2">
<h2 class="text-xl font-semibold flex items-center gap-2">📜 Live Log</h2>
<div>
<button onclick="fetchLog()" class="text-xs px-3 py-1 bg-zinc-800 rounded-xl mr-2">Refresh</button>
<button onclick="window.location='/log/download'" class="text-xs px-3 py-1 bg-zinc-800 rounded-xl">⬇ Download</button>
</div>
</div>
<div id="log-container" class="bg-black border border-zinc-800 rounded-3xl p-4 h-72 overflow-auto text-xs">
<pre id="log-content" class="whitespace-pre-wrap break-all"></pre>
</div>
</div>
</div>
<script>
let statusInterval, logInterval;
async function fetchStatus() {
try {
const res = await fetch('/status');
const data = await res.json();
document.getElementById('door-position').textContent = data.door_position || 'Unknown';
const grid = document.getElementById('gpio-grid');
grid.innerHTML = '';
const order = [17, 27, 22, 5, 12, 16, 26];
order.forEach(pin => {
if (!data.gpios[pin]) return;
const g = data.gpios[pin];
const active = g.display.includes('ON') || g.display.includes('Pressed') || g.display.includes('Fully Open');
const div = document.createElement('div');
div.className = `flex justify-between px-4 py-3 rounded-2xl border border-zinc-700 ${active ? 'bg-zinc-800' : 'bg-zinc-900'}`;
div.innerHTML = `
<div>
<div class="font-mono text-xs text-zinc-400">GPIO ${pin}</div>
<div class="font-semibold">${g.name}</div>
</div>
<div class="text-right">
<div class="font-mono text-xs ${active ? 'text-emerald-400' : 'text-zinc-400'}">${g.display}</div>
<div class="text-xs text-zinc-500">${g.mode}</div>
</div>`;
grid.appendChild(div);
});
document.getElementById('status-time').textContent = new Date().toLocaleTimeString();
} catch (e) { console.error(e); }
}
async function triggerAction(action) {
try {
const res = await fetch('/action/' + action, { method: 'POST' });
if (!res.ok) {
const err = await res.json();
alert(err.message || 'Busy or error');
}
} catch (e) { alert('Failed to send command'); }
}
async function fetchLog() {
try {
const res = await fetch('/log');
const data = await res.json();
const pre = document.getElementById('log-content');
if (data.lines) {
pre.textContent = data.lines.join('\n');
document.getElementById('log-container').scrollTop = 99999;
}
} catch (e) {}
}
function startPolling() {
fetchStatus();
fetchLog();
if (statusInterval) clearInterval(statusInterval);
if (logInterval) clearInterval(logInterval);
statusInterval = setInterval(fetchStatus, 2000);
logInterval = setInterval(fetchLog, 4000);
}
window.onload = startPolling;
</script>
</body>
</html>
#!/usr/bin/python3
import requests, datetime
def log(msg):
print(f"{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - {msg}")
log("--- Cron: Requesting Door Open ---")
try:
r = requests.post("http://127.0.0.1:5000/action/open", timeout=5)
if r.status_code == 200:
log("Successfully requested door open")
elif r.status_code == 409:
log("Service is busy – request ignored")
else:
log(f"Unexpected response: {r.status_code}")
except Exception as e:
log(f"Failed: {e}")
log("--- Cron finished ---")
Create close_door_cron.py and feed_cron.py the same way — just change the URL to /action/close or /action/feed.
# Live service logs journalctl -u door_service_web -f # Restart sudo systemctl restart door_service_web # Stop sudo systemctl stop door_service_web