Content Update
The provided scripts have been updated on 16 Jul 2023. Specifically the SmartStrip part was not working as intended.
I’ve been a big fan of Betteruptime. I’ve started using it to monitor all my assets online (websites, DNS, ping, successful script runs) as well as my servers (using heartbeats).
I have a few Raspberry Pi’s, and once in a while they hang (not sure why, maybe USB-to-SSD issues or something). Nothing too critical, but annoying.
I’ve plugged them all on TP-Link Kasa smart plugs to remotely restart them if I had to (once or twice a year).
Note, to confuse everyone, TP-Link also launched Tapo, which... competes with Kasa and is not compatible, but does the exact same thing... ¯\_(ツ)_/¯
After some Googling (actually Kagi’ing) I found out, there’s a Python library that lets you control your smart plugs.
So, the idea was born to:
- Check Betteruptime heartbeats, if down, power cycle the smart plug
- Do this at most once per day (in case something else is causing issues)
- Betteruptime heartbeats manage when a device is marked as offline (i.e.: it expects a heartbeat every 5 minutes, but will only consider the device down if no heartbeats are received for 2 hours).
- The bulk of the code had to be written by ChatGPT. Let ChatGPT choose the language (it ended up being a mix of Bash and Python)
- Everything needs to run in Docker (using a cron, the Docker container doesn’t daemonise)
- These run on Raspberry Pi’s (but of course the RPi can’t check itself: so RPi1 checks for RPi2, and vice versa. As these RPis are on different networks (my parent’s home, my own home, etc) I had to enable “
--net=host
” inDocker run
to get the correct routes from the host system, but you may not actually need this - To top if off, sent an email (using Mailgun EU servers) to warn me something broke and it rebooted
So, after some fiddling (half an evening or so) the proof-of-concept worked.
I should probably throw this in a Git repo but shrug. I don’t want to give the impression that I’ll maintain this and provide support.
Dockerfile:
FROM python:alpine
RUN apk add bash curl jq
RUN pip3 install python-kasa
COPY heartbeat.sh kasa-api.py /
VOLUME /tmp/kasa/
CMD ["/heartbeat.sh"]
Python script kasa-api.py
(this works with both smart strips and smart plugs):
import sys
import asyncio
from kasa import SmartPlug, SmartStrip
async def main():
if len(sys.argv) != 4:
print("Usage: python kasa-api.py type IP-address outlet-index")
return
device_type = sys.argv[1]
ip_address = sys.argv[2]
outlet_index = int(sys.argv[3])
if device_type == "smartplug":
await control_smart_plug(ip_address)
elif device_type == "smartstrip":
await control_smart_strip(ip_address, outlet_index)
else:
print(f"Unsupported device type: {device_type}")
async def control_smart_plug(ip_address):
plug = SmartPlug(ip_address)
try:
await plug.update()
# Retrieve the current state
plug_state = plug.is_on
# Turn off the plug
await plug.turn_off()
print(f"Turned off SmartPlug at {ip_address}")
await asyncio.sleep(5)
# Turn on the plug if it was previously on
if plug_state:
await plug.turn_on()
print(f"Turned on SmartPlug at {ip_address}")
except Exception as e:
print(f"Failed to control SmartPlug at {ip_address}: {e}")
async def control_smart_strip(ip_address, outlet_index):
strip = SmartStrip(ip_address)
try:
await strip.update()
# Retrieve the current state of the specified child plug
child_state = strip.children[outlet_index].is_on
# Turn off the specified child plug
await strip.children[outlet_index].turn_off()
print(f"Turned off child plug {outlet_index} in SmartStrip at {ip_address}")
await asyncio.sleep(5)
# Turn on the child plug if it was previously on
await strip.children[outlet_index].turn_on()
print(f"Turned on child plug {outlet_index} in SmartStrip at {ip_address}")
except Exception as e:
print(f"Failed to control SmartStrip at {ip_address}: {e}")
# Run the asyncio event loop
asyncio.run(main())
heartbeat.sh
— with example devices. Be sure to fill in the variables (including hb
, that’s the heartbeat ID you can get from the Betteruptime URL and the IP or DNS hostname of the smartplug):
#!/bin/bash
API_KEY="BetterUptime API token"
BU="https://uptime.betterstack.com/api/v2/heartbeats/" # no need to change this
MAILGUN_API_KEY="Mailgun API token"
MAILGUN_DOMAIN="mg.you.com" # use your own domain
if [[ "$DEVICE" = tyr ]] || [[ "$1" = tyr ]]; then
# Tyr
device="Tyr"
hb=1111
bu="https://uptime.betterstack.com/team/1/heartbeats/$hb"
plug_type="smartplug"
plug_host="smartplug1.kasa.you.com"
elif [[ "$DEVICE" = mammoth ]] || [[ "$1" = mammoth ]]; then
# Mammoth
device="mammoth"
hb=2222
bu="https://uptime.betterstack.com/team/1/heartbeats/$hb"
plug_type="smartstrip"
plug_host="smartstrip1.kasa.you.com"
plug_index=0 # plug 2 is rly plug 3 because the index counts from 0 to 2 and not from 1 to 3.
elif [[ "$DEVICE" = liana ]] || [[ "$1" = liana ]]; then
# Liana
device="liana"
hb=3333
bu="https://uptime.betterstack.com/team/1/heartbeats/$hb"
plug_type="smartstrip"
plug_host="smartstrip1.kasa.you.com"
plug_index=1 # plug 2 is rly plug 3 because the index counts from 0 to 2 and not from 1 to 3.
elif [[ "$DEVICE" = eagle ]] || [[ "$1" = eagle ]]; then
device="eagle"
hb=4444
bu="https://uptime.betterstack.com/team/1/heartbeats/$hb"
plug_type="smartstrip"
plug_host="smartstrip1.kasa.yeri.be"
plug_index=2 # plug 2 is rly plug 3 because the index counts from 0 to 2 and not from 1 to 3.
else
echo "Unknown device."
exit 1
fi
url=$BU/$hb
send_alert() {
MAILGUN_URL="https://api.eu.mailgun.net/v3/$MAILGUN_DOMAIN/messages"
from="[email protected]"
to="[email protected]"
subject="Smartplug power cycled: $device"
body="rebooted device $device!"$'\n'"Kasa IP: $plug_host."$'\n'"$bu"
# Send alert email
curl -s --user "api:$MAILGUN_API_KEY" \
"$MAILGUN_URL" \
-F from="$from" \
-F to="$to" \
-F subject="$subject" \
-F text="$body"
}
kasa_cycle() {
echo "Betteruptime heartbeat ($hb) says the service for $device is down, restarting."
python /kasa-api.py "$plug_type" "$plug_host" "$plug_index"
# Update the last execution date in the file
echo "$current_date" > "$file"
}
kasa_info() {
kasa --host $plug_host
}
response=$(curl -sL "$url" -H "Authorization: Bearer $API_KEY")
status=$(echo "$response" | jq -r '.data.attributes.status')
if [[ "$status" == "down" ]]; then
dir="/tmp/.kasa/"
mkdir -p "$dir"
file="${dir}${device}.txt"
# Get current date
current_date=$(date "+%F")
# Check if the file exists
if [ -f "$file" ]; then
# Get last execution date from the file
last_execution=$(cat "$file")
# We only want to run this once every 24hrs. If a reboot doesn't fix it, something more
# serious is going on and likely needs manual intervention. No point spam rebooting the device.
if [[ "$current_date" != "$last_execution" ]]; then
kasa_cycle
send_alert
else
echo "Power cycle already executed today."
fi
else
kasa_cycle
send_alert
fi
elif [[ "$status" == "up" ]]; then
echo "Betteruptime heartbeat says the service ($hb) for $device is up."
else
# this could happen if the heartbeat is paused.
echo "Unknown status."
kasa_info
exit 1
fi
I run Docker with two scripts, a builder (rebuild.sh
) and a file that runs it (start.sh
). It should rebuild in case a docker cleanup script ran (and deleted dangling containers).
I run this as root
and probably shouldn’t, but yeah… That’ll be for another lifetime.
Be sure to change the paths (/root/git/kasa-api
) in both scripts.
rebuild.sh
:
#!/bin/bash
cd /root/git/kasa-api # the path where this project exists
git pull > /dev/null
BASEIMAGE=`cat Dockerfile | grep FROM | awk '{print $2}'`
docker pull $BASEIMAGE
docker build -q -t kasa-api .
rm -f /tmp/.kasa/*.txt
start.sh
:
#!/bin/bash
if [ -z "$1" ]; then
echo "Missing device name."
exit 1
fi
docker stop kasa-api 2> /dev/null
docker rm kasa-api 2> /dev/null
run_kasa() {
DEVICE=$1
docker run --net=host -v /tmp/.kasa:/tmp/.kasa --rm -e DEVICE=$DEVICE --name kasa-api kasa-api
}
if [[ $(docker image ls | grep kasa-api) ]]; then
run_kasa $1
else
cd /root/git/kasa-api
/root/git/kasa-api/rebuild.sh > /dev/null
run_kasa $1
fi
And that’s pretty much it. I run this using with cron in /etc/cron.d/
. For example (be sure to edit the parameter/device name/path):
#
# cron-jobs for kasa-api
#
MAILTO=root
*/15 * * * * root if [ -x /root/git/kasa-api/start.sh ] && [ -f /root/git/kasa-api/start.sh ]; then /root/git/kasa-api/start.sh tyr >/dev/null; fi
I’m sure there must be bugs in this ChatGPT generated code but… so far, it has actually worked.
Leave a Reply…