PoC: Betteruptime + Python-kasa

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).

Screenshot of Betteruptime showing a heartbeat that failed for several hours.
Image of a heartbeat that failed for several hours. After 2 hours of no hearbeat, it turned into an incident, and several hours later the heartbeats resumed.

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” in Docker 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.


Posted by

in

, , ,

Comments

Leave a Reply…