Raspberry Pi Chicken Door & Feeder Controller

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.

Features

1. Hardware

GPIO Pinout (BCM)

FunctionGPIONotes
Door Open Relay17Active Low
Door Close Relay27Active Low
Feeder Relay22Active Low
Open Button5To GND when pressed
Close Button12To GND when pressed
Feed Button16To GND when pressed
Reed Switch26LOW when fully open
Wiring: All buttons and the reed switch are wired between the GPIO pin and Ground. Internal pull-ups are enabled in software.

2. Installation

Install packages

sudo apt update
sudo apt install python3-gpiozero python3-flask -y
pip3 install requests --break-system-packages

Create the two main files

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

3. Systemd Service

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

4. Cron Helpers (optional)

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

5. Main Service – door_service_web.py

#!/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()

6. Web Interface – index.html

<!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>

7. Cron Helper Scripts

open_door_cron.py

#!/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.

8. Useful Commands

# Live service logs
journalctl -u door_service_web -f

# Restart
sudo systemctl restart door_service_web

# Stop
sudo systemctl stop door_service_web

9. Safety Features

Important: Only one process should control the GPIOs at a time. Always stop the main service before running any other script that uses the same pins.