Skutt API into Grafana

Skutt API into Grafana
My wife's kiln in the garage

My wife has a Skutt KMT-1227-3 Kiln with the KilnMasters touchscreen as the interface. It's pretty neat and even has a handy dandy companion App called KilnLink. Unfortunately, that's about where the fun stops. You can't really do anything neat with the app unless you have a premium subscription. Sure you can view the current status, but I wanted a bit more historical data to go along with it. Surely someone has done this before...right?

Reverse Engineering the API

Turns out I couldn't find anything useful on the web. So let's crack into the API ourselves and see what we get. Although I say reverse engineering, that term is a doing a lot of heavy lifting. We are just going to poke around in the source code of the web app and see what we can find.

You can log into the KilnLink app from their web portal https://www.kilnlink.com/. From there we can just inspect the traffic using whatever browser. They all do it these days.

We could also decompile the Android app (which might gleen some more information) but we'll leave that if we can't get what we need from the website.

Here is a snapshot of the API calls:

Some of the more interesting calls are nicely labeled: login, and view. Let's look at login first.

Logging in

The login is pretty straightforwards: it's a POST request to https://api.kilnlink.com/users/login with URL encoded form data consisting of the email and password. In return, we get an JSON object with kiln info, a pair of JWT tokens, and some other info that isn't relevant at the moment.

Here is an example request curl:

curl --location 'https://api.kilnlink.com/users/login' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'email=YOUR_EMAIL_HERE' \
--data-urlencode 'password=YOUR_PASSWORD_HERE'

And equivalant response:

{
    "message": "User logged in successfully",
    "token": "TOKEN",
    "refresh": "REFRESH_TOKEN",
    "access": "ACCESS_TOKEN",
    "kiln_info": [
        {
            "id": "SOME_ID",
            "role": "owner",
            "name": "KILN_NAME"
        }
    ],
    "role": "user",
    "company": "No account assigned",
    "account": "0",
    "user_type_for_account": "readonly",
    "premium": false,
    "kiln_limit": 0,
    "needs_terms_accepted": false,
    "has_kilnlink_free_kilns": true
}

Getting a Kiln Object

So now that we've got a token, let's get some KILN DATA!

There are two calls that are particularly interesting from the inspect view: slim and view. Slim looks like a more flattened version of view without all the diagnostic and firing step information. I'm not sure what purpose slim serves here since the next call in the app is a call to view, but let's leave slim alone and focus on view.

The view call is also a POST call but to a different url base (which is interesting?) passing in an array of kiln ids. My kiln id is all numeric but it's returned as a string (and sent as a string).

Here is a sample curl:

curl --location 'https://kiln.bartinst.com/kilns/view' \
--header 'x-access-token: TOKEN' \
--header 'Accept: application/json' \
--header 'x-app-name-token: kiln-link' \
--header 'Content-Type: application/json' \
--data '{
    "ids": ["ID"]
}'

An an exmaple response:

{
    "kilns": [
        {
            "latest_firing": {
                "start_time": "1696003436137",
                "ended": false,
                "update_time": "1696012872453",
                "just_ended": false,
                "ended_time": null
            },
            "name": "default controller",
            "users": [],
            "is_premium": false,
            "status": {
                "_id": "65171ac0827b61000beeeaaf",
                "fw": "SDI-1.11.0",
                "mode": "Firing",
                "alarm": "OFF",
                "t1": 9999,
                "t2": 183,
                "t3": 9999,
                "num_fire": 41,
                "firing": {
                    "set_pt": 204,
                    "step": "Ramp 2 of 6",
                    "fire_min": 36,
                    "fire_hour": 2,
                    "hold_min": 0,
                    "hold_hour": 0,
                    "start_min": 0,
                    "start_hour": 0,
                    "cost": "0.23",
                    "max_temp": 187,
                    "etr": "7h 47m",
                    "cost_raw": 23
                },
                "diag": {
                    "a1": 18,
                    "a2": 15,
                    "a3": 18,
                    "nl": 232,
                    "fl": 221,
                    "v1": 1748,
                    "v2": 1748,
                    "v3": 1748,
                    "vs": 1748,
                    "board_t": 89,
                    "last_err": 255,
                    "date": "2023-09-29T18:32:52.277Z"
                },
                "error": {
                    "err_text": "No Errors",
                    "err_num": 255
                }
            },
            "config": {
                "_id": "65171ac0827b61000beeeaa7",
                "err_codes": "On",
                "t_scale": "F",
                "no_load": 232,
                "full_load": 221,
                "num_zones": 1
            },
            "program": {
                "_id": "65171ac0827b61000beeeaa8",
                "name": "Cone 5 Medium",
                "type": "Bisque",
                "num_steps": 6,
                "alarm_t": 0,
                "speed": "Medium",
                "cone": "5",
                "steps": [
                    {
                        "_id": "65171ac0827b61000beeeaae",
                        "t": 180,
                        "hr": 0,
                        "mn": 45,
                        "rt": 60,
                        "num": 1
                    },
                    {
                        "_id": "65171ac0827b61000beeeaad",
                        "t": 250,
                        "hr": 0,
                        "mn": 0,
                        "rt": 200,
                        "num": 2
                    },
                    {
                        "_id": "65171ac0827b61000beeeaac",
                        "t": 1000,
                        "hr": 0,
                        "mn": 0,
                        "rt": 400,
                        "num": 3
                    },
                    {
                        "_id": "65171ac0827b61000beeeaab",
                        "t": 1150,
                        "hr": 0,
                        "mn": 0,
                        "rt": 180,
                        "num": 4
                    },
                    {
                        "_id": "65171ac0827b61000beeeaaa",
                        "t": 1917,
                        "hr": 0,
                        "mn": 0,
                        "rt": 300,
                        "num": 5
                    },
                    {
                        "_id": "65171ac0827b61000beeeaa9",
                        "t": 2167,
                        "hr": 0,
                        "mn": 7,
                        "rt": 120,
                        "num": 6
                    }
                ]
            },
            "log_request": false,
            "mac_address": "",
            "serial_number": "",
            "firmware_version": "1.4.0",
            "product": "SDIK1",
            "external_id": "",
            "createdAt": "2021-02-16T15:14:13.538Z",
            "updatedAt": "2023-09-29T18:43:12.537Z",
            "firings_count": 41,
            "latest_firing_start_time": "1696003436137",
            "firmware_version_update_timestamp": "2023-05-07T23:24:13.205Z",
            "firmware_versions": [
                "SDI-1.11.0"
            ]
        }
    ]
}

WHAT IS ALL THIS DATA?

First and foremost, there is a lot of really good data in here ripe for the picking. Some of it is also confusing. Under the status section, we have 3 sections for temperature (labeled t1, t2, and t3), however I only get real temperatures back for t2. I assume this is because in the configuration of the Kiln that I have, the thermoprobe is situated in the middle set (which is probably t2). I imagine if it was on the top it'd be t1 and likewise the bottom for t3 (or vice versa).

We also get data on what ramp we are currently in. We could store this data and easily retrive how long each segment took in real life.

Finally, we get some data about the kiln itself in the form of diagnostics. They include no_load and full_load voltages, amperages, and what I assume are more voltages of some kind? The voltage numbers don't really make sense though, since they all are 1748 (which is way outside of the US's 240V range). So I'm assuming it's just placeholder data and it's not relevant for me.

Refreshing the Token

So we are logged in, we are getting data, and everything is great! But how do we get a refreshed token? For that we have to either wait for it to happen organically, or we can look for it in the code. I'm impatient, I opted to spend the time hunting around for it.

Since we have access to the source code, we can just take a look and see how they refresh the tokens. The only problem is most javascript on the web is minified and obfuscated, so it might take some time to look through the source code. Unless of course they published the un-obfuscated version. However, in this case, whatever build tasks they are doing have left a ton of code un-obfuscated! So we can just...look at the source code. So let's see how to use the refresh token:

refreshLogin() {
    this.storage.ready().then(() => {
        this.storage.get("email").then(e => {
            const n = e;
            this.storage.get("password").then(e => {
                var t = this.claimUrl + "/users/login";
                let o = {
                    email: n && n.trim(),
                    password: e
                };
                const r = {
                    headers: this.genericHeaders()
                };
                this.http.post(t, o, r).toPromise().then(e => this.events.publish("refeshLogin:success")).catch(e => this.events.publish("refeshLogin:error"))
            })
        })
    })
}

Cool. We don't. It looks like we just re-login as the user. So why send back a refresh token at all then? Perhaps it's a legacy hold over from before they were using refresh tokens or perhaps it's not fully rolled out to all the apps. This appears to be a cordova/ionic application so that could be the case. In any event, we have our way to refresh the token and we can get started on looking around in the more fun bits.

Other APIs

There are other APIs that are in the file, but it's not clear to me if you need premium to access them or if they just aren't implemented.

Skutt Kilns appear to use a modified firmware/hardware of the Bartlett Instrument Company. So I imagine these cover a lot of different use cases other than just Skutt.

Looking at the source code, there are quite a few pieces of dead code (or at least it appears that way). Some of that may be because the build tools strip out unused or unrelated code but we can only speculate.

Prometheus and Grafana

Cool. Now what?

From here we can get this info into Prometheus or something similar. I opted for Prometheus and wrote an app that just polls the endpoints and collects the data.

Introducing: SKUTT EXPORTER!~

This is a simple app (mostly meant for me) but will login to the kiln API and create a nice metrics endpoint for consumption by a Prometheus endpoint.

Here is an example docker:

services:
  skutt-exporter:
    image: ghcr.io/bohregard/skutt-exporter:latest
    expose:
      - 8080
    container_name: skutt-exporter
    environment:
      - SKUTT_USERNAME=YOUR_EMAIL
      - SKUTT_PASSWORD=YOUR_PASSWORD

Now you can spin up a Prometheus endpoint and scrape using a configuration such as:

scrape_configs:
  - job_name: "SkuttKiln"
    static_configs:
      - targets: ["skutt-exporter:8080"]

And now I have a nice pretty dashboard I can stare at. Mixed with my Energy Monitor and a few other house hold automations, I can do some (hopefully) interesting things.

Future Plans

Right now this only works with Prometheus, but wouldn't it be great if this could work with Home Assistant or RedNode? That's my plan.