From a1e03826c0a904b93d60bd8d44f14cc6ba087314 Mon Sep 17 00:00:00 2001
From: jwansek <eddie.atten.ea29@gmail.com>
Date: Sun, 24 Dec 2023 14:46:04 +0000
Subject: Added SNMP client for monitoring omada switches

---
 app.py                       |  17 ++++++++
 config.env.example           |  10 +++--
 docker-compose.yml           |  15 ++++++-
 mikrotik.py                  |  58 +++++++++++++++++++++++++
 switch-snmp/.dockerignore    |   1 +
 switch-snmp/Dockerfile       |  16 +++++++
 switch-snmp/entrypoint.sh    |   4 ++
 switch-snmp/port-names.conf  |   4 ++
 switch-snmp/requirements.txt |   3 ++
 switch-snmp/snmp-omada.py    | 100 +++++++++++++++++++++++++++++++++++++++++++
 10 files changed, 223 insertions(+), 5 deletions(-)
 create mode 100644 app.py
 create mode 100644 mikrotik.py
 create mode 100644 switch-snmp/.dockerignore
 create mode 100644 switch-snmp/Dockerfile
 create mode 100644 switch-snmp/entrypoint.sh
 create mode 100644 switch-snmp/port-names.conf
 create mode 100644 switch-snmp/requirements.txt
 create mode 100644 switch-snmp/snmp-omada.py

diff --git a/app.py b/app.py
new file mode 100644
index 0000000..d67cbac
--- /dev/null
+++ b/app.py
@@ -0,0 +1,17 @@
+import database
+import devices
+import flask
+import os
+
+app = flask.Flask(__name__)
+
+@app.route("/")
+def route_index():
+    with database.PowerDatabase(host = devices.HOST) as db:
+        return flask.render_template(
+            "index.html.j2",
+            tasmota_devices = db.get_tasmota_devices()
+        )
+
+if __name__ == "__main__":
+    app.run(host = "0.0.0.0", port = int(os.environ["APP_PORT"]), debug = True)
\ No newline at end of file
diff --git a/config.env.example b/config.env.example
index 9010652..4878ec2 100644
--- a/config.env.example
+++ b/config.env.example
@@ -1,10 +1,12 @@
 MQTT_USER=eden
-MQTT_PASSWD=****************
+MQTT_PASSWD=*******************
+
+OMADA_SWITCHES=192.168.69.26
 
 DOCKER_INFLUXDB_INIT_MODE=setup
 DOCKER_INFLUXDB_INIT_USERNAME=eden
-DOCKER_INFLUXDB_INIT_PASSWORD=****************
+DOCKER_INFLUXDB_INIT_PASSWORD=********************
 DOCKER_INFLUXDB_INIT_ORG=poweredagay
 DOCKER_INFLUXDB_INIT_BUCKET=edenbucket
-DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=************************
-DOCKER_INFLUXDB_DB=power
+DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=****************
+DOCKER_INFLUXDB_DB=power
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index bede2ad..1c4da89 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -27,7 +27,7 @@ services:
         restart: unless-stopped
 
     mqtt_client:
-        image: jwansek/mqtt-client
+        image: reg.reaweb.uk/mqtt-client
         build:
             context: ./mqtt-client
             dockerfile: Dockerfile
@@ -37,6 +37,19 @@ services:
             - influxdb
         restart: unless-stopped
 
+    snmp_client:
+        image: reg.reaweb.uk/snmp-client
+        build:
+            context: ./switch-snmp
+            dockerfile: Dockerfile
+        env_file:
+            - ./config.env
+        depends_on:
+            - influxdb
+        restart: unless-stopped
+        volumes:
+            - ./switch-snmp/port-names.conf:/app/port-names.conf
+
 volumes:
     mosquitto-data:
     mosquitto-logs:
diff --git a/mikrotik.py b/mikrotik.py
new file mode 100644
index 0000000..90dc438
--- /dev/null
+++ b/mikrotik.py
@@ -0,0 +1,58 @@
+from dataclasses import dataclass, field
+import serial
+import devices
+import time
+import os
+import re
+
+@dataclass
+class MikroTikSerialDevice:
+    device: str = os.environ["MIKROTIK_DEVICE"]
+    user: str = os.environ["MIKROTIK_USER"]
+    passwd: str = os.environ["MIKROTIK_PASS"]
+
+    def __post_init__(self):
+        self.interfaces = {}
+        for i in os.environ["MIKROTIK_INTERFACES"].split(","):
+            self.interfaces.__setitem__(*i.split(":"))
+
+    def _get_poe_info(self, port):
+        self.ser = serial.Serial(self.device, 115200, timeout=0.25)
+
+        self._push_serial("")
+        self._push_serial(self.user)
+        self._push_serial(self.passwd)
+        self._push_serial("/interface/ethernet/poe/monitor %s" % port)
+        time.sleep(0.05)
+        self.ser.write(bytes("q", 'ISO-8859-1'))
+        out = self._read()
+        self.ser.close()
+
+        return self._post_out(out)
+
+    def _push_serial(self, text):
+        time.sleep(0.05)
+        self.ser.write(bytes(text + "\r\n", 'ISO-8859-1'))
+        time.sleep(0.05)
+
+    def _read(self):
+        return self.ser.readlines()
+
+    def _post_out(self, out):
+        d = {}
+        for line in out:
+            line = line.decode().strip()
+            if line.startswith("poe"):
+                d.__setitem__(*line.split(": "))
+
+        return d
+
+    def get_poes(self):
+
+        print(self.interfaces)
+
+
+if __name__ == "__main__":
+    mikrotik = MikroTikSerialDevice()
+    print(mikrotik.get_poes())
+
diff --git a/switch-snmp/.dockerignore b/switch-snmp/.dockerignore
new file mode 100644
index 0000000..ea6cd72
--- /dev/null
+++ b/switch-snmp/.dockerignore
@@ -0,0 +1 @@
+port-names.conf
\ No newline at end of file
diff --git a/switch-snmp/Dockerfile b/switch-snmp/Dockerfile
new file mode 100644
index 0000000..66857fa
--- /dev/null
+++ b/switch-snmp/Dockerfile
@@ -0,0 +1,16 @@
+FROM ubuntu:20.04
+ENV TZ=Europe/London
+RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
+RUN apt-get update -y
+RUN apt-get install -y python3-pip iputils-ping cron wget snmp snmp-mibs-downloader unzip
+COPY . /app
+WORKDIR /app
+RUN wget "https://static.tp-link.com/upload/software/2022/202209/20220915/privateMibs(20220831).zip"
+RUN mkdir -p /usr/share/snmp/mibs
+RUN unzip -j "privateMibs(20220831).zip" -d  /usr/share/snmp/mibs
+RUN pip3 install -r requirements.txt
+RUN rm "privateMibs(20220831).zip"
+
+RUN echo "*/1 * * * * root python3 /app/snmp-omada.py > /proc/1/fd/1 2>/proc/1/fd/2" > /etc/crontab
+ENTRYPOINT ["bash"]
+CMD ["entrypoint.sh"]
\ No newline at end of file
diff --git a/switch-snmp/entrypoint.sh b/switch-snmp/entrypoint.sh
new file mode 100644
index 0000000..610bf84
--- /dev/null
+++ b/switch-snmp/entrypoint.sh
@@ -0,0 +1,4 @@
+# https://stackoverflow.com/questions/27771781/how-can-i-access-docker-set-environment-variables-from-a-cron-job/35088810#35088810
+printenv | grep -v "no_proxy" >> /etc/environment
+
+cron -f
\ No newline at end of file
diff --git a/switch-snmp/port-names.conf b/switch-snmp/port-names.conf
new file mode 100644
index 0000000..e3f61da
--- /dev/null
+++ b/switch-snmp/port-names.conf
@@ -0,0 +1,4 @@
+1 = Routerbox
+5 = PiKVM
+6 = EAP225 WiFi
+8 = Git Raspberry Pi
\ No newline at end of file
diff --git a/switch-snmp/requirements.txt b/switch-snmp/requirements.txt
new file mode 100644
index 0000000..e7e63fd
--- /dev/null
+++ b/switch-snmp/requirements.txt
@@ -0,0 +1,3 @@
+python-dotenv
+influxdb-client
+pandas
\ No newline at end of file
diff --git a/switch-snmp/snmp-omada.py b/switch-snmp/snmp-omada.py
new file mode 100644
index 0000000..aaa340e
--- /dev/null
+++ b/switch-snmp/snmp-omada.py
@@ -0,0 +1,100 @@
+# wget https://static.tp-link.com/upload/software/2022/202209/20220915/privateMibs(20220831).zip
+# cp -v *.mib /home/eden/.snmp/mibs
+# sudo apt install snmp
+# sudo apt-get install snmp-mibs-downloader
+
+import subprocess
+from dataclasses import dataclass
+import dotenv
+import os
+import pandas
+
+from influxdb_client import InfluxDBClient, Point, WritePrecision
+from influxdb_client.client.write_api import SYNCHRONOUS
+
+DIVIDE_BY_10_ENDPOINTS = ["tpPoePower", "tpPoeVoltage"]
+
+PORT_NAMES = dotenv.dotenv_values(os.path.join(os.path.dirname(__file__), "port-names.conf"))
+PORT_NAMES = {int(k): v for k, v in PORT_NAMES.items()}
+
+@dataclass
+class SNMPReading:
+    endpoint: str
+    port: int
+    reading: float
+
+    @classmethod
+    def from_string(cls, str_):
+        s = str_.split()
+        if len(s) != 4:
+            raise Exception("Couldn't parse")
+        endpoint_and_port, _, type_, reading = s
+        endpoint, port = endpoint_and_port.split(".")
+
+        if reading.isdigit():
+            reading = int(reading)
+        if endpoint in DIVIDE_BY_10_ENDPOINTS:
+            reading = reading / 10
+
+        return cls(endpoint, int(port), reading)
+
+def get_alternate_name(port):
+    try:
+        return PORT_NAMES[port]
+    except KeyError:
+        return port
+
+def snmp_walk(host):
+    proc = subprocess.Popen(
+        ["snmpwalk", "-Os", "-c", "tplink", "-v", "2c", "-m", "TPLINK-POWER-OVER-ETHERNET-MIB", host, "tplinkPowerOverEthernetMIB"],
+        stdout = subprocess.PIPE
+    )
+    out = []
+    while True:
+        line = proc.stdout.readline()
+        if not line:
+            break
+        try:
+            out.append(SNMPReading.from_string(line.rstrip().decode()))
+        except Exception:
+            pass
+
+    return out
+
+def readings_to_points(readings, switch_host):
+    points = []
+    df = pandas.DataFrame(readings)
+    df["port_name"] = df["port"].apply(get_alternate_name)
+    for p, group_df in df.groupby(["port", "port_name"]):
+        port, port_name = p
+        fields = dict(zip(group_df['endpoint'], group_df['reading']))
+
+        points.append({"measurement": "switch_status", "tags": {"port": port, "port_name": port_name, "switch_host": switch_host}, "fields": fields})
+    
+    return points
+
+if __name__ == "__main__":
+    env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "config.env")
+    if os.path.exists(env_path):
+        import dotenv
+        dotenv.load_dotenv(dotenv_path = env_path)
+        INFLUXDB_HOST = "dns.athome"
+    else:
+        INFLUXDB_HOST = "influxdb"
+
+    influxc = InfluxDBClient(
+        url = "http://%s:8086" % INFLUXDB_HOST,
+        token = os.environ["DOCKER_INFLUXDB_INIT_ADMIN_TOKEN"],
+        org = os.environ["DOCKER_INFLUXDB_INIT_ORG"] 
+    )
+    influxc.ping()
+
+    for switch_host in os.environ["OMADA_SWITCHES"].split(","):
+        points = readings_to_points(snmp_walk(switch_host), switch_host)
+        write_api = influxc.write_api(write_options = SYNCHRONOUS)
+        write_api.write(
+            os.environ["DOCKER_INFLUXDB_INIT_BUCKET"],
+            os.environ["DOCKER_INFLUXDB_INIT_ORG"],
+            points,
+            write_precision = WritePrecision.S
+        )
\ No newline at end of file
-- 
cgit v1.2.3