Google Ads 廣告欄位
🚧 請讀者考慮關閉廣告封鎖器 🚧
或將本站設定為白名單
以支持本站之運營

TP-Link HS300 串接 Grafana 監控家庭用電

最終成果
最終成果

自從前一陣子開始租屋體驗一度電 5 塊的日常,就有想看一下家裡電腦/NAS/冰箱等家電平常的耗電量大概多少,加上也希望可以遠程開關電腦,於是就買了 TP-Link Kasa HS300 這個 6 開關的延長線。

一拿到手的感覺就是怎麼有這麼短的延長線,基本上這長度是只能放在壁孔的旁邊,如果壁孔在地板附近,想要拉到桌上用可能都有點難度。

第二印象是 APP 提供的記錄功能太陽春了,在 APP 只能看到「當前的用電」、「過去七天的用電」、「過去三十天的用電」,沒辦法做到最常用的「我這兩個月花了多少電」這種查詢,或是想要比較一下過去的用電量也沒辦法。倒也不是說沒有到完全沒用,就是只能加減看,還要自己每個月初的時候手動記錄一下,有點麻煩。

之後這台就被我只被我當成電子XX(普通的遠端開關)用了一個多月,直到有一天心血來潮去搜了一下有沒有人有什麼記錄用電量的解決方法,才發現他的封包已經被逆向工程了,而且已經有人包好了 Python API 可以自己寫程式對他進行各種操作,這一搜直接打開新世界的開關,以下就拋磚引玉分享一下怎麼串接 Grafana。

奇怪的知識增加了
奇怪的知識增加了

Exporter

要串 Grafana 首先需要一個 exporter 去定期撈機器的的資料,搜了一下已經有一個現成的 fffonion/tplink-plug-exporter 用 Go 寫的 Prometheus Exporter,而且已經包好 docker 還有 dashboard template,測試了一下是可以用的,有比較懶的朋友可以直接用。

但因為他提供的 dashboard 有些元件已經 deprecated 了,而且版型也不是我想要的,最後還是自己用 python-kasa 寫了一個 Prometheus Exporter,主要也是因為他提供的 API 功能比較多,以後要自己再改什麼也比較方便,理論上想要接其他裝置應該也可以依樣畫葫蘆改一下就好了。

Python code 差不多就是一個 function 就寫完的感覺。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
import prometheus_client
from prometheus_client import Gauge
from prometheus_client.core import CollectorRegistry
from flask import Response, Flask
from kasa import SmartStrip, SmartDevice
import asyncio
import threading
import traceback
import os
import sys

DEV_IP = os.environ.get("DEV_IP")
if not DEV_IP:
    print('os.environ["DEV_IP"] is None')
    sys.exit(1)

app = Flask(__name__)
dev = SmartStrip(DEV_IP)
labelnames = ['alias', 'id', 'host']

def labels(dev: SmartDevice):
    return [dev.alias, dev.device_id, dev.host]

REGISTRY = CollectorRegistry(auto_describe=False)
kasa_on = Gauge("kasa_on", "On or off", registry=REGISTRY, labelnames=labelnames)
kasa_current = Gauge("kasa_current", "Current value", registry=REGISTRY, labelnames=labelnames)
kasa_voltage = Gauge("kasa_voltage", "Voltage value", registry=REGISTRY, labelnames=labelnames)
kasa_power = Gauge("kasa_power", "Power value", registry=REGISTRY, labelnames=labelnames)
kasa_total = Gauge("kasa_total", "Total value", registry=REGISTRY, labelnames=labelnames)
kasa_emeter_today = Gauge("kasa_emeter_today", "Energy today value", registry=REGISTRY, labelnames=labelnames)
kasa_emeter_this_month = Gauge("kasa_emeter_this_month", "Energy this month value", registry=REGISTRY, labelnames=labelnames)
kasa_rssi = Gauge("kasa_rssi", "RSSI value", registry=REGISTRY, labelnames=labelnames)

async def update_metrics():
    while True:
        try:
            await dev.update()

            dev_emeter_today = 0.0
            dev_emeter_this_month = 0.0

            for ch in dev.children:
                kasa_on.labels(*labels(ch)).set(ch.is_on)

                current = ch.emeter_realtime.current if ch.emeter_realtime.current else 0
                voltage = ch.emeter_realtime.voltage if ch.emeter_realtime.voltage else 0
                power = ch.emeter_realtime.power if ch.emeter_realtime.power else 0
                total = ch.emeter_realtime.total if ch.emeter_realtime.total else 0
                emeter_today = ch.emeter_today if ch.emeter_today else 0
                emeter_this_month = ch.emeter_this_month if ch.emeter_this_month else 0

                kasa_current.labels(*labels(ch)).set(current)
                kasa_voltage.labels(*labels(ch)).set(voltage)
                kasa_power.labels(*labels(ch)).set(power)
                kasa_total.labels(*labels(ch)).set(total)

                kasa_emeter_today.labels(*labels(ch)).set(emeter_today)
                dev_emeter_today += emeter_today

                kasa_emeter_this_month.labels(*labels(ch)).set(emeter_this_month)
                dev_emeter_this_month += emeter_this_month

            kasa_on.labels(*labels(dev)).set(dev.is_on)
            kasa_emeter_today.labels(*labels(dev)).set(dev_emeter_today)
            kasa_emeter_this_month.labels(*labels(dev)).set(dev_emeter_this_month)

            current = dev.emeter_realtime.current if dev.emeter_realtime.current else 0
            voltage = dev.emeter_realtime.voltage if dev.emeter_realtime.voltage else 0
            power = dev.emeter_realtime.power if dev.emeter_realtime.power else 0
            total = dev.emeter_realtime.total if dev.emeter_realtime.total else 0

            kasa_current.labels(*labels(dev)).set(current)
            kasa_voltage.labels(*labels(dev)).set(voltage)
            kasa_power.labels(*labels(dev)).set(power)
            kasa_total.labels(*labels(dev)).set(total)

            if dev.rssi:
                kasa_rssi.labels(*labels(dev)).set(dev.rssi)

        except Exception as e:
            print(f"update error: {e}")
            print(traceback.format_exc())

        await asyncio.sleep(1)

@app.route('/metrics')
def metrics():
    return Response(prometheus_client.generate_latest(REGISTRY), mimetype="text/plain")

if __name__ == "__main__":
    from waitress import serve
    update_thread = threading.Thread(target=asyncio.run, args=(update_metrics(),))
    update_thread.start()
    serve(app, host='0.0.0.0', port=9100)

可以測試一下資料是不是有被撈出來:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
❯ curl 10.0.0.3:9100/metrics
# HELP kasa_on On or off
# TYPE kasa_on gauge
kasa_on{alias="A",host="{{HOST_IP}}",id="{{ID}}_00"} 1.0
kasa_on{alias="B",host="{{HOST_IP}}",id="{{ID}}_01"} 1.0
kasa_on{alias="C",host="{{HOST_IP}}",id="{{ID}}_02"} 1.0
kasa_on{alias="D",host="{{HOST_IP}}",id="{{ID}}_03"} 1.0
kasa_on{alias="E",host="{{HOST_IP}}",id="{{ID}}_04"} 0.0
kasa_on{alias="F",host="{{HOST_IP}}",id="{{ID}}_05"} 1.0
kasa_on{alias="TP-LINK_Power Strip_B77D",host="{{HOST_IP}}",id="{{ID}}"} 1.0
# HELP kasa_current Current value
# TYPE kasa_current gauge
kasa_current{alias="A",host="{{HOST_IP}}",id="{{ID}}_00"} 0.015
kasa_current{alias="B",host="{{HOST_IP}}",id="{{ID}}_01"} 0.0
kasa_current{alias="C",host="{{HOST_IP}}",id="{{ID}}_02"} 0.026
kasa_current{alias="D",host="{{HOST_IP}}",id="{{ID}}_03"} 0.388
kasa_current{alias="E",host="{{HOST_IP}}",id="{{ID}}_04"} 0.0
kasa_current{alias="F",host="{{HOST_IP}}",id="{{ID}}_05"} 0.047
kasa_current{alias="TP-LINK_Power Strip_B77D",host="{{HOST_IP}}",id="{{ID}}"} 0.476
...

Prometheus

之後就可以把 Exporter 包一包,服務起來後把 prometheus config 設定好,我是設定 15s 爬一次 {EXPORTER_IP}:9100/metrics

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
global:
  scrape_interval: 15s
  external_labels:
    monitor: 'monitor'

scrape_configs:
  # ...
  - job_name: 'hs300_exporter'
    static_configs:
      - targets:
        - {YOUR_IP}:9100

理論上有起來在 Targets 那邊就會是 UP 了

記得 Prometheus 要設定 retention time: storage.tsdb.retention.time

Grafana Dashboard

在 Grafana 把 Prometheus 的 Data source 接起來後,就可以去 Dashboard 那邊新增一個 Visualization,這邊示範一個,把 Metric 填上 kasa_power 按一下 Run Queries,右上角選擇 Time Series,就可以做出瓦數走勢圖了:

之後再依個人喜好可以自己客製化要什麼圖,以下分享我的方案:

首先是整個延長線的狀態,就是上面腳本這一系列透過 dev 爬到的資料

1
2
3
4
5
current = dev.emeter_realtime.current if dev.emeter_realtime.current else 0
voltage = dev.emeter_realtime.voltage if dev.emeter_realtime.voltage else 0
power = dev.emeter_realtime.power if dev.emeter_realtime.power else 0
total = dev.emeter_realtime.total if dev.emeter_realtime.total else 0
...

再來是每個插座各自的資料,左邊是開關、今天還有本月的用電狀態,右邊則是整個記錄到的資料

這邊最常看的「我這個月花了多少電」這類的查詢就可以直接用右上角選擇要查詢的時間區間,在 Energy (由 kasa_total 提供資料) 右方的 difference 就可以直接看到這段時間用了幾度電了。

結論

藉著這個機會順便學了一下基本的 Prometheus 跟 Grafana,目前跑了快一個月還沒遇到什麼問題,使用體驗個人是覺得比 APP 好上不少,似乎領悟到了 IOT 的正確玩法可能就是買之前先搜一下有沒有人已經把它給逆向了

Google Ads 廣告欄位
🚧 請讀者考慮關閉廣告封鎖器 🚧
或將本站設定為白名單
以支持本站之運營