將Unifi無線存取點的設備資訊經由MQTT放入Home Assistant

對於一個有嚴重資訊恐慌症的人來說,好像也沒什麼需要說的,就是手邊能有多少資訊就該有多少資訊(?

最近把爬蟲相關的command_line sensor搬遷到MQTT sensor上,之前的「以Debian系統資訊為例實作Home Assistant MQTT sensor」一文中有提到理由。很早之前我就把Unifi的設備資訊做好在我的Home Assistant上,當時還沒有任何一個整合可以百分之百符合我的需求,所以最後我是透過Unifi控制器提供的API自己刻一個出來用。

搬遷到MQTT sensor後,整個爬蟲的機制是

  1. 由Python以固定頻率對Unifi控制器送出請求,得到請求的結果之後做改寫,改寫的部分包含只留下需要的資訊以及輸出成JSON格式。固定頻率送出請求是由cronjob來完成。
  2. 透過MQTT上傳到MQTT伺服器由Home Assistant MQTT sensor做接收。

假設固定頻率為三分鐘執行一次,cronjob可以這樣寫。

*/3 * * * * cd /home/tzungshiun/Python3/Unifi/ &&  /usr/bin/python3 /home/tzungshiun/Python3/Unifi/unifi.py

Python的部分主要由以下幾個套件完成

  1. requests負責送出POST請求
  2. json負責處理JSON格式相關的東西
  3. 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伺服器上斷線
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中,每一個無線存取點我只設定了八個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

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

返回頂端