對於一個有嚴重資訊恐慌症的人來說,好像也沒什麼需要說的,就是手邊能有多少資訊就該有多少資訊(?
最近把爬蟲相關的command_line sensor搬遷到MQTT sensor上,之前的「以Debian系統資訊為例實作Home Assistant MQTT sensor」一文中有提到理由。很早之前我就把Unifi的設備資訊做好在我的Home Assistant上,當時還沒有任何一個整合可以百分之百符合我的需求,所以最後我是透過Unifi控制器提供的API自己刻一個出來用。
搬遷到MQTT sensor後,整個爬蟲的機制是
- 由Python以固定頻率對Unifi控制器送出請求,得到請求的結果之後做改寫,改寫的部分包含只留下需要的資訊以及輸出成JSON格式。固定頻率送出請求是由cronjob來完成。
- 透過MQTT上傳到MQTT伺服器由Home Assistant MQTT sensor做接收。
假設固定頻率為三分鐘執行一次,cronjob可以這樣寫。
*/3 * * * * cd /home/tzungshiun/Python3/Unifi/ && /usr/bin/python3 /home/tzungshiun/Python3/Unifi/unifi.py
Python的部分主要由以下幾個套件完成
- requests負責送出POST請求
- json負責處理JSON格式相關的東西
- paho.mqtt.client負責MQTT伺服器連線和訊息的發佈
期望的結果是每一個無線存取點的設備資訊會被整理成為一個topic發佈,而控制器相關的資訊則整理成二條做發佈。
在連上Unifi控制器抓資料之前,要先做登入的動作,登入也是透過送出一個POST請求來達成。
import requests
import logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
unifi_controller_ip = "192.168.xx.xx"
port = 8443
username = ""
password = ""
def get_session():
session = requests.Session()
login_url = f"https://{unifi_controller_ip}:{port}/api/login"
login_data = {"username": username, "password": password, "strict": True}
response = session.post(login_url, json=login_data, verify=False)
if response.status_code == 200:
return session
else:
logging.error("Login failed")
return None
再來是將發佈MQTT訊息寫成一個函數,訊息發佈後會延遲三秒再到下一個動作,因為有發現到訊息在頻繁地發佈下會有漏掉的可能,加上延遲之後有改善很多。
import paho.mqtt.client as mqtt
import json
import time
mqtt_broker = "192.168.yy.yy"
mqtt_port = 1883
mqtt_username = ""
mqtt_password = ""
def publish_data(client, topic, payload):
client.publish(topic, json.dumps(payload), retain=True)
time.sleep(3)
最後是爬蟲的部分,以及將爬到的資料做篩選、改寫成JSON並經由MQTT發佈。因為所有需要的資訊散落在三個不同的API網址中,所以這裡分成三個主題去處理
def main():
session = get_session() # 先取得連線
if session: # 若連線正常
client = mqtt.Client()
client.username_pw_set(mqtt_username, mqtt_password)
client.connect(mqtt_broker, mqtt_port) # 連上MQTT伺服器
# device_stats
device_stats_url = f"https://{unifi_controller_ip}:{port}/api/s/default/stat/device" # 從這個API取得設備資訊
device_stats_response = session.get(device_stats_url, verify=False)
device_stats_json = device_stats_response.json()
for item in device_stats_json.get("data", []): # 使用for迴圈一個個檢查JSON裡面的項目
name = item.get("name", "") # 無線存取點的設備名稱
router_port = ""
for lldp_item in item.get("lldp_table", []):
if "MikroTik" in lldp_item.get("chassis_descr", ""):
router_port = lldp_item.get("port_id", "")
break # 找到符合條件的項目後退出循環
payload = { # 要留下來的資訊
"name": item.get("name", ""),
"ip": item.get("ip", ""),
"mac": item.get("mac", ""),
"version": item.get("version", ""),
"adopt_state": item.get("state", ""),
"upgradable": item.get("upgradable", ""),
"cpu": item.get("system-stats", {}).get("cpu", ""),
"mem": item.get("system-stats", {}).get("mem", ""),
"load_1m": item.get("sys_stats", {}).get("loadavg_1", ""),
"load_5m": item.get("sys_stats", {}).get("loadavg_5", ""),
"load_15m": item.get("sys_stats", {}).get("loadavg_15", ""),
"uplink": item.get("uplink", {}).get("speed", ""),
"router_port": router_port,
"uptime": item.get("uptime", ""),
"2G_Channel": item.get("radio_table_stats", [{}])[0].get("channel", ""),
"2G_Power": item.get("radio_table_stats", [{}])[0].get("tx_power", ""),
"2G_User": item.get("radio_table_stats", [{}])[0].get("user-num_sta", ""),
"2G_Satisfaction": item.get("radio_table_stats", [{}])[0].get("satisfaction", ""),
"5G_Channel": item.get("radio_table_stats", [{}])[1].get("channel", ""),
"5G_Power": item.get("radio_table_stats", [{}])[1].get("tx_power", ""),
"5G_User": item.get("radio_table_stats", [{}])[1].get("user-num_sta", ""),
"5G_Satisfaction": item.get("radio_table_stats", [{}])[1].get("satisfaction", "")
}
publish_data(client, f"unifi_ap/{name}", payload) # 將一個設備的資訊經由MQTT發佈
# controller_data
controller_data_url = f"https://{unifi_controller_ip}:{port}/api/s/default/stat/sysinfo" # 從這個API取得控制器資訊
controller_data_response = session.get(controller_data_url, verify=False)
controller_data_json = controller_data_response.json()
for item in controller_data_json.get("data", []):
payload = {
"update_available": item.get("update_available", ""),
"version": item.get("version", ""),
"uptime": item.get("uptime", "")
}
publish_data(client, "unifi_ap/controller_data", payload)
# controller_stats
controller_stats_url = f"https://{unifi_controller_ip}:{port}/status" # 從這個API取得控制器狀態
controller_stats_response = session.get(controller_stats_url, verify=False)
controller_stats_json = controller_stats_response.json()
payload = {"state": controller_stats_json.get("meta", {}).get("up", "")}
publish_data(client, "unifi_ap/controller_stats", payload)
client.disconnect() # 從MQTT伺服器上斷線
完整的Python程式碼為
import requests
import json
import paho.mqtt.client as mqtt
import time
import logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
unifi_controller_ip = "192.168.xx.xx"
port = 8443
username = ""
password = ""
mqtt_broker = "192.168.yy.yy"
mqtt_port = 1883
mqtt_username = ""
mqtt_password = ""
def get_session():
session = requests.Session()
login_url = f"https://{unifi_controller_ip}:{port}/api/login"
login_data = {"username": username, "password": password, "strict": True}
response = session.post(login_url, json=login_data, verify=False)
if response.status_code == 200:
return session
else:
logging.error("Login failed")
return None
def publish_data(client, topic, payload):
client.publish(topic, json.dumps(payload), retain=True)
time.sleep(3)
def main():
session = get_session()
if session:
client = mqtt.Client()
client.username_pw_set(mqtt_username, mqtt_password)
client.connect(mqtt_broker, mqtt_port)
# device_stats
device_stats_url = f"https://{unifi_controller_ip}:{port}/api/s/default/stat/device"
device_stats_response = session.get(device_stats_url, verify=False)
device_stats_json = device_stats_response.json()
for item in device_stats_json.get("data", []):
name = item.get("name", "")
router_port = ""
for lldp_item in item.get("lldp_table", []):
if "MikroTik" in lldp_item.get("chassis_descr", ""):
router_port = lldp_item.get("port_id", "")
break
payload = {
"name": item.get("name", ""),
"ip": item.get("ip", ""),
"mac": item.get("mac", ""),
"version": item.get("version", ""),
"adopt_state": item.get("state", ""),
"upgradable": item.get("upgradable", ""),
"cpu": item.get("system-stats", {}).get("cpu", ""),
"mem": item.get("system-stats", {}).get("mem", ""),
"load_1m": item.get("sys_stats", {}).get("loadavg_1", ""),
"load_5m": item.get("sys_stats", {}).get("loadavg_5", ""),
"load_15m": item.get("sys_stats", {}).get("loadavg_15", ""),
"uplink": item.get("uplink", {}).get("speed", ""),
"router_port": router_port,
"uptime": item.get("uptime", ""),
"2G_Channel": item.get("radio_table_stats", [{}])[0].get("channel", ""),
"2G_Power": item.get("radio_table_stats", [{}])[0].get("tx_power", ""),
"2G_User": item.get("radio_table_stats", [{}])[0].get("user-num_sta", ""),
"2G_Satisfaction": item.get("radio_table_stats", [{}])[0].get("satisfaction", ""),
"5G_Channel": item.get("radio_table_stats", [{}])[1].get("channel", ""),
"5G_Power": item.get("radio_table_stats", [{}])[1].get("tx_power", ""),
"5G_User": item.get("radio_table_stats", [{}])[1].get("user-num_sta", ""),
"5G_Satisfaction": item.get("radio_table_stats", [{}])[1].get("satisfaction", "")
}
publish_data(client, f"unifi_ap/{name}", payload)
# controller_data
controller_data_url = f"https://{unifi_controller_ip}:{port}/api/s/default/stat/sysinfo"
controller_data_response = session.get(controller_data_url, verify=False)
controller_data_json = controller_data_response.json()
for item in controller_data_json.get("data", []):
payload = {
"update_available": item.get("update_available", ""),
"version": item.get("version", ""),
"uptime": item.get("uptime", "")
}
publish_data(client, "unifi_ap/controller_data", payload)
# controller_stats
controller_stats_url = f"https://{unifi_controller_ip}:{port}/status"
controller_stats_response = session.get(controller_stats_url, verify=False)
controller_stats_json = controller_stats_response.json()
payload = {"state": controller_stats_json.get("meta", {}).get("up", "")}
publish_data(client, "unifi_ap/controller_stats", payload)
client.disconnect()
if __name__ == "__main__":
main()
Home Assistant的部分
在Home Assistant中,每一個無線存取點我只設定了八個MQTT sensor,其他篩選過的資訊以attribute的方式接入,因為這些資訊只需顯示在Lovelace card上即可,我並不需要它的歷史記錄。
sensors:
- name: Unifi_002_Name
state_topic: "unifi_ap/GARAGE(UAP-AC-PRO)"
value_template: "{{ value_json.name }}"
json_attributes_topic: "unifi_ap/GARAGE(UAP-AC-PRO)"
json_attributes_template: "{{ value_json | tojson }}"
- name: Unifi_002_CPU
state_topic: "unifi_ap/GARAGE(UAP-AC-PRO)"
value_template: "{{ value_json.cpu }}"
unit_of_measurement: "%"
- name: Unifi_002_Memory
state_topic: "unifi_ap/GARAGE(UAP-AC-PRO)"
value_template: "{{ value_json.mem }}"
unit_of_measurement: "%"
- name: Unifi_002_Adopted
state_topic: "unifi_ap/GARAGE(UAP-AC-PRO)"
value_template: "{{ value_json.adopt_state }}"
- name: Unifi_002_Upgradable
state_topic: "unifi_ap/GARAGE(UAP-AC-PRO)"
value_template: "{{ value_json.upgradable }}"
- name: Unifi_002_Load_1m
state_topic: "unifi_ap/GARAGE(UAP-AC-PRO)"
value_template: "{{ value_json.load_1m }}"
- name: Unifi_002_Load_5m
state_topic: "unifi_ap/GARAGE(UAP-AC-PRO)"
value_template: "{{ value_json.load_5m }}"
- name: Unifi_002_Load_15m
state_topic: "unifi_ap/GARAGE(UAP-AC-PRO)"
value_template: "{{ value_json.load_15m }}"
Lovelace card的部分,除了device_tracker是由Mikrotik提供的,照自己的方式更改就好。
- type: entities
entities:
- type: custom:button-card
entity: sensor.unifi_002_name
aspect_ratio: 2.2/1
show_entity_picture: true
show_name: false
entity_picture: /local/image/UAP-AC-PRO.jpg
styles:
card:
- font-size: 14px
- padding: 0% 3% 3% 3%
grid:
- grid-template-areas: >-
"id id id track" "i adopt uptime uptime" "i upgrade version
version" "i upgrade port port" "i upgrade ip ip" "i uplink mac
mac"
- grid-template-columns: 3fr 2fr 3fr 2fr
- grid-template-rows: 2fr 1fr 1fr 1fr 1fr 1fr
img_cell:
- justify-content: middle
icon:
- width: 100%
custom_fields:
id:
- font-weight: bold
- color: blue
- justify-self: start
- font-size: 18px
track:
- font-weight: bold
- color: red
- justify-self: end
- font-size: 18px
uptime:
- justify-self: end
version:
- justify-self: end
port:
- justify-self: end
ip:
- justify-self: end
mac:
- justify-self: end
custom_fields:
id: '[[[ return entity.state ]]]'
adopt: |
[[[
if (entity.attributes.adopt_state == '1') return `<ha-icon icon="mdi:link" style="width: 40%; height: 40%; color: Green;"></ha-icon>`;
else return `<ha-icon icon="mdi:link-off" style="width: 40%; height: 40%; color: Red;"></ha-icon>`;
]]]
upgrade: |
[[[
if (entity.attributes.upgradable == true) return `<ha-icon icon="mdi:database-sync" style="width: 40%; height: 40%; color: Red;"></ha-icon>`;
else return `<ha-icon icon="mdi:database-check" style="width: 40%; height: 40%; color: DarkGrey;"></ha-icon>`;
]]]
uptime: |
[[[
return '已運作 ' + (Math.round(entity.attributes.uptime / 86400 * 100) / 100) + ' 天';
]]]
version: '[[[ return `韌體版本 ${entity.attributes.version}`; ]]]'
port: '[[[ return `連接埠 ${entity.attributes.router_port}`; ]]]'
ip: '[[[ return `LAN ${entity.attributes.ip}`; ]]]'
mac: '[[[ return `MAC ${entity.attributes.mac.toUpperCase()}`; ]]]'
uplink: |
[[[
if (entity.attributes.uplink == 1000) return `<ha-icon icon="mdi:toggle-switch-outline" style="width: 35%; height: 35%; color: DarkGrey;"></ha-icon>`;
else return `<ha-icon icon="mdi:toggle-switch-off-outline" style="width: 35%; height: 35%; color: Red;"></ha-icon>`;
]]]
track: |
[[[
if (states['device_tracker.xx_xx_xx_xx_xx_xx'].state == 'home') return '線上';
else return '離線';
]]]
- type: custom:multiple-entity-row
entity: sensor.unifi_002_name
icon: mdi:wifi
name: 2.4GHz
show_state: false
tap_action: none
entities:
- attribute: 2G_Channel
name: 頻道
styles:
width: 30px
- attribute: 2G_User
name: 裝置
unit: false
styles:
width: 30px
- attribute: 2G_Power
name: 功率
unit: false
styles:
width: 30px
- attribute: 2G_Satisfaction
name: 品質
unit: ' '
styles:
width: 30px
- type: custom:multiple-entity-row
entity: sensor.unifi_002_name
icon: mdi:wifi
name: 5GHz
show_state: false
tap_action: none
entities:
- attribute: 5G_Channel
name: 頻道
styles:
width: 30px
- attribute: 5G_User
name: 裝置
unit: false
styles:
width: 30px
- attribute: 5G_Power
name: 功率
unit: false
styles:
width: 30px
- attribute: 5G_Satisfaction
name: 品質
unit: ' '
styles:
width: 30px
- type: custom:multiple-entity-row
entity: sensor.unifi_002_name
icon: mdi:devices
name: 服務
show_state: false
tap_action: none
entities:
- icon: mdi:refresh
tap_action:
action: call-service
service: shell_command.reboot_unifi_002
service_data: {}
confirmation:
text: 請確認要重新啟動設備
styles:
width: 30px
- icon: mdi:link
tap_action:
action: call-service
service: shell_command.adopt_unifi_002
service_data: {}
confirmation:
text: 請確認是否要接管設備
styles:
width: 30px
- type: section
label: 系統資源
- type: custom:vertical-stack-in-card
cards:
- type: custom:bar-card
height: 70%
width: 80%
entity: sensor.unifi_002_cpu
entity_row: true
name: CPU
severity:
- color: Red
from: 80
to: 100
- color: Orange
from: 60
to: 80
- color: NavajoWhite
from: 40
to: 60
- color: LightGreen
from: 0
to: 40
positions:
name: outside
indicator: inside
icon: 'off'
card_mod:
style: |-
bar-card-card {
margin: 10px 0px 5px 0px;}
bar-card-value {
margin: auto;
font-weight: bold;
font-size: 13px;}
- type: custom:bar-card
height: 70%
width: 80%
entity: sensor.unifi_002_memory
entity_row: true
name: Memory
severity:
- color: Red
from: 80
to: 100
- color: Orange
from: 60
to: 80
- color: NavajoWhite
from: 40
to: 60
- color: LightGreen
from: 0
to: 40
positions:
name: outside
indicator: inside
icon: 'off'
card_mod:
style: |-
bar-card-card {
margin: 5px 0px 10px 0px;}
bar-card-value {
margin: auto;
font-weight: bold;
font-size: 13px;}
- type: section
label: 負載狀態
- type: custom:vertical-stack-in-card
horizontal: true
cards:
- type: custom:mini-graph-card
entities:
- entity: sensor.unifi_002_load_1min
name: 1 Min
color: lightsalmon
font_size_header: 12
font_size: 50
hours_to_show: 48
points_per_hour: 6
line_width: 3
height: 175
show:
icon: false
- type: custom:mini-graph-card
entities:
- entity: sensor.unifi_002_load_5min
name: 5 Min
color: dodgerblue
font_size_header: 12
font_size: 50
hours_to_show: 48
points_per_hour: 6
line_width: 3
height: 175
show:
icon: false
- type: custom:mini-graph-card
entities:
- entity: sensor.unifi_002_load_15min
name: 15 Min
color: palevioletred
font_size_header: 12
font_size: 50
hours_to_show: 48
points_per_hour: 6
line_width: 3
height: 175
show:
icon: false