Smart plugs and Datadog - Triggering physical alerts with Py Kasa

Published: Nov 4, 2022 by Isaac Johnson

Having to replace some cheap smart plugs that all failed, I figured it might be worth getting some that we could program.

I picked up some Kasa Smart Plug HS103P4 from Amazon made by TP-Link. They were just about US$5 more than the cheapest ones that had previously died after a few months.

While TPLink doesn’t exactly expose a public REST API, developers deciphered it and the comapany seems to have no intention to stop them. Open-source developers exposed it as a Python project: Python-Kasa which makes it easy to turn off and on bulbs with a simple python script call.

Today we’ll setup a local environment to build python-kasa (uses “Poetry”) then work to containerize it. Lastly, we’ll add it as containerized microservice to an on-prem Kubernetes cluster, expose an HTTPS webhook URL and tie it into Datadog for alerting.

I won’t really cover building the little lamp you’ll see, but I’m happy to provide the STL i created for the trailer bulb and it’s enclosure. I wired it up with a 5v plug I chopped from a broken IKEA kids nightlight.

Setup

First, we need to pull down and try the python-kasa project as it stands.

Before we do, we need to make sure we have the Python package manager Poetry

$ curl -sSL https://install.python-poetry.org | python3 -
Retrieving Poetry metadata

# Welcome to Poetry!

This will download and install the latest version of Poetry,
a dependency and package manager for Python.

It will add the `poetry` command to Poetry's bin directory, located at:

/home/builder/.local/bin

You can uninstall at any time by executing this script with the --uninstall option,
and these changes will be reverted.

Installing Poetry (1.2.2): Done

Poetry (1.2.2) is installed now. Great!

You can test that everything is set up by executing:

`poetry --version`

Then we can clone the repo

builder@DESKTOP-QADGF36:~/Workspaces$ git clone https://github.com/python-kasa/python-kasa.git
Cloning into 'python-kasa'...
remote: Enumerating objects: 2150, done.
remote: Counting objects: 100% (896/896), done.
remote: Compressing objects: 100% (232/232), done.
remote: Total 2150 (delta 739), reused 754 (delta 659), pack-reused 1254
Receiving objects: 100% (2150/2150), 758.71 KiB | 5.13 MiB/s, done.
Resolving deltas: 100% (1471/1471), done.
builder@DESKTOP-QADGF36:~/Workspaces$ cd python-kasa/

We can now install the Kasa binary

builder@DESKTOP-QADGF36:~/Workspaces/python-kasa$ poetry install
Creating virtualenv python-kasa-ZQLIqOJb-py3.10 in /home/builder/.cache/pypoetry/virtualenvs
Installing dependencies from lock file

Package operations: 41 installs, 0 updates, 0 removals

  • Installing certifi (2021.10.8)
  • Installing charset-normalizer (2.0.12)
  • Installing idna (3.3)
  • Installing pyparsing (3.0.7)
  • Installing urllib3 (1.26.9)
  • Installing attrs (21.4.0): Installing...
  • Installing attrs (21.4.0)
  • Installing distlib (0.3.4)
  • Installing filelock (3.6.0)
  • Installing iniconfig (1.1.1)
  • Installing packaging (21.3)
  • Installing platformdirs (2.5.1)
  • Installing pluggy (1.0.0)
  • Installing py (1.11.0)
  • Installing requests (2.27.1)
  • Installing six (1.16.0)
  • Installing tomli (2.0.1)
  • Installing cfgv (3.3.1): Downloading... 0%
  • Installing coverage (6.3.2): Pending...
  • Installing cfgv (3.3.1)
  • Installing coverage (6.3.2)
  • Installing identify (2.4.12)
  • Installing nodeenv (1.6.0)
  • Installing pytest (7.1.1)
  • Installing pyyaml (6.0)
  • Installing sniffio (1.2.0)
  • Installing termcolor (1.1.0)
  • Installing toml (0.10.2)
  • Installing typing-extensions (4.1.1)
  • Installing virtualenv (20.14.0)
  • Installing zipp (3.8.0)
  • Installing anyio (3.5.0): Installing...
  • Installing asyncclick (8.0.3.2): Pending...
  • Installing codecov (2.1.12): Installing...
  • Installing anyio (3.5.0)
  • Installing asyncclick (8.0.3.2)
  • Installing codecov (2.1.12)
  • Installing importlib-metadata (4.11.3)
  • Installing pre-commit (2.18.1)
  • Installing pydantic (1.9.0)
  • Installing pytest-asyncio (0.18.3)
  • Installing pytest-cov (2.12.1)
  • Installing pytest-mock (3.7.0)
  • Installing pytest-sugar (0.9.4)
  • Installing tox (3.24.5)
  • Installing voluptuous (0.13.0)
  • Installing xdoctest (0.15.10)

Installing the current project: python-kasa (0.5.0)

It installed it into ~/.cache/pypoetry. I exported it into my path so I could call kasa (e.g. $ export PATH=$PATH:/home/builder/.cache/pypoetry/virtualenvs/python-kasa-ZQLIqOJb-py3.10/bin/)

While the binary in WSL didn’t find it

builder@DESKTOP-QADGF36:~/Workspaces/python-kasa$ kasa discover
Discovering devices on 255.255.255.255 for 3 seconds

builder@DESKTOP-QADGF36:~/Workspaces/python-kasa$ kasa
No host name given, trying discovery..
Discovering devices on 255.255.255.255 for 3 seconds

Doing it on Windows did work

C:\Users\isaac\Workspaces\hv-packer>pip install python-kasa
Defaulting to user installation because normal site-packages is not writeable
Collecting python-kasa
  Downloading python_kasa-0.5.0-py3-none-any.whl (131 kB)
     |████████████████████████████████| 131 kB 3.3 MB/s
Collecting importlib-metadata
  Downloading importlib_metadata-5.0.0-py3-none-any.whl (21 kB)
Collecting asyncclick>=8
  Downloading asyncclick-8.1.3.4-py3-none-any.whl (97 kB)
     |████████████████████████████████| 97 kB 6.4 MB/s
Collecting pydantic<2,>=1
  Downloading pydantic-1.10.2-cp39-cp39-win_amd64.whl (2.1 MB)
     |████████████████████████████████| 2.1 MB 6.8 MB/s
Collecting anyio
  Downloading anyio-3.6.2-py3-none-any.whl (80 kB)
     |████████████████████████████████| 80 kB ...
Requirement already satisfied: colorama in c:\program files\python39\lib\site-packages (from asyncclick>=8->python-kasa) (0.4.4)
Collecting typing-extensions>=4.1.0
  Downloading typing_extensions-4.4.0-py3-none-any.whl (26 kB)
Collecting sniffio>=1.1
  Downloading sniffio-1.3.0-py3-none-any.whl (10 kB)
Collecting idna>=2.8
  Downloading idna-3.4-py3-none-any.whl (61 kB)
     |████████████████████████████████| 61 kB ...
Collecting zipp>=0.5
  Downloading zipp-3.9.0-py3-none-any.whl (5.8 kB)
Installing collected packages: zipp, typing-extensions, sniffio, idna, pydantic, importlib-metadata, asyncclick, anyio, python-kasa
  WARNING: The script kasa.exe is installed in 'C:\Users\isaac\AppData\Roaming\Python\Python39\Scripts' which is not on PATH.
  Consider adding this directory to PATH or, if you prefer to suppress this warning, use --no-warn-script-location.
Successfully installed anyio-3.6.2 asyncclick-8.1.3.4 idna-3.4 importlib-metadata-5.0.0 pydantic-1.10.2 python-kasa-0.5.0 sniffio-1.3.0 typing-extensions-4.4.0 zipp-3.9.0
WARNING: You are using pip version 21.1.3; however, version 22.3 is available.
You should consider upgrading via the 'c:\program files\python39\python.exe -m pip install --upgrade pip' command.

C:\Users\isaac\Workspaces\hv-packer>C:\Users\isaac\AppData\Roaming\Python\Python39\Scripts\kasa.exe
No host name given, trying discovery..
Discovering devices on 255.255.255.255 for 3 seconds
== Office LED lights - HS103(US) ==
        Host: 192.168.1.245
        Device state: ON

        == Generic information ==
        Time:         2022-10-22 18:52:59 (tz: {'index': 13, 'err_code': 0}
        Hardware:     5.0
        Software:     1.0.3 Build 201015 Rel.142523
        MAC (rssi):   6C:5A:B0:BC:40:23 (-44)
        Location:     {'latitude': 44.9305, 'longitude': -92.9119}

        == Device specific information ==
        LED state: True
        On since: 2022-10-22 08:36:04

        == Modules ==
        + <Module Schedule (schedule) for 192.168.1.245>
        + <Module Usage (schedule) for 192.168.1.245>
        + <Module Antitheft (anti_theft) for 192.168.1.245>
        + <Module Time (time) for 192.168.1.245>
        + <Module Cloud (cnCloud) for 192.168.1.245>

Exception ignored in: <function _ProactorBasePipeTransport.__del__ at 0x0000024F86863EE0>
Traceback (most recent call last):
  File "c:\program files\python39\lib\asyncio\proactor_events.py", line 116, in __del__
    self.close()
  File "c:\program files\python39\lib\asyncio\proactor_events.py", line 108, in close
    self._loop.call_soon(self._call_connection_lost, None)
  File "c:\program files\python39\lib\asyncio\base_events.py", line 746, in call_soon
    self._check_closed()
  File "c:\program files\python39\lib\asyncio\base_events.py", line 510, in _check_closed
    raise RuntimeError('Event loop is closed')
RuntimeError: Event loop is closed

I can now turn them off and on locally

builder@DESKTOP-QADGF36:~/Workspaces/python-kasa$ kasa --host 192.168.1.245 off
No --type defined, discovering..
Turning off Office LED lights
builder@DESKTOP-QADGF36:~/Workspaces/python-kasa$ kasa --host 192.168.1.245 on
No --type defined, discovering..
Turning on Office LED lights

I should note, installing this with Python10 on a different Windows host worked fine (no python stack dump at end)

C:\Users\isaac>C:\Users\isaac\AppData\Roaming\Python\Python310\Scripts\kasa.exe
No host name given, trying discovery..
Discovering devices on 255.255.255.255 for 3 seconds
== Office LED lights - HS103(US) ==
        Host: 192.168.1.245
        Device state: OFF

        == Generic information ==
        Time:         2022-10-23 09:47:47 (tz: {'index': 13, 'err_code': 0}
        Hardware:     5.0
        Software:     1.0.3 Build 201015 Rel.142523
        MAC (rssi):   6C:5A:B0:BC:40:23 (-38)
        Location:     {'latitude': 44.9305, 'longitude': -92.9119}

        == Device specific information ==
        LED state: True
        On since: None

        == Modules ==
        + <Module Schedule (schedule) for 192.168.1.245>
        + <Module Usage (schedule) for 192.168.1.245>
        + <Module Antitheft (anti_theft) for 192.168.1.245>
        + <Module Time (time) for 192.168.1.245>
        + <Module Cloud (cnCloud) for 192.168.1.245>

== OfficeLamp - HS103(US) ==
        Host: 192.168.1.24
        Device state: OFF

        == Generic information ==
        Time:         2022-10-23 09:47:36 (tz: {'index': 13, 'err_code': 0}
        Hardware:     5.0
        Software:     1.0.3 Build 201015 Rel.142523
        MAC (rssi):   6C:5A:B0:BC:7D:E5 (-47)
        Location:     {'latitude': 44.9305, 'longitude': -92.9119}

        == Device specific information ==
        LED state: True
        On since: None

        == Modules ==
        + <Module Schedule (schedule) for 192.168.1.24>
        + <Module Usage (schedule) for 192.168.1.24>
        + <Module Antitheft (anti_theft) for 192.168.1.24>
        + <Module Time (time) for 192.168.1.24>
        + <Module Cloud (cnCloud) for 192.168.1.24>

Containerizing it

(If you want to skip how I worked through this, feel free to just look at the forked repo)

First, I’ll get a basic Flask app started

$ cat restApi.py
from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello_world():
    return "Hello, World!"

Then test it

builder@DESKTOP-72D2D9T:~/Workspaces/python-kasa$ export FLASK_APP=restApi.py
builder@DESKTOP-72D2D9T:~/Workspaces/python-kasa$ export FLASK_ENV=development
builder@DESKTOP-72D2D9T:~/Workspaces/python-kasa$ /home/builder/.local/bin/flask run
 * Serving Flask app 'restApi.py' (lazy loading)
 * Environment: development
 * Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 134-193-920
127.0.0.1 - - [29/Oct/2022 09:10:27] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [29/Oct/2022 09:10:27] "GET /favicon.ico HTTP/1.1" 404 -

/content/images/2022/10/kasapy-01.png

Adding virtenv

Cbuilder@DESKTOP-72D2D9T:~/Workspaces/python-kasa$ pip install pipenv
Collecting pipenv
  Downloading pipenv-2022.10.25-py2.py3-none-any.whl (3.2 MB)
     |████████████████████████████████| 3.2 MB 1.7 MB/s
Requirement already satisfied: setuptools>=36.2.1 in /usr/lib/python3/dist-packages (from pipenv) (45.2.0)
Collecting virtualenv-clone>=0.2.5
  Downloading virtualenv_clone-0.5.7-py3-none-any.whl (6.6 kB)
Requirement already satisfied: certifi in /usr/lib/python3/dist-packages (from pipenv) (2019.11.28)
Collecting virtualenv
  Downloading virtualenv-20.16.6-py3-none-any.whl (8.8 MB)
     |████████████████████████████████| 8.8 MB 1.4 MB/s
Collecting platformdirs<3,>=2.4
  Using cached platformdirs-2.5.2-py3-none-any.whl (14 kB)
Collecting distlib<1,>=0.3.6
  Using cached distlib-0.3.6-py2.py3-none-any.whl (468 kB)
Collecting filelock<4,>=3.4.1
  Using cached filelock-3.8.0-py3-none-any.whl (10 kB)
Installing collected packages: virtualenv-clone, platformdirs, distlib, filelock, virtualenv, pipenv
  WARNING: The script virtualenv-clone is installed in '/home/builder/.local/bin' which is not on PATH.
  Consider adding this directory to PATH or, if you prefer to suppress this warning, use --no-warn-script-location.
  WARNING: The script virtualenv is installed in '/home/builder/.local/bin' which is not on PATH.
  Consider adding this directory to PATH or, if you prefer to suppress this warning, use --no-warn-script-location.
  WARNING: The scripts pipenv and pipenv-resolver are installed in '/home/builder/.local/bin' which is not on PATH.
  Consider adding this directory to PATH or, if you prefer to suppress this warning, use --no-warn-script-location.
Successfully installed distlib-0.3.6 filelock-3.8.0 pipenv-2022.10.25 platformdirs-2.5.2 virtualenv-20.16.6 virtualenv-clone-0.5.7
builder@DESKTOP-72D2D9T:~/Workspaces/python-kasa$ mkdir cashman-flask-project && cd cashman-flask-project
builder@DESKTOP-72D2D9T:~/Workspaces/python-kasa/cashman-flask-project$ pipenv --three

Command 'pipenv' not found, but can be installed with:

sudo apt install pipenv

builder@DESKTOP-72D2D9T:~/Workspaces/python-kasa/cashman-flask-project$ /home/builder/.local/bin/pipenv --three
WARNING: --three is deprecated! pipenv uses python3 by default

Creating a virtualenv for this project...
Pipfile: /home/builder/Workspaces/python-kasa/cashman-flask-project/Pipfile
Using /usr/bin/python3.8 (3.8.10) to create virtualenv...
⠋ Creating virtual environment...created virtual environment CPython3.8.10.final.0-64 in 713ms
  creator Venv(dest=/home/builder/.local/share/virtualenvs/cashman-flask-project-0ejrmkiu, clear=False, no_vcs_ignore=False, global=False, describe=CPython3Posix)
  seeder FromAppData(download=False, pip=bundle, setuptools=bundle, wheel=bundle, via=copy, app_data_dir=/home/builder/.local/share/virtualenv)
    added seed packages: pip==22.3, setuptools==65.5.0, wheel==0.37.1
  activators BashActivator,CShellActivator,FishActivator,NushellActivator,PowerShellActivator,PythonActivator

✔ Successfully created virtual environment!
Virtualenv location: /home/builder/.local/share/virtualenvs/cashman-flask-project-0ejrmkiu
Creating a Pipfile for this project...
builder@DESKTOP-72D2D9T:~/Workspaces/python-kasa/cashman-flask-project$

add flask back

builder@DESKTOP-72D2D9T:~/Workspaces/python-kasa/cashman-flask-project$ /home/builder/.local/bin/pipenv install flask
Installing flask...
Adding flask to Pipfile's [packages]...
✔ Installation Succeeded
Pipfile.lock not found, creating...
Locking [packages] dependencies...
Building requirements...
Resolving dependencies...
✔ Success!
Locking [dev-packages] dependencies...
Updated Pipfile.lock (601e48664aae47c8c903f9f070ec23d766dc916b445c91361ab8090b3c62cf9f)!
Installing dependencies from Pipfile.lock (62cf9f)...
To activate this project's virtualenv, run pipenv shell.
Alternatively, run a command inside the virtualenv with pipenv run.

This creates a couple files

builder@DESKTOP-72D2D9T:~/Workspaces/python-kasa/cashman-flask-project$ ls
Pipfile  Pipfile.lock
builder@DESKTOP-72D2D9T:~/Workspaces/python-kasa/cashman-flask-project$ cat Pipfile
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

[packages]
flask = "*"

[dev-packages]

[requires]
python_version = "3.8"
python_full_version = "3.8.10"
builder@DESKTOP-72D2D9T:~/Workspaces/python-kasa/cashman-flask-project$ cat Pipfile.lock
{
    "_meta": {
        "hash": {
            "sha256": "601e48664aae47c8c903f9f070ec23d766dc916b445c91361ab8090b3c62cf9f"
        },
        "pipfile-spec": 6,
        "requires": {
          ...snip...

Now I’ll add an index.py with some switches

builder@DESKTOP-72D2D9T:~/Workspaces/python-kasa/cashman-flask-project/restapi$ cat index.py
from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello_world():
    return "Hello, World!"

@app.route("/on")
def turn_on():
    return "Turn On!"

@app.route("/off")
def turn_off():
    return "Turn Off!"

@app.route("/swap")
def turn_swap():
    return "Swap on and off!"

In the parent directory we will make a bootstrap.sh and change its perms

builder@DESKTOP-72D2D9T:~/Workspaces/python-kasa/cashman-flask-project$ cat bootstrap.sh
#!/bin/sh
export FLASK_APP=./restapi/index.py
pipenv run flask --debug run -h 0.0.0.0
builder@DESKTOP-72D2D9T:~/Workspaces/python-kasa/cashman-flask-project$ chmod u+x bootstrap.sh

Now to test

builder@DESKTOP-72D2D9T:~/Workspaces/python-kasa/cashman-flask-project$ export PATH=$PATH:/home/builder/.local/bin
builder@DESKTOP-72D2D9T:~/Workspaces/python-kasa/cashman-flask-project$ ./bootstrap.sh
 * Serving Flask app './restapi/index.py'
 * Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:5000
 * Running on http://172.25.86.186:5000
Press CTRL+C to quit
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 130-147-564

/content/images/2022/10/kasapy-02.png

Next, we can push an image up

docker push harbor.freshbrewed.science/freshbrewedprivate/kasarest:1.0.0
The push refers to repository [harbor.freshbrewed.science/freshbrewedprivate/kasarest]
5f6d128f32ff: Pushed
add780bbe862: Pushed
91d2c77a2edc: Pushed
712c133ff3aa: Pushed
ebdb09e1ad9c: Pushed
98310229ec44: Pushed
229486f87ac7: Pushed
ec8f0bde2cdf: Pushed
2fef07314e9a: Pushed
15dfb8e06bed: Pushed
83b7d033bde4: Pushed
3d3e9708cef3: Pushed
1fe0699af9f7: Pushed
156568a71809: Pushed
5fca8a94d542: Pushed
6b183c62e3d7: Pushed
882fd36bfd35: Pushed
d1dec9917839: Pushed
d38adf39e1dd: Pushed
4ed121b04368: Pushed
d9d07d703dd5: Pushed
1.0.0: digest: sha256:ba33e37dbf3d550c2c3bfdbec2d25bb58618e5042dd377dffa87116f3d37be47 size: 4734

And I can now see in Harbor

/content/images/2022/10/kasapy-03.png

We can “copy” the pull command

docker pull harbor.freshbrewed.science/freshbrewedprivate/kasarest@sha256:ba33e37dbf3d550c2c3bfdbec2d25bb58618e5042dd377dffa87116f3d37be47

But more exactly we’ll be using this in a basic deployment.

I’ll add an A record

$ cat r53-kasarest.json 
{
    "Comment": "CREATE kasarest fb.s A record ",
    "Changes": [
      {
        "Action": "CREATE",
        "ResourceRecordSet": {
          "Name": "kasarest.freshbrewed.science",
          "Type": "A",
          "TTL": 300,
          "ResourceRecords": [
            {
              "Value": "73.242.50.46"
            }
          ]
        }
      }
    ]
  }

$ aws route53 change-resource-record-sets --hosted-zone-id Z39E8QFU0F9PZP --change-batch file://r53-kasarest.json
{
    "ChangeInfo": {
        "Id": "/change/C02863662AUH1QQYVNMQI",
        "Status": "PENDING",
        "SubmittedAt": "2022-10-31T17:25:59.145Z",
        "Comment": "CREATE kasarest fb.s A record "
    }
}

Then create a quick deployment yaml

$ cat kasadep.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: kasarest
    app.kubernetes.io/name: kasarest
  name: kasarest-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: kasarest
  strategy:
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 25%
    type: RollingUpdate
  template:
    metadata:
      labels:
        app: kasarest
    spec:
      containers:
      - env:
        - name: PORT
          value: "5000"
        image: harbor.freshbrewed.science/freshbrewedprivate/kasarest:1.0.0
        name: kasarest
        ports:
        - containerPort: 5000
          protocol: TCP
      imagePullSecrets:
      - name: myharborreg
      restartPolicy: Always
---
apiVersion: v1
kind: Service
metadata:
  labels:
    app.kubernetes.io/name: kasarest
  name: kasarest
spec:
  internalTrafficPolicy: Cluster
  ports:
  - name: http
    port: 5000
    protocol: TCP
    targetPort: 5000
  selector:
    app.kubernetes.io/name: kasarest
  sessionAffinity: None
  type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    kubernetes.io/ingress.class: nginx
    kubernetes.io/tls-acme: "true"
  name: kasarest
spec:
  rules:
  - host: kasarest.freshbrewed.science
    http:
      paths:
      - backend:
          service:
            name: kasarest
            port:
              number: 5000
        path: /
        pathType: ImplementationSpecific
  tls:
  - hosts:
    - kasarest.freshbrewed.science
    secretName: kasarest-tls
---

Then apply

$ kubectl apply -f kasadep.yaml 
deployment.apps/kasarest-deployment created
service/kasarest created
ingress.networking.k8s.io/kasarest created

Then I can check on it’s progress

builder@DESKTOP-QADGF36:~/Workspaces/jekyll-blog$ kubectl get pods | grep kasa
kasarest-deployment-7f9697c654-x8lrs                     0/1     ContainerCreating   0                 55s
builder@DESKTOP-QADGF36:~/Workspaces/jekyll-blog$ kubectl describe pod kasarest-deployment-7f9697c654-x8lrs | tail -n20
  Initialized       True 
  Ready             False 
  ContainersReady   False 
  PodScheduled      True 
Volumes:
  kube-api-access-2nqgm:
    Type:                    Projected (a volume that contains injected data from multiple sources)
    TokenExpirationSeconds:  3607
    ConfigMapName:           kube-root-ca.crt
    ConfigMapOptional:       <nil>
    DownwardAPI:             true
QoS Class:                   BestEffort
Node-Selectors:              <none>
Tolerations:                 node.kubernetes.io/not-ready:NoExecute op=Exists for 300s
                             node.kubernetes.io/unreachable:NoExecute op=Exists for 300s
Events:
  Type    Reason     Age   From               Message
  ----    ------     ----  ----               -------
  Normal  Scheduled  30s   default-scheduler  Successfully assigned default/kasarest-deployment-7f9697c654-x8lrs to builder-hp-elitebook-850-g2
  Normal  Pulling    30s   kubelet            Pulling image "harbor.freshbrewed.science/freshbrewedprivate/kasarest:1.0.0"

builder@DESKTOP-QADGF36:~/Workspaces/jekyll-blog$ kubectl get pods | grep kasa
kasarest-deployment-7f9697c654-x8lrs                     1/1     Running   0                 3m1s

Adding to Datadog

In Datadog, we go to Integrations

/content/images/2022/10/kasapy-04.png

I’ll go to Webhooks where I already have some old Rundeck ones defined

/content/images/2022/10/kasapy-05.png

I’ll add my endpoint using the JSON to pass the form var

/content/images/2022/10/kasapy-06.png

The resulting webhook:

/content/images/2022/10/kasapy-07.png

Next, I’ll add a new Monitor based on Host

/content/images/2022/10/kasapy-08.png

and we can then send an alert to that endpoint

/content/images/2022/10/kasapy-09.png

Running a test failed (that is, the light did not change)

/content/images/2022/10/kasapy-08.png

After several unsuccessful attempts, I realized the issue was i was assuming GET when the webhook in Datadog was using POST

10.42.2.104 - - [31/Oct/2022 20:30:14] "GET /off?devip=192.168.1.24 HTTP/1.1" 200 -
10.42.2.104 - - [31/Oct/2022 20:30:19] "POST /on?devip=192.168.1.24 HTTP/1.1" 405 -
10.42.2.104 - - [31/Oct/2022 20:30:20] "POST /on?devip=192.168.1.24 HTTP/1.1" 405 -

Adding POST

This was an easy fix. I updated my rest functions to add a post block

import subprocess
from subprocess import Popen, PIPE
from subprocess import check_output
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route("/")
def hello_world():
    return "use /on /off and /swap with ?devip=IP!"

@app.route("/on", methods=["GET","POST"])
def turn_on():
    if request.method == 'GET':
        devip = request.args.get('devip','192.168.1.245')
        typen = request.args.get('type','plug')
        stdout = check_output(['./some.sh',typen,devip,'on']).decode('utf-8')
        return '''<h1>Turning On {}: {}</h1>'''.format(devip, stdout)
    if request.method == 'POST':
        devip = request.form.get('devip')
        typen = request.form.get('type','plug')
        stdout = check_output(['./some.sh',typen,devip,'on']).decode('utf-8')
        return '''<h1>Turning On {}: {}</h1>'''.format(devip, stdout)

@app.route("/off", methods=["GET","POST"])
def turn_off():
    if request.method == 'GET':
        devip = request.args.get('devip','192.168.1.245')
        typen = request.args.get('type','plug')
        stdout = check_output(['./some.sh',typen,devip,'off']).decode('utf-8')
        return '''<h1>Turning Off {}: {}</h1>'''.format(devip, stdout)
    if request.method == 'POST':
        devip = request.form.get('devip')
        typen = request.form.get('type','plug')
        stdout = check_output(['./some.sh',typen,devip,'off']).decode('utf-8')
        return '''<h1>Turning Off {}: {}</h1>'''.format(devip, stdout)

@app.route("/swap", methods=["GET","POST"])
def turn_swap():
    if request.method == 'GET':
        devip = request.args.get('devip','192.168.1.245')
        typen = request.args.get('type','plug')
        #return '''<h1>Swapping On/Off: {}</h1>'''.format(devip)
        stdout = check_output(['./swap.sh',typen,devip]).decode('utf-8')
        return stdout
    if request.method == 'POST':
        devip = request.form.get('devip','192.168.1.245')
        typen = request.form.get('type','plug')
        stdout = check_output(['./swap.sh',typen,devip]).decode('utf-8')
        return 

Once I built and pushed a new container from the Dockerfile and tagged it 1.1.0

/content/images/2022/10/kasapy-11.png

I simply updated the deployment YAML to include it (harbor.freshbrewed.science/freshbrewedprivate/kasarest:1.1.0)

$ cat kasadep.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: kasarest
    app.kubernetes.io/name: kasarest
  name: kasarest-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: kasarest
  strategy:
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 25%
    type: RollingUpdate
  template:
    metadata:
      labels:
        app: kasarest
        app.kubernetes.io/name: kasarest
    spec:
      containers:
      - env:
        - name: PORT
          value: "5000"
        image: harbor.freshbrewed.science/freshbrewedprivate/kasarest:1.1.0
        name: kasarest
        ports:
        - containerPort: 5000
          protocol: TCP
      imagePullSecrets:
      - name: myharborreg
      restartPolicy: Always
---
apiVersion: v1
kind: Service
metadata:
  labels:
    app.kubernetes.io/name: kasarest
  name: kasarest
spec:
  internalTrafficPolicy: Cluster
  ports:
  - name: http
    port: 5000
    protocol: TCP
    targetPort: 5000
  selector:
    app.kubernetes.io/name: kasarest
  sessionAffinity: None
  type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    kubernetes.io/ingress.class: nginx
    kubernetes.io/tls-acme: "true"
  name: kasarest
spec:
  rules:
  - host: kasarest.freshbrewed.science
    http:
      paths:
      - backend:
          service:
            name: kasarest
            port:
              number: 5000
        path: /
        pathType: ImplementationSpecific
  tls:
  - hosts:
    - kasarest.freshbrewed.science
    secretName: kasarest-tls
---

This means I can still turn off my office light with a GET (e.g. have fun and harass me by turning off my desk LED lights with https://kasarest.freshbrewed.science/off but then turn them back on with https://kasarest.freshbrewed.science/on)

Then it worked to trigger:

/content/images/2022/10/kasapy-12.png

This is a great start. But perhaps we want to turn it off when our cluster comes back to known good.

Now we can do the inverse to create a Monitor on the down condition being missing

/content/images/2022/10/kasapy-13.png

But that might get a little weird as we would have a monitor constantly in error.

Instead, I’ll initially flip the light but also send some reminder emails

/content/images/2022/10/kasapy-14.png

Which when tested, not only flips the light, but sends an email

/content/images/2022/10/kasapy-15.png

And clicking the link flips the light back off

/content/images/2022/10/kasapy-16.png

Builds

I can alert on other things as well. For instance, say I want a bulb to kick in when I build things in Github.

I quickly created a simple “Build” logo that I 3d Printed ( IkeaLampBuild.stl ).

Because I push an event with the github workflow (used for this blog), I’ll be able to use that in an event monitor.

      - name: Build count
        uses: masci/datadog@v1
        with:
          api-key: $
          metrics: |
            - type: "count"
              name: "prfinal.runs.count"
              value: 1.0
              host: $
              tags:
                - "project:$"
                - "branch:$"
      - name: Datadog-Pass
        uses: masci/datadog@v1
        with:
          api-key: $
          events: |
            - title: "Passed building jekyll"
              text: "Branch $ passed build"
              alert_type: "info"
              host: $
              tags:
                - "project:$"

My Datadog tags are a bit funny in that I make the “host” my Repo Owner (idjohnson in github). But it’s still a thing we can query.

I can then create some webhooks we’ll use for a event based monitor.

First, I create the On and Off webhooks

/content/images/2022/10/kasapy-20.png

The off for ended:

/content/images/2022/10/kasapy-21.png

Then I can create the monitor which will trigger the build is running (alert) and completed (alert recovery) using the following message syntax:

{{#is_alert}}
@webhook-BuildStarted 
{{/is_alert}}

{{#is_recovery}}
@webhook-BuildEnded
{{/is_recovery}}

Which we can test

and see in action:

/content/images/2022/11/20221102_142549.jpg

I did need to tweak the threshold to be “above” 0.5 since the initial (above 1) didn’t trigger on 1.

/content/images/2022/10/kasapy-24.png

However, if we pause for a moment, we’ll realize this is rather silly.
I could simply trigger the call inline on my GH Runners.

To test, I muted my DD Monitor

/content/images/2022/10/kasapy-25.png

In my PR build (the main build), I updated some steps:

# at the start of both test and final builds
    ...
    steps:
      - name: Check out repository code
        uses: actions/checkout@v2
      - name: Turn on build light
        run: |
          curl -s -o /dev/null https://kasarest.freshbrewed.science/on?devip=192.168.1.3
    ...
# for passing
      ...
      - name: Datadog-Pass
        uses: masci/datadog@v1
        with:
          api-key: $
          events: |
            - title: "Passed building jekyll"
              text: "Branch $ passed build"
              alert_type: "info"
              host: $
              tags:
                - "project:$"
      - name: Turn off build light
        run: |
          curl -s -o /dev/null https://kasarest.freshbrewed.science/off?devip=192.168.1.3

# for failures
  run-if-failed:
    runs-on: ubuntu-latest
    needs: [build_deploy_test, build_deploy_final]
    if: always() && (needs.build_deploy_test.result == 'failure' || needs.build_deploy_final.result == 'failure')
    steps:
      - name: Blink and Leave On build light
        run: |
          curl -s -o /dev/null https://kasarest.freshbrewed.science/off?devip=192.168.1.3 && sleep 5 \
          && curl -s -o /dev/null https://kasarest.freshbrewed.science/on?devip=192.168.1.3 && sleep 5 \
          &&  curl -s -o /dev/null https://kasarest.freshbrewed.science/off?devip=192.168.1.3 && sleep 5 \
          && curl -s -o /dev/null https://kasarest.freshbrewed.science/on?devip=192.168.1.3
      - name: Datadog-Fail
      ....

Here we can see it in action:

Summary

We tackled using Python-Kasa to trigger TP-Link smart plugs. We then containerized it with a Flask app and published it to Kubernetes. Lastly, we tied it in as a Webhook (and POST handlers) and used it to trigger a physical light should our Cluster have issues.

To demonstrate other kinds of Monitors, I setup an Event Monitor on a simple metric I push in my Github Workflows. This allowed me to trigger a switch tied to a lightbulb with the word “build” when a build was running (albeit with a slight delay).

There is an obvious issue, if you think about it - I run this containerized service ON the cluster that might be down. If it’s just a Datadog agent issue or something simple, we might still be fine. But if our cluster is out (such as the master going down), then we clearly cannot trigger the bulb.

This might mean we need to move this function to a different cluster or host it in some other way, such as an internal pi or Linux host. We could look into using the TPLink cloud as we can see in guides here and here. The only issue with that is it is easy for a vendor to just deprecate their unofficial cloud API or end support for Apps. My plugs, unless I firmware update, should be accessible on port 80 and UDP 20002 (discovery). One more crazy approach that comes to mind is to expose a port on my external IP and forward traffic directly to the plug. Then I could run the function in the public cloud. However, to expose each bulb would be cumbersome and likely not a scalable solution.

datadog tplink smartbulb kasa python

Have something to add? Feedback? You can use the feedback form

Isaac Johnson

Isaac Johnson

Cloud Solutions Architect

Isaac is a CSA and DevOps engineer who focuses on cloud migrations and devops processes. He also is a dad to three wonderful daughters (hence the references to Princess King sprinkled throughout the blog).

Theme built by C.S. Rhymes