How I Automated My Home Lab Power Schedule — And Made the Alerts Stop

There’s a highly efficient monitoring system in my house, and it has absolutely nothing to do with Grafana. It tracks electricity costs with a level of accuracy I’ve never managed to achieve in my own infrastructure, generates alerts that are nearly impossible to ignore, and has a remarkable ability to win every discussion.

As it turns out, those alerts were entirely justified. Running a server 24/7 for months on end is difficult to defend as a household expense—especially when that server is a Dell PowerEdge R620 with dual Xeons and 120 GB of RAM, hosting a collection of test VMs that nobody needs at three o’clock in the morning. Nothing on it is production, nothing is business-critical, and most of the time it is simply waiting for the next lab experiment while steadily consuming electricity.

The obvious solution was to automate the entire process. An aging Raspberry Pi Zero that had long since lost hope of being useful again, a TP-Link Tapo smart plug already sitting in the rack, and a Python script to tie everything together. The idea was simple: power the server on in the morning, perform a graceful shutdown in the evening, and do it only on weekdays.

In reality, the smart plug could have been scheduled directly from the Tapo app in less than two minutes. However, I wanted everything—the smart plug, iDRAC, and ESXi—to be controlled from a single script running on the Pi. No schedules hidden inside mobile apps, no exceptions, and no fragmented automation. I don’t have a particularly convincing explanation for this decision. It cost me two days, several incompatible Python libraries, and at one point I came very close to abandoning the entire project because of a smart plug. Fortunately, persistence eventually won.

 


The Setup

The lab runs on a single Dell PowerEdge R620, sitting in a small rack in the basement. iDRAC is on 192.168.1.20, ESXi on 192.168.1.10 — both in a dedicated lab subnet behind a pfSense firewall. The Raspberry Pi and everything else in the house live on a separate subnet, behind a Fritz!Box router.

This means the Pi can’t talk directly to iDRAC or ESXi out of the box. There’s a pfSense sitting between the two networks, and by default it blocks everything. We’ll need to fix that.

The smart plug — a TP-Link Tapo P110 — is on the same subnet as the Pi, at 192.168.178.75. It controls power to the entire rack: pfSense router, server — everything. When the plug is off, the lab is completely dead.

Here’s the full picture:

[Fritz!Box 192.168.178.1]
        |
        ├── Raspberry Pi (192.168.178.x)
        ├── Tapo P110   (192.168.178.75)
        │
        └── [pfSense WAN: 192.168.178.12]
                 |
            [pfSense LAN: 192.168.1.1]
                 |
                 ├── ESXi   (192.168.1.10)
                 └── iDRAC  (192.168.1.20)

Raspberry Pi — Getting Started

The Pi Zero doesn’t need much. We’re not running a web server or a database — just a couple of Python scripts triggered by cron. Raspberry Pi OS Lite (32-bit) is more than enough.

Flash it using Raspberry Pi Imager, and before writing to the card, open the settings (⚙️) and configure:

  • Hostname
  • SSH — enable, password authentication
  • WiFi — SSID and password
  • Locale and timezone

This gives you a headless setup from the first boot — no monitor, no keyboard needed. After about two minutes, you can SSH in directly:

ssh pi@your-hostname.local

Once in, update the system:

sudo apt update && sudo apt upgrade -y

Install Python venv support:

sudo apt install -y python3-venv

Create the project folder and virtual environment:

mkdir ~/server-auto
python3 -m venv ~/server-auto/venv
source ~/server-auto/venv/bin/activate

Install the required packages — and only these:

pip install python-kasa requests python-dotenv

That’s it. No extras, no bloat. The Pi Zero doesn’t have the resources to compile Rust-based libraries — we learned that the hard way — so we keep the dependencies minimal and pre-compiled.


The Network Problem

The Pi lives on the Fritz!Box subnet (192.168.178.0/24). iDRAC and ESXi live behind pfSense on a separate subnet (192.168.1.0/24). By default, these two networks don’t talk to each other — and Fritz!Box doesn’t support static routes, so we can’t fix it there.

The solution has two parts.

First, add a static route on the Pi so it knows how to reach the lab subnet:

sudo ip route add 192.168.1.0/24 via 192.168.178.12

To make it permanent across reboots, add it to /etc/network/interfaces:

sudo nano /etc/network/interfaces
up ip route add 192.168.1.0/24 via 192.168.178.12

If you’re also accessing the lab from a Windows machine, add the route there too — open Command Prompt as Administrator:

route add 192.168.1.0 mask 255.255.255.0 192.168.178.12 -p

The -p flag makes it persistent across reboots.

Second, add a firewall rule in pfSense to allow traffic from the Fritz!Box subnet into the lab. Go to Firewall → Rules → WAN → Add:

  • Action: Pass
  • Protocol: Any
  • Source: 192.168.178.0/24
  • Destination: 192.168.1.0/24
  • Description: Allow Fritz subnet to Lab

Hit Save and Apply Changes. At this point, the Pi can reach iDRAC and ESXi directly.


The Smart Plug Saga

The P110 runs firmware 1.4.6, which uses a protocol called TPAP — a newer TP-Link encryption scheme that most Python libraries don’t support yet. We went through several of them:

  • PyP100 — too old, doesn’t work with newer firmware
  • tapo — requires Rust to compile, which fills up /tmp on a Pi Zero and fails
  • plugp100 — supports TPAP in theory, but authentication kept returning 403
  • python-kasa — discovers the device but marks it as unsupported

The breakthrough came from two things combined. First, in the Tapo mobile app, go to the device settings and enable Third Party Compatibility. This unlocks local network control via the KLAP protocol. Second, python-kasa with credentials works perfectly once this is enabled:

kasa --username your@email.com --password yourpassword --host 192.168.178.75 state

To use it in a script:

from kasa import Discover, Credentials

creds = Credentials("your@email.com", "yourpassword")
device = await Discover.discover_single("192.168.178.75", credentials=creds)
await device.turn_on()

Two days and several failed libraries later — that’s all it took.


iDRAC & Redfish API

iDRAC is Dell’s out-of-band management interface — it runs independently of the server’s operating system and allows you to power the machine on and off, monitor hardware health, and manage boot options, all without touching the server physically. Even when the server is completely off, iDRAC is alive as long as it has power.

Redfish is a REST API standard created by the DMTF consortium and adopted by all major server vendors — Dell, HP, Lenovo, Supermicro. Before Redfish, every vendor had their own proprietary management protocol. Redfish standardized everything under a clean HTTP/JSON interface.

On a Dell PowerEdge, the Redfish endpoint lives on the iDRAC IP. To check the current power state of the server:

curl -k -u root:calvin \
  https://192.168.1.20/redfish/v1/Systems/System.Embedded.1 | python3 -m json.tool | grep PowerState

To power on the server:

curl -k -u root:calvin \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"ResetType":"On"}' \
  https://192.168.1.20/redfish/v1/Systems/System.Embedded.1/Actions/ComputerSystem.Reset

One thing worth knowing: iDRAC sometimes returns a timeout or a 409 on power actions, even when the command executed successfully. A 409 means the server is already powered on or in the process of starting up — not an error. We handle both cases in the script.


SSH to ESXi

To shut down VMs and the ESXi host gracefully, the script connects via SSH. Rather than storing the ESXi password in the .env file, we use key-based authentication — cleaner, more secure, and the script never needs to handle a password at all.

Generate an RSA key on the Pi:

ssh-keygen -t rsa -b 4096 -C "pi-server-auto" -f ~/.ssh/esxi_key_rsa

Note: We initially tried ed25519, which is the modern recommended key type on Linux. It generated without issues and was copied to ESXi successfully — but authentication kept failing silently. Switching to RSA 4096 fixed it immediately. We didn’t dig deeper into the root cause — RSA 4096 worked, and that was good enough.

Leave the passphrase empty — the script needs to connect without human interaction.

Copy the public key to ESXi. The standard ssh-copy-id doesn’t work reliably here, so we do it manually:

cat ~/.ssh/esxi_key_rsa.pub | ssh root@192.168.1.10 "cat >> /etc/ssh/keys-root/authorized_keys"

ESXi stores authorized keys in /etc/ssh/keys-root/ rather than the standard ~/.ssh/ location. Once the key is in place, test it:

ssh -i ~/.ssh/esxi_key_rsa root@192.168.1.10 "echo connected"

If it returns connected without asking for a password — you’re good.


The Scripts

There are two scripts: startup.py and shutdown.py. Both live in ~/server-auto/ and share the same configuration file.

Keeping Credentials Safe

Passwords and hostnames live in a .env file, not hardcoded in the scripts:

nano ~/server-auto/.env
chmod 600 ~/server-auto/.env
TAPO_USER=your@email.com
TAPO_PASS=yourpassword
IDRAC_USER=root
IDRAC_PASS=calvin
ESXI_USER=root
ESXI_IP=192.168.1.10
IDRAC_IP=192.168.1.20
PLUG_HOST=192.168.178.75

The chmod 600 ensures only the current user can read the file.

startup.py

Powers on the smart plug, waits for iDRAC to become available, then sends the power-on command via Redfish. Runs automatically on scheduled days, or immediately with --now:

#!/usr/bin/env python3
import asyncio
import requests
import time
import urllib3
import sys
import os
from dotenv import load_dotenv
urllib3.disable_warnings()

# Load environment variables
load_dotenv(os.path.expanduser("~/server-auto/.env"))

PLUG_HOST = os.getenv("PLUG_HOST")
TAPO_USER = os.getenv("TAPO_USER")
TAPO_PASS = os.getenv("TAPO_PASS")
IDRAC_IP = os.getenv("IDRAC_IP")
IDRAC_USER = os.getenv("IDRAC_USER")
IDRAC_PASS = os.getenv("IDRAC_PASS")
SSH_KEY = os.path.expanduser("~/.ssh/esxi_key_rsa")

async def plug_on():
    from kasa import Discover, Credentials
    print("Turning on smart plug...")
    creds = Credentials(TAPO_USER, TAPO_PASS)
    device = await Discover.discover_single(PLUG_HOST, credentials=creds)
    await device.turn_on()
    await device.update()
    print(f"Plug state: {'ON' if device.is_on else 'OFF'}")

def wait_for_idrac():
    print("Waiting for iDRAC...")
    for i in range(20):
        try:
            r = requests.get(
                f"https://{IDRAC_IP}/redfish/v1/Systems/System.Embedded.1",
                auth=(IDRAC_USER, IDRAC_PASS),
                verify=False,
                timeout=10
            )
            if r.status_code == 200:
                print("iDRAC is available!")
                return True
        except:
            pass
        print(f"  Retry {i+1}/20...")
        time.sleep(15)
    return False

def server_power_on():
    print("Powering on server via iDRAC...")
    try:
        r = requests.post(
            f"https://{IDRAC_IP}/redfish/v1/Systems/System.Embedded.1/Actions/ComputerSystem.Reset",
            auth=(IDRAC_USER, IDRAC_PASS),
            json={"ResetType": "On"},
            verify=False,
            timeout=30
        )
        if r.status_code in [200, 204]:
            print("Server powered on!")
            return True
        elif r.status_code == 409:
            print("Server is already powered on or starting up!")
            return True
        print(f"Error powering on server: {r.status_code}")
    except requests.exceptions.ReadTimeout:
        # iDRAC often times out on power actions but executes them anyway
        print("Server powered on! (iDRAC timeout is normal for power actions)")
        return True
    except Exception as e:
        print(f"Error: {e}")
    return False

async def main():
    force = "--now" in sys.argv

    if not force:
        from datetime import datetime
        now = datetime.now()
        # 0=Monday, 2=Wednesday, 4=Friday
        if now.weekday() not in [0, 2, 4]:
            print("Not a scheduled day. Use --now to force startup.")
            return

    print("=== STARTUP SEQUENCE ===")

    # 1. Turn on smart plug
    await plug_on()

    # 2. Wait for iDRAC to initialize (~90 sec)
    print("Waiting for iDRAC to initialize...")
    time.sleep(90)

    # 3. Wait for iDRAC and power on server
    if wait_for_idrac():
        server_power_on()
    else:
        print("iDRAC did not respond!")

asyncio.run(main())

shutdown.py

Connects to ESXi via SSH, checks which VMs are running and shuts them down gracefully, powers off ESXi, then cuts power to the smart plug:

#!/usr/bin/env python3
import asyncio
import subprocess
import time
import urllib3
import sys
import os
from dotenv import load_dotenv
urllib3.disable_warnings()

# Load environment variables
load_dotenv(os.path.expanduser("~/server-auto/.env"))

PLUG_HOST = os.getenv("PLUG_HOST")
TAPO_USER = os.getenv("TAPO_USER")
TAPO_PASS = os.getenv("TAPO_PASS")
IDRAC_IP = os.getenv("IDRAC_IP")
IDRAC_USER = os.getenv("IDRAC_USER")
IDRAC_PASS = os.getenv("IDRAC_PASS")
ESXI_IP = os.getenv("ESXI_IP")
ESXI_USER = os.getenv("ESXI_USER")
SSH_KEY = os.path.expanduser("~/.ssh/esxi_key_rsa")

def ssh_esxi(command):
    result = subprocess.run([
        "ssh",
        "-i", SSH_KEY,
        "-o", "StrictHostKeyChecking=no",
        "-o", "BatchMode=yes",
        f"{ESXI_USER}@{ESXI_IP}",
        command
    ], capture_output=True, text=True)
    return result.stdout

def shutdown_vms():
    print("Shutting down running VMs...")
    vms = ssh_esxi("vim-cmd vmsvc/getallvms | awk 'NR>1 {print $1}'")
    vm_ids = [v.strip() for v in vms.strip().split("\n") if v.strip().isdigit()]

    for vm_id in vm_ids:
        state = ssh_esxi(f"vim-cmd vmsvc/power.getstate {vm_id}")
        if "Powered on" in state:
            print(f"  Shutting down VM {vm_id}...")
            ssh_esxi(f"vim-cmd vmsvc/power.shutdown {vm_id}")
        else:
            print(f"  VM {vm_id} already off, skipping.")

    print("Waiting for VMs to shut down...")
    time.sleep(60)

def shutdown_esxi():
    print("Shutting down ESXi...")
    ssh_esxi("esxcli system shutdown poweroff -d 10 -r 'Scheduled shutdown'")
    print("Waiting for ESXi to power off...")
    time.sleep(60)

async def plug_off():
    from kasa import Discover, Credentials
    print("Turning off smart plug...")
    creds = Credentials(TAPO_USER, TAPO_PASS)
    device = await Discover.discover_single(PLUG_HOST, credentials=creds)
    await device.turn_off()
    await device.update()
    print(f"Plug state: {'ON' if device.is_on else 'OFF'}")

async def main():
    force = "--now" in sys.argv

    if not force:
        from datetime import datetime
        now = datetime.now()
        # 0=Monday, 2=Wednesday, 4=Friday
        if now.weekday() not in [0, 2, 4]:
            print("Not a scheduled day. Use --now to force shutdown.")
            return

    print("=== SHUTDOWN SEQUENCE ===")

    # 1. Shut down running VMs only
    shutdown_vms()

    # 2. Shut down ESXi
    shutdown_esxi()

    # 3. Turn off smart plug
    await plug_off()

    print("=== SHUTDOWN COMPLETE ===")

asyncio.run(main())

Cron Jobs

With both scripts in place, the last step is scheduling them. Open the crontab editor:

crontab -e

Add these two lines:

0 9    * * 1,3,5 /home/ioan/server-auto/venv/bin/python3 /home/ioan/server-auto/startup.py >> /home/ioan/server-auto/server-auto.log 2>&1
30 17  * * 1,3,5 /home/ioan/server-auto/venv/bin/python3 /home/ioan/server-auto/shutdown.py >> /home/ioan/server-auto/server-auto.log 2>&1

This runs startup at 09:00 and shutdown at 17:30, Monday, Wednesday and Friday. The output of both scripts is appended to server-auto.log — useful for checking what happened if something goes wrong.

Note that we’re calling the Python binary from inside the virtual environment directly. This ensures the script always runs with the correct dependencies, regardless of what’s installed system-wide.

To temporarily disable the schedule without deleting the cron jobs, just comment out the lines:

#0 9    * * 1,3,5 /home/ioan/server-auto/venv/bin/python3 /home/ioan/server-auto/startup.py >> /home/ioan/server-auto/server-auto.log 2>&1
#30 17  * * 1,3,5 /home/ioan/server-auto/venv/bin/python3 /home/ioan/server-auto/shutdown.py >> /home/ioan/server-auto/server-auto.log 2>&1

To run either script outside the schedule:

python3 ~/server-auto/startup.py --now
python3 ~/server-auto/shutdown.py --now

Results

The setup has been running for a few weeks now without any issues. The server comes up every Monday, Wednesday and Friday at 09:00, ESXi loads, the VMs start automatically, and everything is ready by the time the first coffee is done. At 17:30, everything shuts down gracefully — VMs first, then ESXi, then the plug.

The numbers speak for themselves. From roughly 50€/month running 24/7, down to around 7-8€/month running 8.5 hours a day, three days a week. That’s a saving of over 40€/month, or roughly 480€/year — for a setup that cost nothing, built entirely from hardware that was already collecting dust.

The monitoring system in the house has not generated any further alerts on this topic. I’m calling that a success.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top