diff options
author | jwansek <eddie.atten.ea29@gmail.com> | 2023-10-15 21:07:46 +0100 |
---|---|---|
committer | jwansek <eddie.atten.ea29@gmail.com> | 2023-10-15 21:07:46 +0100 |
commit | 247a962c39e4ade2d9fb0b280d400d82fd8db56d (patch) | |
tree | 9b9c771a7e7e29fdf44165f0a24b493c60f7b732 | |
parent | bbeeebb51fc9eb84cb976cb49ab2935f332f94ed (diff) | |
download | power.eda.gay-247a962c39e4ade2d9fb0b280d400d82fd8db56d.tar.gz power.eda.gay-247a962c39e4ade2d9fb0b280d400d82fd8db56d.zip |
Added plug usage table to web UI, with HTTP requests
-rw-r--r-- | app.py | 13 | ||||
-rw-r--r-- | app_requirements.txt | 2 | ||||
-rw-r--r-- | cron_Dockerfile | 4 | ||||
-rw-r--r-- | cron_requirements.txt (renamed from requirements.txt) | 2 | ||||
-rw-r--r-- | database.py | 25 | ||||
-rw-r--r-- | devices.py | 6 | ||||
-rw-r--r-- | docker-compose.yml | 1 | ||||
-rw-r--r-- | mikrotik.py | 21 | ||||
-rw-r--r-- | power.env.example | 15 | ||||
-rw-r--r-- | static/scripts.js | 108 | ||||
-rw-r--r-- | static/style.css | 27 | ||||
-rw-r--r-- | templates/index.html.j2 | 29 |
12 files changed, 153 insertions, 100 deletions
@@ -1,4 +1,5 @@ import database +import mistune import mikrotik import devices import flask @@ -6,18 +7,22 @@ import os app = flask.Flask(__name__) switch = mikrotik.MikroTikSerialDevice() +markdown_renderer = mistune.create_markdown( + renderer = mistune.HTMLRenderer(), + plugins = ["strikethrough", "table", "url"] +) @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() + tasmota_devices = [[i[0], markdown_renderer(i[-1])] for i in db.get_tasmota_devices()] ) @app.route("/api/mikrotik_devices") def api_get_mikrotik_devices(): - return flask.jsonify(switch.interfaces) + return flask.jsonify({i[0]: markdown_renderer(i[1]) for i in switch.interfaces.items()}) @app.route("/api/mikrotik_interface/<interface>") def api_poll_mikrotik_interface(interface): @@ -31,6 +36,10 @@ def api_poll_mikrotik_interface(interface): ) except (IndexError, KeyError): return flask.abort(400) + +@app.route("/api/mikrotik_plug") +def api_get_mikrotik_plug(): + return flask.jsonify({"parent": os.environ["MIKROTIK_TASMOTA"]}) @app.route("/api/plugs") def api_poll_plugs(): diff --git a/app_requirements.txt b/app_requirements.txt new file mode 100644 index 0000000..df52348 --- /dev/null +++ b/app_requirements.txt @@ -0,0 +1,2 @@ +flask +mistune
\ No newline at end of file diff --git a/cron_Dockerfile b/cron_Dockerfile index f198662..ad6ea6a 100644 --- a/cron_Dockerfile +++ b/cron_Dockerfile @@ -8,9 +8,9 @@ RUN mkdir app COPY . /app
WORKDIR /app
RUN touch .docker
-RUN pip3 install -r requirements.txt
+RUN pip3 install -r cron_requirements.txt
RUN echo "*/1 * * * * root python3 /app/devices.py nothourly > /proc/1/fd/1 2>/proc/1/fd/2" > /etc/crontab
-RUN echo "@daily root python3 /app/devices.py daily > /proc/1/fd/1 2>/proc/1/fd/2" >> /etc/crontab
+RUN echo "@daily root python3 /app/devices.py daily > /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/requirements.txt b/cron_requirements.txt index c8fabfc..2c2bbc9 100644 --- a/requirements.txt +++ b/cron_requirements.txt @@ -3,4 +3,4 @@ aiohttp==3.8.3 pymysql python-dotenv pyserial -flask + diff --git a/database.py b/database.py index 60ad520..8b57d25 100644 --- a/database.py +++ b/database.py @@ -28,7 +28,7 @@ class PowerDatabase: with self.__connection.cursor() as cursor:
if "TASMOTA_DEVICES" in os.environ.keys():
- for host, username, password in self.get_tasmota_devices():
+ for host, username, password, description in self.get_tasmota_devices():
cursor.execute("""
INSERT INTO tasmota_devices (host, username, password)
VALUES (%s, %s, %s)
@@ -98,8 +98,9 @@ class PowerDatabase: def get_tasmota_devices(self):
o = []
- for d in os.environ["TASMOTA_DEVICES"].split(","):
- o.append(d.split(":"))
+ for d in os.environ["TASMOTA_DEVICES"].split(";"):
+ line = d.split(",")
+ o.append(line)
return o
def append_watt_readings(self, host, reading):
@@ -115,12 +116,20 @@ class PowerDatabase: plugs = [i[0] for i in self.get_tasmota_devices()]
with self.__connection.cursor() as cursor:
cursor.execute("SELECT host, MAX(datetime) FROM watt_readings WHERE host IN %s GROUP BY host;", (plugs, ))
- plugtimes = cursor.fetchall()
+ wattplugtimes = cursor.fetchall()
- readings = []
- for host, datetime in plugtimes:
+ cursor.execute("SELECT host, MAX(datetime) FROM kwh_readings WHERE host IN %s GROUP BY host;", (plugs, ))
+ kwhplugtimes = {i[0]: i[1] for i in cursor.fetchall()}
+
+ readings = {}
+ for host, datetime in wattplugtimes:
cursor.execute("SELECT host, datetime, reading FROM watt_readings WHERE host = %s AND datetime = %s;", (host, datetime))
- readings.append(cursor.fetchone())
+ o1 = cursor.fetchone()
+ readings[host] = {"watts": (o1[1], o1[2])}
+
+ cursor.execute("SELECT host, datetime, reading FROM kwh_readings WHERE host = %s AND datetime = %s;", (host, kwhplugtimes[host]))
+ o2 = cursor.fetchone()
+ readings[host]["kWh"] = (o2[1], o2[2])
return readings
def get_watt_chart(self):
@@ -159,4 +168,4 @@ if __name__ == "__main__": host = None
with PowerDatabase(host = host) as db:
- print(to_series(db.get_kwh_chart()))
+ print(db.get_last_plug_readings())
@@ -38,7 +38,9 @@ def poll_watt_all(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) with database.PowerDatabase(host = HOST) as db: - for host, username, password in db.get_tasmota_devices(): + devices = db.get_tasmota_devices() + print("There are devices: ", [i[0] for i in devices]) + for host, username, password, description in devices: while True: try: asyncio.run(poll_watt_for(db, host, username, password)) @@ -52,7 +54,7 @@ def poll_kwh_all(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) with database.PowerDatabase(host = HOST) as db: - for host, username, password in db.get_tasmota_devices(): + for host, username, password, description in db.get_tasmota_devices(): while True: try: asyncio.run(poll_yesterday_kwh_for(db, host, username, password)) diff --git a/docker-compose.yml b/docker-compose.yml index 6907a00..c531fcb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,3 +8,4 @@ services: image: jwansek/power
env_file:
- ./power.env
+ restart: always
diff --git a/mikrotik.py b/mikrotik.py index 4cd9231..b739132 100644 --- a/mikrotik.py +++ b/mikrotik.py @@ -26,13 +26,15 @@ class MikroTikSerialDevice: def __post_init__(self): self.interfaces = {} self.last_return = {} - for i in os.environ["MIKROTIK_INTERFACES"].split(","): - self.interfaces.__setitem__(*i.split(":")) + for i in os.environ["MIKROTIK_INTERFACES"].split(";"): + self.interfaces.__setitem__(*i.split(",")) self.is_being_polled = threading.Event() self.poe_cache = {interface: {} for interface in self.interfaces} def get_poe_info(self, interface): - print(self.poe_cache) + # fetch from cache so that multiple processes don't try to access serial at the same time + # this means that the same MikroTikSerialDevice object must be used for multiple threads + # if another thread is accessing the critical region, return from cache if self.is_being_polled.is_set(): fetched_cache = self.poe_cache[interface] fetched_cache["cached"] = True @@ -70,7 +72,13 @@ class MikroTikSerialDevice: if line.startswith("poe"): d.__setitem__(*line.split(": ")) - self.last_return = d + # also fetch from cache if it returned nothing + if d == {}: + fetched_cache = self.poe_cache[interface] + fetched_cache["cached"] = True + return fetched_cache + + self.last_return = d self.poe_cache[interface] = d d["cached"] = was_cached return d @@ -84,6 +92,7 @@ if __name__ == "__main__": dotenv.load_dotenv(dotenv_path = "power.env") mikrotik = MikroTikSerialDevice() - for interface in mikrotik.interfaces: - print(interface, mikrotik.get_poe_info(interface)) + for i in range(10): + for interface in mikrotik.interfaces: + print(interface, mikrotik.get_poe_info(interface)) diff --git a/power.env.example b/power.env.example index 36cc9f2..1b0b3f0 100644 --- a/power.env.example +++ b/power.env.example @@ -1,10 +1,13 @@ -MYSQL_ROOT_PASSWORD=************
+MYSQL_ROOT_PASSWORD=*******************
MYSQL_HOST=192.168.69.3
-TASMOTA_DEVICES=switch.plug:admin:=************,nas.plug:admin:=************,12vbrick.plug:admin:=************,backup.plug:admin:=************
+# strings cannot contain , ; or #
+TASMOTA_DEVICES=switch.plug,admin,*******************,[Mikrotik CRS112-8P-4S-IN](https://wiki.eda.gay/index.php/Switches);nas.plug,admin,*******************,[TrueNAS NAS](https://wiki.eda.gay/index.php/TrueNAS_NAS);12vbrick.plug,admin,*******************,KVM Switch & Backup External Hard Drive & etc;backup.plug,admin,*******************,Backup NAS
-MIKROTIK_DEVICE=COM6
+MIKROTIK_DEVICE=/dev/ttyUSB0
+MIKROTIK_BAUD=115200
MIKROTIK_USER=admin
-MIKROTIK_PASS==************
-MIKROTIK_INTERFACES=ether1:pfsense router,ether2:interface2,ether3:interface3,ether4:interface4,ether5:interface5,ether6:interface6,ether7:interface7,ether8:interface8
+MIKROTIK_PASS=*******************
+MIKROTIK_INTERFACES=ether1,[EAP225 Wi-Fi AP](https://wiki.eda.gay/index.php/Main_Page);ether2,[TL-RP108GE](https://wiki.eda.gay/index.php/Switches) & [pfsense router](https://wiki.eda.gay/index.php/Pfsense_router);ether3,[Mikrotik CSS610-8G-2S+IN](https://wiki.eda.gay/index.php/Switches);ether4,[PiKVM](https://wiki.eda.gay/index.php/Blikvm_PiKVM);ether5,interface5;ether6,[Intel Compute Stick](https://wiki.eda.gay/index.php/Intel_Compute_Stick);ether7,SSH/Git/PiHole Raspberry Pi;ether8,interface8
+MIKROTIK_TASMOTA=switch.plug
-APP_PORT = 5021
\ No newline at end of file +APP_PORT = 5021
diff --git a/static/scripts.js b/static/scripts.js index 390c2c3..f252fc5 100644 --- a/static/scripts.js +++ b/static/scripts.js @@ -1,73 +1,37 @@ $(document).ready(function() { - Highcharts.chart('longterm_chart', { - chart: { - type: 'area' - }, - - title: { - text: 'Estimated Worldwide Population Growth by Region' - }, - - subtitle: { - text: 'Source: Wikipedia.org' - }, - - xAxis: { - categories: ['1750', '1800', '1850', '1900', '1950', '1999', '2050'], - tickmarkPlacement: 'on', - title: { - enabled: false - } - - }, - - yAxis: { - title: { - text: 'Billions' - }, - - labels: { - formatter: function() { - return this.value / 1000; - - } - - } - - }, - - tooltip: { - split: true, - valueSuffix: ' millions' - }, - - plotOptions: { - area: { - stacking: 'normal', - lineColor: '#666666', - lineWidth: 1, - marker: { - lineWidth: 1, - lineColor: '#666666' - } - - }, - - series: [{ - name: 'Asia', - data: [502, 635, 809, 947, 1402, 3634, 5268] - }, { - name: 'Africa', - data: [106, 107, 111, 133, 221, 767, 1766] - }, { - name: 'Europe', - data: [163, 203, 276, 408, 547, 729, 628] - }, { - name: 'America', - data: [18, 31, 54, 156, 339, 818, 1201] - }, { - name: 'Oceania', - data: [2, 2, 2, 6, 13, 30, 46] - }] - }}); -})
\ No newline at end of file + fetch("/api/mikrotik_plug").then((resp) => { + resp.json().then((body) => { + const MIKROTIK_PARENT = body["parent"]; + }); + }); + + console.log(MIKROTIK_PARENT); + + get_main_table(); +}) + +function get_main_table() { + fetch("/api/plugs").then((resp) => { + resp.json().then((body) => { + let watts_sum = 0; + let kwh_sum = 0; + Object.keys(body).forEach((host, i) => { + watts = body[host]["watts"]; + kwh = body[host]["kWh"]; + document.getElementById(host + "_watts_now").innerHTML = watts[1]; + document.getElementById(host + "_watts_yesterday").innerHTML = kwh[1]; + watts_sum += watts[1]; + kwh_sum += kwh[1]; + + document.getElementById("watts_last_updated").innerHTML = "Current power usage last updated at " + watts[0]; + document.getElementById("kwh_last_updated").innerHTML = "Yesterday's power usage last updated at " + kwh[0]; + + console.log(host, watts[0], watts[1], kwh[1]) + }); + document.getElementById("sum_watts_now").innerHTML = watts_sum; + document.getElementById("sum_watts_yesterday").innerHTML = kwh_sum; + }); + }); + + setTimeout(get_main_table, 30000); +}
\ No newline at end of file diff --git a/static/style.css b/static/style.css index 2bc7b32..56627aa 100644 --- a/static/style.css +++ b/static/style.css @@ -70,7 +70,7 @@ footer { list-style-type: none; width: 45%; display: inline-flex; - /* background-color: pink; */ + background-color: pink; min-height: 350px; margin-bottom: 7px; overflow: hidden; @@ -87,6 +87,31 @@ footer { flex-direction: row-reverse; } +#power_table { + width: 90%; +} + +#header_row { + background-color: black; +} + +#header_row td { + color: #f1f3f3; + font-weight: bold; +} + +#power_table tr { + margin-bottom: 3px; +} + +#last_updated_ul { + font-size: small; +} + +#sum_row { + background-color: gainsboro; +} + @media screen and (max-width: 1200px) { #multicharts ul li { width: 100%; diff --git a/templates/index.html.j2 b/templates/index.html.j2 index 534b1ce..69fcd31 100644 --- a/templates/index.html.j2 +++ b/templates/index.html.j2 @@ -52,8 +52,37 @@ </li> </ul> </div> + + <table id="power_table"> + <tr id="header_row"> + <td>Plug local hostname</td> + <td>Plug description</td> + <td>Current power usage (W)</td> + <td>Power usage yesterday (kWh)</td> + </tr> + {% for host, description_md in tasmota_devices %} + <tr id="tr_{{ host }}"> + <td><a href="http://{{ host }}" target="_blank">{{ host }}</a></td> + <td>{{ description_md|safe }}</td> + <td id="{{ host }}_watts_now"></td> + <td id="{{ host }}_watts_yesterday"></td> + </tr> + {% endfor %} + <tr id="sum_row"> + <td></td> + <td></td> + <td id="sum_watts_now"></td> + <td id="sum_watts_yesterday"></td> + </tr> + </table> </div> + <ul id="last_updated_ul"> + <li id="watts_last_updated">Current power usage never updated</li> + <li id="kwh_last_updated">Yesterday's power usage never updated</li> + <li id="switch_last_updated">Switch power usage never updated</li> + </ul> + <footer> <p><a href="https://github.com/jwansek/power.eda.gay">Source code released under GPLv3</a></p> <p><a href="https://www.fsf.org/campaigns/freejs">Read the Free Software Foundations statements about JavaScript</a></p> |