Merge remote-tracking branch 'refs/remotes/origin/master'
This commit is contained in:
commit
85ed9c1983
18 changed files with 1604 additions and 168 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -4,3 +4,5 @@ Cargo.lock
|
||||||
test_devices.conf
|
test_devices.conf
|
||||||
|
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
|
midea_ac_lan/
|
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
@ -3,5 +3,6 @@
|
||||||
"activityBar.background": "#4C0C61",
|
"activityBar.background": "#4C0C61",
|
||||||
"titleBar.activeBackground": "#6B1188",
|
"titleBar.activeBackground": "#6B1188",
|
||||||
"titleBar.activeForeground": "#FDFAFE"
|
"titleBar.activeForeground": "#FDFAFE"
|
||||||
}
|
},
|
||||||
|
"rust-analyzer.showUnlinkedFileNotification": false
|
||||||
}
|
}
|
12
Cargo.toml
12
Cargo.toml
|
@ -7,10 +7,14 @@ edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rusqlite = "0.29.0"
|
rusqlite = "0.29.0"
|
||||||
anyhow = { version = "1.0.71", features = ["backtrace"] }
|
anyhow = { version = "1.0.75", features = ["backtrace"] }
|
||||||
reqwest = "0.11.20"
|
reqwest = "0.11.22"
|
||||||
serde = { version="1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
futures = "0.3.28"
|
futures = "0.3.28"
|
||||||
tokio = { version="1.32.0", features=["macros", "rt-multi-thread"] }
|
tokio = { version = "1.33.0", features=["macros", "rt-multi-thread"] }
|
||||||
tibber = "0.5.0"
|
tibber = "0.5.0"
|
||||||
|
chrono = "0.4.31"
|
||||||
|
actix-web = "4.4.0"
|
||||||
|
actix-files = "0.6.2"
|
||||||
|
midea = { git = "https://gavania.de/hodasemi/Midea.git" }
|
||||||
|
|
13
build.sh
Normal file
13
build.sh
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
rustup default stable
|
||||||
|
rustup update
|
||||||
|
|
||||||
|
git pull
|
||||||
|
|
||||||
|
cargo update
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
mkdir -p server
|
||||||
|
|
||||||
|
cp devices.conf server/
|
||||||
|
cp target/release/home_server server/
|
||||||
|
cp -r resources server/
|
|
@ -1,6 +1,8 @@
|
||||||
{
|
{
|
||||||
"plugs": [
|
"plugs": [
|
||||||
"Dev1",
|
["Tasmota-Plug-1", false],
|
||||||
"Dev2"
|
["Tasmota-Plug-2", false],
|
||||||
|
["Tasmota-Plug-3", true],
|
||||||
|
["Tasmota-Plug-4", true]
|
||||||
]
|
]
|
||||||
}
|
}
|
94
meross.py
94
meross.py
|
@ -1,94 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import os
|
|
||||||
import datetime
|
|
||||||
import time
|
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
from meross_iot.controller.mixins.electricity import ElectricityMixin
|
|
||||||
from meross_iot.http_api import MerossHttpClient
|
|
||||||
from meross_iot.manager import MerossManager
|
|
||||||
|
|
||||||
EMAIL = os.environ.get('MEROSS_EMAIL') or "superschneider@t-online.de"
|
|
||||||
PASSWORD = os.environ.get('MEROSS_PASSWORD') or "hodasemi1"
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
# Setup the HTTP client API from user-password
|
|
||||||
http_api_client = await MerossHttpClient.async_from_user_password(
|
|
||||||
api_base_url='https://iotx-eu.meross.com',
|
|
||||||
email=EMAIL,
|
|
||||||
password=PASSWORD
|
|
||||||
)
|
|
||||||
|
|
||||||
# Setup and start the device manager
|
|
||||||
manager = MerossManager(http_client=http_api_client)
|
|
||||||
await manager.async_init()
|
|
||||||
|
|
||||||
# Retrieve all the devices that implement the electricity mixin
|
|
||||||
await manager.async_device_discovery()
|
|
||||||
devs = manager.find_devices(device_class=ElectricityMixin)
|
|
||||||
|
|
||||||
if len(devs) < 1:
|
|
||||||
print("No electricity-capable device found...")
|
|
||||||
else:
|
|
||||||
dev = devs[0]
|
|
||||||
|
|
||||||
# Update device status: this is needed only the very first time we play with this device (or if the
|
|
||||||
# connection goes down)
|
|
||||||
await dev.async_update()
|
|
||||||
|
|
||||||
con = connect_to_db()
|
|
||||||
|
|
||||||
while True:
|
|
||||||
# Read the electricity power/voltage/current
|
|
||||||
instant_consumption = await dev.async_get_instant_metrics()
|
|
||||||
|
|
||||||
insert_into_db(con, instant_consumption.power)
|
|
||||||
|
|
||||||
time.sleep(3.0)
|
|
||||||
|
|
||||||
# Close the manager and logout from http_api
|
|
||||||
manager.close()
|
|
||||||
await http_api_client.async_logout()
|
|
||||||
|
|
||||||
|
|
||||||
def connect_to_db():
|
|
||||||
con = sqlite3.connect("data.db")
|
|
||||||
|
|
||||||
cur = con.cursor()
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS data (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
time INTEGER NOT NULL,
|
|
||||||
watts REAL NOT NULL
|
|
||||||
)""")
|
|
||||||
|
|
||||||
con.commit()
|
|
||||||
|
|
||||||
return con
|
|
||||||
|
|
||||||
|
|
||||||
def insert_into_db(con, watts):
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
unix_time = time.mktime(now.timetuple()) * 1000
|
|
||||||
|
|
||||||
date = (int(unix_time), watts)
|
|
||||||
|
|
||||||
cur = con.cursor()
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO data (time, watts)
|
|
||||||
VALUES (?, ?)
|
|
||||||
""",
|
|
||||||
date)
|
|
||||||
|
|
||||||
con.commit()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
if os.name == 'nt':
|
|
||||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
loop.run_until_complete(main())
|
|
||||||
loop.stop()
|
|
|
@ -1,21 +0,0 @@
|
||||||
import sqlite3
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
con = sqlite3.connect("data.db")
|
|
||||||
cur = con.cursor()
|
|
||||||
|
|
||||||
res = cur.execute("SELECT time, watts FROM data")
|
|
||||||
|
|
||||||
x_values = []
|
|
||||||
y_values = []
|
|
||||||
|
|
||||||
for time, watts in res:
|
|
||||||
x_values.append(time)
|
|
||||||
y_values.append(watts)
|
|
||||||
|
|
||||||
plt.plot(x_values, y_values)
|
|
||||||
|
|
||||||
plt.ylim([0, 200])
|
|
||||||
|
|
||||||
plt.show()
|
|
6
renovate.json
Normal file
6
renovate.json
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:base"
|
||||||
|
]
|
||||||
|
}
|
3
resources/css/index.css
Normal file
3
resources/css/index.css
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
td>*:not(:last-child) {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
293
resources/js/main.js
Normal file
293
resources/js/main.js
Normal file
|
@ -0,0 +1,293 @@
|
||||||
|
window.onload = startup;
|
||||||
|
|
||||||
|
async function startup() {
|
||||||
|
const response = await fetch(
|
||||||
|
"/devices",
|
||||||
|
{
|
||||||
|
method: "GET"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let table = document.createElement('table');
|
||||||
|
table.className = "pure-table pure-table-bordered";
|
||||||
|
let json = JSON.parse(await response.json());
|
||||||
|
|
||||||
|
console.log(json);
|
||||||
|
|
||||||
|
for (const [group_name, devices] of Object.entries(json)) {
|
||||||
|
let row_group = document.createElement('tr');
|
||||||
|
let data_group = document.createElement('td');
|
||||||
|
let group_label = document.createElement('label');
|
||||||
|
group_label.innerText = group_name;
|
||||||
|
|
||||||
|
data_group.appendChild(group_label);
|
||||||
|
row_group.appendChild(data_group);
|
||||||
|
table.appendChild(row_group);
|
||||||
|
|
||||||
|
let row_header = document.createElement('tr');
|
||||||
|
let header_name_entry = document.createElement('td');
|
||||||
|
header_name_entry.innerText = "Name"
|
||||||
|
let header_led_entry = document.createElement('td');
|
||||||
|
header_led_entry.innerText = "LED"
|
||||||
|
let header_power_entry = document.createElement('td');
|
||||||
|
header_power_entry.innerText = "Power"
|
||||||
|
let header_power_draw_entry = document.createElement('td');
|
||||||
|
header_power_draw_entry.innerText = "Power Draw";
|
||||||
|
|
||||||
|
row_header.appendChild(header_name_entry);
|
||||||
|
row_header.appendChild(header_led_entry);
|
||||||
|
row_header.appendChild(header_power_entry);
|
||||||
|
row_header.appendChild(header_power_draw_entry);
|
||||||
|
table.appendChild(row_header);
|
||||||
|
|
||||||
|
for (let i = 0; i < devices.length; i++) {
|
||||||
|
let device_id = devices[i][0];
|
||||||
|
let device_descriptor;
|
||||||
|
|
||||||
|
if (devices[i][1] == null) {
|
||||||
|
device_descriptor = device_id
|
||||||
|
} else {
|
||||||
|
device_descriptor = devices[i][1];
|
||||||
|
}
|
||||||
|
|
||||||
|
let row_device = document.createElement('tr');
|
||||||
|
|
||||||
|
// create device name column
|
||||||
|
let device_name_entry = document.createElement('td');
|
||||||
|
let device_name = document.createElement('input');
|
||||||
|
device_name.className = "pure-u-2";
|
||||||
|
device_name.value = device_descriptor;
|
||||||
|
device_name.readOnly = true;
|
||||||
|
let device_name_edit = document.createElement('button');
|
||||||
|
device_name_edit.className = "pure-button";
|
||||||
|
device_name_edit.onclick = async () => {
|
||||||
|
if (device_name.readOnly) {
|
||||||
|
device_name.readOnly = false;
|
||||||
|
device_name.focus();
|
||||||
|
} else {
|
||||||
|
device_name.readOnly = true;
|
||||||
|
await change_device_name(device_id, device_name.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let button_icon = document.createElement('i');
|
||||||
|
button_icon.className = "fa fa-pencil-square-o";
|
||||||
|
|
||||||
|
device_name_entry.appendChild(device_name);
|
||||||
|
device_name_edit.appendChild(button_icon);
|
||||||
|
device_name_entry.appendChild(device_name_edit);
|
||||||
|
row_device.appendChild(device_name_entry);
|
||||||
|
|
||||||
|
// get plug status
|
||||||
|
const device_status_response = await fetch(
|
||||||
|
"/plug_state/" + device_id,
|
||||||
|
{
|
||||||
|
method: "GET"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let device_state = JSON.parse(await device_status_response.json());
|
||||||
|
|
||||||
|
// create device led state column
|
||||||
|
let device_led_state_entry = document.createElement('td');
|
||||||
|
let device_led_state = document.createElement('label');
|
||||||
|
device_led_state.innerText = device_state["led"];
|
||||||
|
device_led_state.className = "pure-u-2";
|
||||||
|
device_led_state.id = "led_" + device_id;
|
||||||
|
let device_led_on = document.createElement('button');
|
||||||
|
device_led_on.innerText = "On";
|
||||||
|
device_led_on.className = "pure-button";
|
||||||
|
device_led_on.onclick = async () => { await led_on(device_id) };
|
||||||
|
let device_led_off = document.createElement('button');
|
||||||
|
device_led_off.innerText = "Off"
|
||||||
|
device_led_off.className = "pure-button";
|
||||||
|
device_led_off.onclick = async () => { await led_off(device_id) };
|
||||||
|
|
||||||
|
device_led_state_entry.appendChild(device_led_state);
|
||||||
|
device_led_state_entry.appendChild(device_led_on);
|
||||||
|
device_led_state_entry.appendChild(device_led_off);
|
||||||
|
row_device.appendChild(device_led_state_entry);
|
||||||
|
|
||||||
|
// create device power state column
|
||||||
|
let device_power_state_entry = document.createElement('td');
|
||||||
|
let device_power_state = document.createElement('label');
|
||||||
|
device_power_state.innerText = device_state["power"];
|
||||||
|
device_power_state.className = "pure-u-2";
|
||||||
|
device_power_state.id = "power_" + device_id;
|
||||||
|
|
||||||
|
device_power_state_entry.appendChild(device_power_state);
|
||||||
|
|
||||||
|
if (devices[i][2] == true && device_state["power_draw"] < 15) {
|
||||||
|
let device_power_on = document.createElement('button');
|
||||||
|
device_power_on.innerText = "On"
|
||||||
|
device_power_on.className = "pure-button";
|
||||||
|
device_power_on.onclick = async () => { await power_on(device_id) };
|
||||||
|
let device_power_off = document.createElement('button');
|
||||||
|
device_power_off.innerText = "Off"
|
||||||
|
device_power_off.className = "pure-button";
|
||||||
|
device_power_off.onclick = async () => { await power_off(device_id) };
|
||||||
|
|
||||||
|
device_power_state_entry.appendChild(device_power_on);
|
||||||
|
device_power_state_entry.appendChild(device_power_off);
|
||||||
|
}
|
||||||
|
|
||||||
|
row_device.appendChild(device_power_state_entry);
|
||||||
|
|
||||||
|
// create device power draw column
|
||||||
|
let device_power_draw_entry = document.createElement('td');
|
||||||
|
let device_power_draw = document.createElement('label');
|
||||||
|
device_power_draw.className = "pure-u-2";
|
||||||
|
device_power_draw.innerText = device_state["power_draw"] + " W";
|
||||||
|
let device_power_draw_graph_button = document.createElement('button');
|
||||||
|
device_power_draw_graph_button.className = "pure-button";
|
||||||
|
device_power_draw_graph_button.onclick = async () => { await render_graph(device_id, device_descriptor) };
|
||||||
|
let device_power_draw_graph_button_icon = document.createElement('i');
|
||||||
|
device_power_draw_graph_button_icon.className = "fa fa-line-chart";
|
||||||
|
|
||||||
|
device_power_draw_graph_button.appendChild(device_power_draw_graph_button_icon);
|
||||||
|
device_power_draw_entry.appendChild(device_power_draw);
|
||||||
|
device_power_draw_entry.appendChild(device_power_draw_graph_button);
|
||||||
|
row_device.appendChild(device_power_draw_entry);
|
||||||
|
|
||||||
|
table.appendChild(row_device);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("main").appendChild(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function change_plug_state(plug, module, state) {
|
||||||
|
const response = await fetch(
|
||||||
|
"/plug/" + plug + "/" + module + "_" + state,
|
||||||
|
{
|
||||||
|
method: "POST"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const device_status_response = await fetch(
|
||||||
|
"/plug_state/" + plug,
|
||||||
|
{
|
||||||
|
method: "GET"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let device_state = JSON.parse(await device_status_response.json());
|
||||||
|
|
||||||
|
document.getElementById(module + "_" + plug).innerHTML = device_state[module];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function led_on(plug) {
|
||||||
|
await change_plug_state(plug, "led", "on")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function led_off(plug) {
|
||||||
|
await change_plug_state(plug, "led", "off")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function power_on(plug) {
|
||||||
|
await change_plug_state(plug, "power", "on")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function power_off(plug) {
|
||||||
|
// get plug status
|
||||||
|
const device_status_response = await fetch(
|
||||||
|
"/plug_state/" + device_id,
|
||||||
|
{
|
||||||
|
method: "GET"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let device_state = JSON.parse(await device_status_response.json());
|
||||||
|
|
||||||
|
if (device_state["power_draw"] < 15) {
|
||||||
|
await change_plug_state(plug, "power", "off")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function change_device_name(plug, name) {
|
||||||
|
const response = await fetch(
|
||||||
|
"/device_name/" + plug + "/" + name,
|
||||||
|
{
|
||||||
|
method: "POST"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(response.body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render_graph(plug, name) {
|
||||||
|
// remove old graph, if present
|
||||||
|
let old = document.getElementById("chart");
|
||||||
|
|
||||||
|
if (old !== null) {
|
||||||
|
old.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// get start date
|
||||||
|
let start_text = document.getElementById("start").value;
|
||||||
|
let start_date = parseInt(new Date(start_text).getTime() / 1000).toFixed(0);
|
||||||
|
|
||||||
|
// get end date1
|
||||||
|
let end_text = document.getElementById("end").value;
|
||||||
|
let end_date = parseInt(new Date(end_text).getTime() / 1000).toFixed(0);
|
||||||
|
|
||||||
|
// create new chart div
|
||||||
|
let chart = document.createElement('canvas');
|
||||||
|
chart.id = "chart";
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
"/plug_data/" + plug + "/" + start_date + "/" + end_date + "/" + "hourly",
|
||||||
|
{
|
||||||
|
method: "GET"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const j = await response.json();
|
||||||
|
const data = JSON.parse(j);
|
||||||
|
|
||||||
|
let y = [];
|
||||||
|
let x = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
let [time, watts] = data[i];
|
||||||
|
|
||||||
|
x.push(new Date(time * 1000));
|
||||||
|
y.push(watts);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chart_data = {
|
||||||
|
labels: x,
|
||||||
|
datasets: [{
|
||||||
|
label: name,
|
||||||
|
data: y,
|
||||||
|
fill: false,
|
||||||
|
borderColor: 'rgb(75, 192, 192)',
|
||||||
|
tension: 0.1
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
new Chart(chart, {
|
||||||
|
type: 'line',
|
||||||
|
data: chart_data,
|
||||||
|
options: {
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
type: 'time',
|
||||||
|
time: {
|
||||||
|
displayFormats: {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
locale: 'de-DE'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("graph").appendChild(chart);
|
||||||
|
}
|
33
resources/static/index.html
Normal file
33
resources/static/index.html
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<!-- Required meta tags -->
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
|
||||||
|
<title>Smart Homeserver</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fork-awesome@1.2.0/css/fork-awesome.min.css"
|
||||||
|
integrity="sha256-XoaMnoYC5TH6/+ihMEnospgm0J1PM/nioxbOUdnM8HY=" crossorigin="anonymous">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/purecss@3.0.0/build/pure-min.css"
|
||||||
|
integrity="sha384-X38yfunGUhNzHpBaEBsWLO+A0HDYOQi8ufWDkZ0k9e0eXz/tH3II7uKZ9msv++Ls" crossorigin="anonymous">
|
||||||
|
<link rel="stylesheet" href="https://cdn.korzh.com/metroui/v4/css/metro-all.min.css">
|
||||||
|
<link href="/css/index.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="main"></div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/js/main.js"></script>
|
||||||
|
<script src="https://cdn.korzh.com/metroui/v4/js/metro.min.js"></script>
|
||||||
|
|
||||||
|
<input id="start" type="text" data-role="calendarpicker">
|
||||||
|
<input id="end" type="text" data-role="calendarpicker">
|
||||||
|
<div id="graph">
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
285
src/data.rs
Normal file
285
src/data.rs
Normal file
|
@ -0,0 +1,285 @@
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct Date {
|
||||||
|
pub day: u8,
|
||||||
|
pub month: u8,
|
||||||
|
pub year: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Date {
|
||||||
|
pub fn new(day: u8, month: u8, year: u32) -> Self {
|
||||||
|
Self { day, month, year }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, PartialOrd)]
|
||||||
|
pub struct Day {
|
||||||
|
pub date: Date,
|
||||||
|
|
||||||
|
pub hours: [TimeFrame; 24],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Day {
|
||||||
|
pub fn new(date: Date, hours: [TimeFrame; 24]) -> Self {
|
||||||
|
Self { date, hours }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cost(&self) -> f32 {
|
||||||
|
let mut sum = 0.0;
|
||||||
|
|
||||||
|
for hour in self.hours.iter() {
|
||||||
|
sum += hour.cost();
|
||||||
|
}
|
||||||
|
|
||||||
|
sum
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn consumption(&self) -> f32 {
|
||||||
|
let mut sum = 0.0;
|
||||||
|
|
||||||
|
for hour in self.hours.iter() {
|
||||||
|
sum += hour.consumed;
|
||||||
|
}
|
||||||
|
|
||||||
|
sum / 1000.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, PartialOrd)]
|
||||||
|
pub struct TimeFrame {
|
||||||
|
pub start: u8,
|
||||||
|
pub end: u8,
|
||||||
|
|
||||||
|
// const in euro per kWh
|
||||||
|
pub cost: f32,
|
||||||
|
|
||||||
|
// average Wh in the time frame
|
||||||
|
pub consumed: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimeFrame {
|
||||||
|
pub fn new(start: u8, end: u8, cost: f32, consumed: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
cost,
|
||||||
|
consumed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cost(&self) -> f32 {
|
||||||
|
self.consumed / 1000.0 * self.cost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use crate::db::DataBase;
|
||||||
|
use crate::devices::Devices;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use chrono::prelude::NaiveDateTime;
|
||||||
|
use chrono::{Datelike, NaiveDate, NaiveTime, Timelike};
|
||||||
|
|
||||||
|
fn generate_price_list(low: f32, high: f32) -> [f32; 24] {
|
||||||
|
[
|
||||||
|
vec![low; 6],
|
||||||
|
vec![high; 4],
|
||||||
|
vec![low; 4],
|
||||||
|
vec![high; 6],
|
||||||
|
vec![low; 4],
|
||||||
|
]
|
||||||
|
.concat()
|
||||||
|
.try_into()
|
||||||
|
.unwrap_or_else(|_: Vec<f32>| unreachable!("create array from vec from an array"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_into_days(
|
||||||
|
input: Vec<(NaiveDateTime, f32)>,
|
||||||
|
) -> HashMap<NaiveDate, Vec<(NaiveTime, f32)>> {
|
||||||
|
let mut map: HashMap<NaiveDate, Vec<(NaiveTime, f32)>> = HashMap::new();
|
||||||
|
|
||||||
|
for (datetime, watts) in input {
|
||||||
|
let date = datetime.date();
|
||||||
|
let tuple = (datetime.time(), watts);
|
||||||
|
|
||||||
|
match map.get_mut(&date) {
|
||||||
|
Some(list) => list.push(tuple),
|
||||||
|
None => {
|
||||||
|
map.insert(date, vec![tuple]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_into_hours(input: Vec<(NaiveTime, f32)>) -> HashMap<u32, Vec<f32>> {
|
||||||
|
let mut map: HashMap<u32, Vec<f32>> = HashMap::new();
|
||||||
|
|
||||||
|
for (time, watts) in input {
|
||||||
|
match map.get_mut(&time.hour()) {
|
||||||
|
Some(list) => list.push(watts),
|
||||||
|
None => {
|
||||||
|
map.insert(time.hour(), vec![watts]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_cost_diff_overview(
|
||||||
|
input: Vec<(&str, Vec<(Date, f32, f32)>)>,
|
||||||
|
) -> HashMap<Date, (f32, HashMap<&str, f32>)> {
|
||||||
|
let mut map: HashMap<Date, (f32, HashMap<&str, f32>)> = HashMap::new();
|
||||||
|
|
||||||
|
for (provider, data) in input {
|
||||||
|
for (date, cost, consumption) in data {
|
||||||
|
match map.get_mut(&date) {
|
||||||
|
Some((cons, provider_data)) => {
|
||||||
|
assert_eq!(consumption, *cons);
|
||||||
|
|
||||||
|
match provider_data.get(provider) {
|
||||||
|
Some(_e) => panic!("double entry!?"),
|
||||||
|
None => {
|
||||||
|
provider_data.insert(provider, cost);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
map.insert(date, (consumption, HashMap::from([(provider, cost)])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn example() -> Result<()> {
|
||||||
|
let mut price_list = Vec::new();
|
||||||
|
|
||||||
|
// Drewag preise
|
||||||
|
let drewag_price = 0.366;
|
||||||
|
let drewag_prices = generate_price_list(drewag_price, drewag_price);
|
||||||
|
|
||||||
|
price_list.push(("drewag", drewag_prices));
|
||||||
|
|
||||||
|
// tibber monthly
|
||||||
|
let tibber_average_price = 0.25;
|
||||||
|
let tibber_average_prices = generate_price_list(tibber_average_price, tibber_average_price);
|
||||||
|
|
||||||
|
price_list.push(("tibber monthly", tibber_average_prices));
|
||||||
|
|
||||||
|
// tibber hourly prices
|
||||||
|
let price_low = 0.19;
|
||||||
|
let price_high = 0.366;
|
||||||
|
|
||||||
|
let tibber_hourly_prices = generate_price_list(price_low, price_high);
|
||||||
|
|
||||||
|
price_list.push(("tibber hourly", tibber_hourly_prices));
|
||||||
|
|
||||||
|
// tibber optimal prices
|
||||||
|
let tibber_hourly_prices_optimal = generate_price_list(price_low, price_low);
|
||||||
|
|
||||||
|
price_list.push(("tibber hourly (optimal)", tibber_hourly_prices_optimal));
|
||||||
|
|
||||||
|
let db = DataBase::new("home_server.db").await?;
|
||||||
|
let devices = Devices::read("devices.conf").await?;
|
||||||
|
|
||||||
|
for (plug, _) in devices.plugs {
|
||||||
|
println!("===== data for plug {plug} =====");
|
||||||
|
|
||||||
|
let days: HashMap<NaiveDate, HashMap<u32, Vec<f32>>> = split_into_days(
|
||||||
|
db.read(&plug)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(time, watts)| {
|
||||||
|
(
|
||||||
|
NaiveDateTime::from_timestamp_opt(time as i64 / 1000, 0).unwrap(),
|
||||||
|
watts,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(date, entries)| (date, split_into_hours(entries)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let data: Vec<(&str, Vec<Day>)> = price_list
|
||||||
|
.iter()
|
||||||
|
.map(|(provider, prices)| {
|
||||||
|
(
|
||||||
|
*provider,
|
||||||
|
days.iter()
|
||||||
|
.map(|(date, entries)| {
|
||||||
|
Day::new(
|
||||||
|
Date::new(
|
||||||
|
date.day() as u8,
|
||||||
|
date.month() as u8,
|
||||||
|
date.year() as u32,
|
||||||
|
),
|
||||||
|
prices
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, &price)| {
|
||||||
|
let consumption = match entries.get(&(index as u32)) {
|
||||||
|
Some(consumptions) => {
|
||||||
|
consumptions.iter().sum::<f32>()
|
||||||
|
/ consumptions.len() as f32
|
||||||
|
}
|
||||||
|
None => 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
TimeFrame::new(
|
||||||
|
index as u8,
|
||||||
|
index as u8 + 1,
|
||||||
|
price,
|
||||||
|
consumption,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<TimeFrame>>()
|
||||||
|
.try_into()
|
||||||
|
.unwrap_or_else(|_: Vec<TimeFrame>| {
|
||||||
|
unreachable!("create array from vec from an array")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let costs: Vec<(&str, Vec<(Date, f32, f32)>)> = data
|
||||||
|
.iter()
|
||||||
|
.map(|(provider, days)| {
|
||||||
|
(
|
||||||
|
*provider,
|
||||||
|
days.iter()
|
||||||
|
.map(|day| (day.date.clone(), day.cost(), day.consumption()))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let cost_diff = create_cost_diff_overview(costs);
|
||||||
|
|
||||||
|
println!("{cost_diff:#?}");
|
||||||
|
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
*/
|
268
src/db.rs
268
src/db.rs
|
@ -1,30 +1,65 @@
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use rusqlite::{Connection, ToSql};
|
use rusqlite::{Connection, OptionalExtension, ToSql};
|
||||||
|
|
||||||
|
use crate::devices::{Devices, DevicesWithName};
|
||||||
|
|
||||||
pub struct DataBase {
|
pub struct DataBase {
|
||||||
sql: Connection,
|
sql: Connection,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DataBase {
|
impl DataBase {
|
||||||
|
const VERSION_0_1_0: &'static str = "0.1.0";
|
||||||
|
|
||||||
pub async fn new(path: impl AsRef<Path>) -> Result<Self> {
|
pub async fn new(path: impl AsRef<Path>) -> Result<Self> {
|
||||||
let me = Self {
|
let me = Self {
|
||||||
sql: Connection::open(path)?,
|
sql: Connection::open(path)?,
|
||||||
};
|
};
|
||||||
|
|
||||||
me.generate_tables()?;
|
me.generate_tables()?;
|
||||||
|
me.init()?;
|
||||||
|
|
||||||
Ok(me)
|
Ok(me)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_tables(&self) -> Result<()> {
|
fn generate_tables(&self) -> Result<()> {
|
||||||
self.sql.execute(
|
self.sql.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS data (
|
"CREATE TABLE IF NOT EXISTS meta (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
device TEXT NOT NULL,
|
version INTEGER NOT NULL
|
||||||
time BIGINT NOT NULL,
|
)",
|
||||||
watts REAL NOT NULL
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
self.sql.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS devices(
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
device VARCHAR(60) NOT NULL,
|
||||||
|
type VARCHAR(30) NOT NULL,
|
||||||
|
control INTEGER NOT NULL,
|
||||||
|
name VARCHAR(80)
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
self.sql.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS data (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
time BIGINT NOT NULL,
|
||||||
|
watts REAL NOT NULL,
|
||||||
|
device_id INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(device_id) REFERENCES devices(id)
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
self.sql.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS credentials (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
key VARCHAR(60) NOT NULL,
|
||||||
|
device_id BIGINT NOT NULL,
|
||||||
|
cred VARCHAR(256) NOT NULL
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
|
@ -32,17 +67,200 @@ impl DataBase {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn version(&self) -> Result<String> {
|
||||||
|
Ok(self
|
||||||
|
.sql
|
||||||
|
.query_row("SELECT version FROM meta WHERE id=1", [], |row| row.get(0))?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(&self) -> Result<()> {
|
||||||
|
if self.version().is_err() {
|
||||||
|
self.sql.execute(
|
||||||
|
"INSERT INTO meta (version)
|
||||||
|
VALUES (?1)",
|
||||||
|
&[Self::VERSION_0_1_0],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register_devices(&self, devices: &Devices) -> Result<()> {
|
||||||
|
for (device, control) in devices.plugs.iter() {
|
||||||
|
self.sql.execute(
|
||||||
|
&format!(
|
||||||
|
"INSERT INTO devices (device, type, control)
|
||||||
|
SELECT \"{device}\", \"plug\", ?1
|
||||||
|
WHERE
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT device
|
||||||
|
FROM devices
|
||||||
|
WHERE device=\"{device}\"
|
||||||
|
)
|
||||||
|
"
|
||||||
|
),
|
||||||
|
&[control],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let ctl = if *control { 1 } else { 0 };
|
||||||
|
|
||||||
|
self.sql.execute(
|
||||||
|
&format!(
|
||||||
|
"
|
||||||
|
UPDATE devices
|
||||||
|
SET control=\"{ctl}\"
|
||||||
|
WHERE device=\"{device}\"
|
||||||
|
"
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn write(&self, device_name: &str, time: u64, watts: f32) -> Result<()> {
|
pub fn write(&self, device_name: &str, time: u64, watts: f32) -> Result<()> {
|
||||||
let params: &[&dyn ToSql] = &[&device_name, &time, &watts];
|
let params: &[&dyn ToSql] = &[&time, &watts];
|
||||||
|
|
||||||
self.sql.execute(
|
self.sql.execute(
|
||||||
"INSERT INTO data (device, time, watts)
|
&format!(
|
||||||
VALUES (?1, ?2, ?3)",
|
"INSERT INTO data (time, watts, device_id)
|
||||||
|
VALUES (?1, ?2, (SELECT id FROM devices WHERE device=\"{device_name}\") )"
|
||||||
|
),
|
||||||
params,
|
params,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn devices(&self) -> Result<DevicesWithName> {
|
||||||
|
let mut devices = DevicesWithName::default();
|
||||||
|
|
||||||
|
for row in self
|
||||||
|
.sql
|
||||||
|
.prepare(&format!(
|
||||||
|
"
|
||||||
|
SELECT device, type, name, control
|
||||||
|
FROM devices
|
||||||
|
"
|
||||||
|
))?
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
||||||
|
})?
|
||||||
|
{
|
||||||
|
let (device, dev_type, name, control): (String, String, Option<String>, i32) = row?;
|
||||||
|
|
||||||
|
match dev_type.as_str() {
|
||||||
|
"plug" => devices.plugs.push((device, name, control != 0)),
|
||||||
|
|
||||||
|
_ => panic!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(devices)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn change_device_name(&self, device: &str, description: &str) -> Result<()> {
|
||||||
|
self.sql.execute(
|
||||||
|
&format!(
|
||||||
|
"
|
||||||
|
UPDATE devices
|
||||||
|
SET name=\"{description}\"
|
||||||
|
WHERE device=\"{device}\"
|
||||||
|
"
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read(&self, device: &str) -> Result<Vec<(u64, f32)>> {
|
||||||
|
self._read(&format!(
|
||||||
|
"
|
||||||
|
SELECT data.time, data.watts
|
||||||
|
FROM data
|
||||||
|
INNER JOIN devices
|
||||||
|
ON data.device_id=devices.id
|
||||||
|
WHERE devices.device=\"{device}\"
|
||||||
|
"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_range(&self, device: &str, start: u64, end: u64) -> Result<Vec<(u64, f32)>> {
|
||||||
|
self._read(&format!(
|
||||||
|
"
|
||||||
|
SELECT data.time, data.watts
|
||||||
|
FROM data
|
||||||
|
INNER JOIN devices
|
||||||
|
ON data.device_id=devices.id
|
||||||
|
WHERE devices.device=\"{device}\"
|
||||||
|
AND data.time>={start}
|
||||||
|
AND data.time<{end}
|
||||||
|
"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn _read(&self, query: &str) -> Result<Vec<(u64, f32)>> {
|
||||||
|
self.sql
|
||||||
|
.prepare(query)?
|
||||||
|
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
|
||||||
|
.map(|row| {
|
||||||
|
let (time, watts) = row?;
|
||||||
|
|
||||||
|
Ok((time, watts))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_credential(&self, key: &str, device_id: u64, credential: &str) -> Result<()> {
|
||||||
|
if self
|
||||||
|
.sql
|
||||||
|
.prepare(&format!(
|
||||||
|
"
|
||||||
|
SELECT cred
|
||||||
|
FROM credentials
|
||||||
|
WHERE key=\"{key}\" AND device_id={device_id}
|
||||||
|
"
|
||||||
|
))?
|
||||||
|
.exists([])?
|
||||||
|
{
|
||||||
|
self.sql.execute(
|
||||||
|
&format!(
|
||||||
|
"
|
||||||
|
UPDATE credentials
|
||||||
|
SET cred=\"{credential}\"
|
||||||
|
WHERE key=\"{key}\" AND device_id={device_id}
|
||||||
|
"
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
self.sql.execute(
|
||||||
|
&format!(
|
||||||
|
"INSERT INTO crendetials (key, device_id, cred)
|
||||||
|
VALUES (\"{key}\", {device_id}, \"{credential}\")"
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_credential(&self, key: &str, device_id: u64) -> Result<Option<String>> {
|
||||||
|
Ok(self
|
||||||
|
.sql
|
||||||
|
.prepare(&format!(
|
||||||
|
"
|
||||||
|
SELECT cred
|
||||||
|
FROM credentials
|
||||||
|
WHERE key=\"{key}\" AND device_id={device_id}
|
||||||
|
"
|
||||||
|
))?
|
||||||
|
.query_row([], |row| Ok(row.get(0)?))
|
||||||
|
.optional()?)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
@ -51,22 +269,52 @@ mod test {
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::devices::Devices;
|
||||||
|
|
||||||
use super::DataBase;
|
use super::DataBase;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_connection() -> Result<()> {
|
async fn test_connection() -> Result<()> {
|
||||||
DataBase::new("connection_test.db").await?;
|
let db = DataBase::new("connection_test.db").await?;
|
||||||
|
assert_eq!(DataBase::VERSION_0_1_0, db.version()?);
|
||||||
|
|
||||||
fs::remove_file("connection_test.db")?;
|
fs::remove_file("connection_test.db")?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_startup() -> Result<()> {
|
||||||
|
let db = DataBase::new("startup_test.db").await?;
|
||||||
|
|
||||||
|
db.register_devices(&Devices {
|
||||||
|
plugs: vec![("test".to_string(), true)],
|
||||||
|
})?;
|
||||||
|
|
||||||
|
fs::remove_file("startup_test.db")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_write() -> Result<()> {
|
async fn test_write() -> Result<()> {
|
||||||
let db = DataBase::new("write_test.db").await?;
|
let db = DataBase::new("write_test.db").await?;
|
||||||
|
|
||||||
db.write("test", 0, 5.5)?;
|
let device_name = "test";
|
||||||
|
|
||||||
|
db.register_devices(&Devices {
|
||||||
|
plugs: vec![(device_name.to_string(), true)],
|
||||||
|
})?;
|
||||||
|
|
||||||
|
db.write(device_name, 0, 5.5)?;
|
||||||
|
|
||||||
|
let device_descriptor = "udo";
|
||||||
|
db.change_device_name(device_name, device_descriptor)?;
|
||||||
|
|
||||||
|
let devices = db.devices()?;
|
||||||
|
|
||||||
|
assert_eq!(devices.plugs[0].1.as_ref().unwrap(), device_descriptor);
|
||||||
|
assert_eq!(devices.plugs[0].0, device_name);
|
||||||
|
|
||||||
fs::remove_file("write_test.db")?;
|
fs::remove_file("write_test.db")?;
|
||||||
|
|
||||||
|
|
|
@ -2,11 +2,11 @@ use std::fs;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{from_str, to_string_pretty};
|
use serde_json::{from_str, to_string, to_string_pretty};
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, Debug)]
|
#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, Debug)]
|
||||||
pub struct Devices {
|
pub struct Devices {
|
||||||
pub plugs: Vec<String>,
|
pub plugs: Vec<(String, bool)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Devices {
|
impl Devices {
|
||||||
|
@ -22,6 +22,17 @@ impl Devices {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||||
|
pub struct DevicesWithName {
|
||||||
|
pub plugs: Vec<(String, Option<String>, bool)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DevicesWithName {
|
||||||
|
pub fn to_json(&self) -> Result<String> {
|
||||||
|
Ok(to_string(self)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use super::Devices;
|
use super::Devices;
|
||||||
|
@ -30,7 +41,7 @@ mod test {
|
||||||
#[test]
|
#[test]
|
||||||
fn create_conf() -> Result<()> {
|
fn create_conf() -> Result<()> {
|
||||||
let devices = Devices {
|
let devices = Devices {
|
||||||
plugs: vec!["Dev1".to_string(), "Dev2".to_string()],
|
plugs: vec![("Dev1".to_string(), true), ("Dev2".to_string(), false)],
|
||||||
};
|
};
|
||||||
|
|
||||||
devices.save("test_devices.conf")
|
devices.save("test_devices.conf")
|
||||||
|
|
110
src/main.rs
110
src/main.rs
|
@ -5,56 +5,126 @@ use std::{
|
||||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::db::DataBase;
|
use crate::{db::DataBase, midea_helper::MideaDiscovery, web_server::plug_data_range};
|
||||||
|
|
||||||
|
mod data;
|
||||||
mod db;
|
mod db;
|
||||||
mod devices;
|
mod devices;
|
||||||
|
mod midea_helper;
|
||||||
mod tasmota;
|
mod tasmota;
|
||||||
mod tibber;
|
mod tibber_handler;
|
||||||
|
mod web_server;
|
||||||
|
|
||||||
use ::tibber::TimeResolution::Daily;
|
use tibber::TimeResolution::Daily;
|
||||||
|
|
||||||
|
use actix_files::Files;
|
||||||
|
use actix_web::{web::Data, App, HttpServer};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use devices::Devices;
|
use devices::Devices;
|
||||||
use futures::{future::try_join_all, try_join};
|
use futures::{future::try_join_all, try_join, Future};
|
||||||
|
use midea_helper::MideaDishwasher;
|
||||||
use tasmota::Tasmota;
|
use tasmota::Tasmota;
|
||||||
use tibber::TibberHandler;
|
use tibber_handler::TibberHandler;
|
||||||
|
use web_server::*;
|
||||||
|
|
||||||
fn since_epoch() -> Result<u64> {
|
fn since_epoch() -> Result<u64> {
|
||||||
Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
|
Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_power_usage(
|
||||||
|
tasmota_plugs: Vec<Tasmota>,
|
||||||
|
db: Arc<Mutex<DataBase>>,
|
||||||
|
) -> impl Future<Output = Result<()>> {
|
||||||
|
async move {
|
||||||
|
loop {
|
||||||
|
try_join_all(tasmota_plugs.iter().map(|plug| async {
|
||||||
|
if let Ok(usage) = plug.read_power_usage().await {
|
||||||
|
db.lock()
|
||||||
|
.unwrap()
|
||||||
|
.write(plug.name(), since_epoch()?, usage)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok::<(), anyhow::Error>(())
|
||||||
|
}))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_secs(3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_web_server(
|
||||||
|
devices: Devices,
|
||||||
|
plugs: Vec<Tasmota>,
|
||||||
|
db: Arc<Mutex<DataBase>>,
|
||||||
|
dishwasher: Vec<Arc<MideaDishwasher>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
const IP: &str = "0.0.0.0";
|
||||||
|
const PORT: u16 = 8062;
|
||||||
|
|
||||||
|
println!("Starting server on http://{IP}:{PORT}");
|
||||||
|
|
||||||
|
HttpServer::new(move || {
|
||||||
|
App::new()
|
||||||
|
.app_data(Data::new(devices.clone()))
|
||||||
|
.app_data(Data::new(db.clone()))
|
||||||
|
.app_data(Data::new(plugs.clone()))
|
||||||
|
.app_data(Data::new(dishwasher.clone()))
|
||||||
|
.service(Files::new("/images", "resources/images/").show_files_listing())
|
||||||
|
.service(Files::new("/css", "resources/css").show_files_listing())
|
||||||
|
.service(Files::new("/js", "resources/js").show_files_listing())
|
||||||
|
.service(index)
|
||||||
|
.service(device_query)
|
||||||
|
.service(plug_state)
|
||||||
|
.service(change_plug_state)
|
||||||
|
.service(change_device_name)
|
||||||
|
.service(plug_data)
|
||||||
|
.service(plug_data_range)
|
||||||
|
})
|
||||||
|
.bind((IP, PORT))
|
||||||
|
.map_err(|err| anyhow::Error::msg(format!("failed binding to address: {err:#?}")))?
|
||||||
|
.run()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let db_future = DataBase::new("home_server.db");
|
let db_future = DataBase::new("home_server.db");
|
||||||
let devices_future = Devices::read("devices.conf");
|
let devices_future = Devices::read("devices.conf");
|
||||||
let tibber_future = TibberHandler::new(fs::read_to_string("tibber_token.txt")?);
|
let tibber_future = TibberHandler::new(fs::read_to_string("tibber_token.txt")?);
|
||||||
|
|
||||||
let (db, devices, tibber) = try_join!(db_future, devices_future, tibber_future)?;
|
let (db, devices, tibber, midea) = try_join!(
|
||||||
|
db_future,
|
||||||
|
devices_future,
|
||||||
|
tibber_future,
|
||||||
|
MideaDiscovery::discover()
|
||||||
|
)?;
|
||||||
|
|
||||||
let prices_today = tibber.prices_today().await?;
|
let prices_today = tibber.prices_today().await?;
|
||||||
let prices_tomorrow = tibber.prices_tomorrow().await?;
|
let prices_tomorrow = tibber.prices_tomorrow().await?;
|
||||||
let consumption = tibber.consumption(Daily, 1).await?;
|
let consumption = tibber.consumption(Daily, 1).await?;
|
||||||
|
|
||||||
|
db.register_devices(&devices)?;
|
||||||
let shared_db = Arc::new(Mutex::new(db));
|
let shared_db = Arc::new(Mutex::new(db));
|
||||||
|
|
||||||
let tasmota_plugs: Vec<Tasmota> = devices
|
let tasmota_plugs: Vec<Tasmota> = devices
|
||||||
.plugs
|
.plugs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|plug| Tasmota::new(plug))
|
.map(|(plug, _)| Tasmota::new(plug))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
loop {
|
let dishwasher = MideaDishwasher::create(midea, shared_db.clone())
|
||||||
try_join_all(tasmota_plugs.iter().map(|plug| async {
|
.await?
|
||||||
if let Ok(usage) = plug.read_power_usage().await {
|
.into_iter()
|
||||||
shared_db
|
.map(|d| Arc::new(d))
|
||||||
.lock()
|
.collect();
|
||||||
.unwrap()
|
|
||||||
.write(plug.name(), since_epoch()?, usage)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok::<(), anyhow::Error>(())
|
try_join!(
|
||||||
}))
|
read_power_usage(tasmota_plugs.clone(), shared_db.clone()),
|
||||||
.await?;
|
run_web_server(devices, tasmota_plugs, shared_db, dishwasher)
|
||||||
|
)?;
|
||||||
|
|
||||||
thread::sleep(Duration::from_secs(3));
|
Ok(())
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
95
src/midea_helper.rs
Normal file
95
src/midea_helper.rs
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use midea::*;
|
||||||
|
|
||||||
|
use crate::db::DataBase;
|
||||||
|
|
||||||
|
enum LoginInfo {
|
||||||
|
Cloud { mail: String, password: String },
|
||||||
|
Token { token: String, key: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoginInfo {
|
||||||
|
const MIDEA_KEY_EMAIL: &str = "midea_cloud_mail";
|
||||||
|
const MIDEA_KEY_PW: &str = "midea_cloud_pw";
|
||||||
|
const MIDEA_KEY_TOKEN: &str = "midea_token";
|
||||||
|
const MIDEA_KEY_KEY: &str = "midea_key";
|
||||||
|
|
||||||
|
fn new(db: &Arc<Mutex<DataBase>>, device_id: u64) -> Result<LoginInfo> {
|
||||||
|
let db_lock = db.lock().unwrap();
|
||||||
|
|
||||||
|
let token = db_lock.read_credential(Self::MIDEA_KEY_TOKEN, device_id)?;
|
||||||
|
let key = db_lock.read_credential(Self::MIDEA_KEY_KEY, device_id)?;
|
||||||
|
|
||||||
|
if token.is_none() || key.is_none() {
|
||||||
|
let mail = db_lock.read_credential(Self::MIDEA_KEY_EMAIL, device_id)?;
|
||||||
|
let pw = db_lock.read_credential(Self::MIDEA_KEY_PW, device_id)?;
|
||||||
|
|
||||||
|
if mail.is_none() || pw.is_none() {
|
||||||
|
bail!("missing credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(LoginInfo::Cloud {
|
||||||
|
mail: mail.unwrap(),
|
||||||
|
password: pw.unwrap(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Ok(LoginInfo::Token {
|
||||||
|
token: token.unwrap(),
|
||||||
|
key: key.unwrap(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MideaDiscovery {
|
||||||
|
infos: Vec<DeviceInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MideaDiscovery {
|
||||||
|
pub async fn discover() -> Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
infos: Startup::discover().await?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MideaDishwasher {
|
||||||
|
device_info: DeviceInfo,
|
||||||
|
device: Device,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MideaDishwasher {
|
||||||
|
pub async fn create(discovery: MideaDiscovery, db: Arc<Mutex<DataBase>>) -> Result<Vec<Self>> {
|
||||||
|
let mut v = Vec::new();
|
||||||
|
|
||||||
|
for device_info in discovery
|
||||||
|
.infos
|
||||||
|
.into_iter()
|
||||||
|
.filter(|device_info| device_info.device_type == 0xE1)
|
||||||
|
{
|
||||||
|
if let Ok(res) = LoginInfo::new(&db, device_info.id) {
|
||||||
|
let (token, key) = match res {
|
||||||
|
LoginInfo::Cloud { mail, password } => {
|
||||||
|
let mut cloud = Cloud::new(mail, password)?;
|
||||||
|
|
||||||
|
cloud.login().await?;
|
||||||
|
|
||||||
|
cloud.keys(device_info.id).await?
|
||||||
|
}
|
||||||
|
LoginInfo::Token { token, key } => (token, key),
|
||||||
|
};
|
||||||
|
|
||||||
|
let device = Device::connect(device_info.clone(), &token, &key).await?;
|
||||||
|
|
||||||
|
v.push(Self {
|
||||||
|
device_info,
|
||||||
|
device,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
}
|
162
src/tasmota.rs
162
src/tasmota.rs
|
@ -1,5 +1,8 @@
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::from_str;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
pub struct Tasmota {
|
pub struct Tasmota {
|
||||||
device: String,
|
device: String,
|
||||||
}
|
}
|
||||||
|
@ -15,11 +18,18 @@ impl Tasmota {
|
||||||
&self.device
|
&self.device
|
||||||
}
|
}
|
||||||
|
|
||||||
fn command(&self, command: &str) -> String {
|
fn command<'a>(&self, command: impl IntoIterator<Item = &'a str>) -> String {
|
||||||
format!("http://{}/cm?cmnd={}", self.device, command)
|
let mut str = String::new();
|
||||||
|
|
||||||
|
for s in command.into_iter() {
|
||||||
|
str += s;
|
||||||
|
str += "%20";
|
||||||
|
}
|
||||||
|
|
||||||
|
format!("http://{}/cm?cmnd={}", self.device, str)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn post(&self, command: &str) -> Result<String> {
|
async fn post<'a>(&self, command: impl IntoIterator<Item = &'a str>) -> Result<String> {
|
||||||
Ok(reqwest::Client::new()
|
Ok(reqwest::Client::new()
|
||||||
.post(&self.command(command))
|
.post(&self.command(command))
|
||||||
.send()
|
.send()
|
||||||
|
@ -28,7 +38,7 @@ impl Tasmota {
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get(&self, command: &str) -> Result<String> {
|
async fn get<'a>(&self, command: impl IntoIterator<Item = &'a str>) -> Result<String> {
|
||||||
Ok(reqwest::Client::new()
|
Ok(reqwest::Client::new()
|
||||||
.get(&self.command(command))
|
.get(&self.command(command))
|
||||||
.send()
|
.send()
|
||||||
|
@ -38,32 +48,162 @@ impl Tasmota {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn turn_on_led(&self) -> Result<()> {
|
pub async fn turn_on_led(&self) -> Result<()> {
|
||||||
self.post("LedPower=1").await?;
|
self.post(["LedPower", "1"]).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn turn_off_led(&self) -> Result<()> {
|
pub async fn turn_off_led(&self) -> Result<()> {
|
||||||
self.post("LedPower=2").await?;
|
self.post(["LedPower", "0"]).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn switch_on(&self) -> Result<()> {
|
pub async fn switch_on(&self) -> Result<()> {
|
||||||
self.post("Power0=1").await?;
|
self.post(["Power0", "1"]).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn switch_off(&self) -> Result<()> {
|
pub async fn switch_off(&self) -> Result<()> {
|
||||||
self.post("Power0=0").await?;
|
self.post(["Power0", "0"]).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn read_power_usage(&self) -> Result<f32> {
|
pub async fn power_state(&self) -> Result<bool> {
|
||||||
let res = self.get("Status=8").await?;
|
let res = self.get(["Power0"]).await?;
|
||||||
|
|
||||||
Ok(res.parse()?)
|
let state = PowerState::from_str(&res)?;
|
||||||
|
|
||||||
|
Ok(state.is_on())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read_power_usage(&self) -> Result<f32> {
|
||||||
|
let res = self.get(["Status", "8"]).await?;
|
||||||
|
|
||||||
|
let status = Status::from_str(&res)?;
|
||||||
|
|
||||||
|
Ok(status.StatusSNS.ENERGY.Power)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn led_state(&self) -> Result<bool> {
|
||||||
|
let res = self.get(["LedState"]).await?;
|
||||||
|
|
||||||
|
let state = LedState::from_str(&res)?;
|
||||||
|
|
||||||
|
Ok(state.LedState != 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use std::{thread, time::Duration};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_connection() -> Result<()> {
|
||||||
|
let dev = Tasmota::new("Tasmota-Plug-1");
|
||||||
|
|
||||||
|
let power = dev.read_power_usage().await?;
|
||||||
|
|
||||||
|
println!("{power}");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_toggle() -> Result<()> {
|
||||||
|
let dev = Tasmota::new("Tasmota-Plug-4");
|
||||||
|
|
||||||
|
dev.switch_off().await?;
|
||||||
|
assert_eq!(dev.power_state().await?, false);
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_secs(5));
|
||||||
|
|
||||||
|
dev.switch_on().await?;
|
||||||
|
assert_eq!(dev.power_state().await?, true);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_led() -> Result<()> {
|
||||||
|
let dev = Tasmota::new("Tasmota-Plug-4");
|
||||||
|
|
||||||
|
dev.turn_off_led().await?;
|
||||||
|
assert_eq!(dev.led_state().await?, false);
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_secs(5));
|
||||||
|
|
||||||
|
dev.turn_on_led().await?;
|
||||||
|
assert_eq!(dev.led_state().await?, true);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct Status {
|
||||||
|
pub StatusSNS: StatusSNS,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Status {
|
||||||
|
fn from_str(s: &str) -> Result<Self> {
|
||||||
|
Ok(from_str(s)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct StatusSNS {
|
||||||
|
pub Time: String,
|
||||||
|
pub ENERGY: Energy,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct Energy {
|
||||||
|
pub TotalStartTime: String,
|
||||||
|
pub Total: f32,
|
||||||
|
pub Yesterday: f32,
|
||||||
|
pub Today: f32,
|
||||||
|
pub Power: f32,
|
||||||
|
pub ApparentPower: u32,
|
||||||
|
pub ReactivePower: u32,
|
||||||
|
pub Factor: f32,
|
||||||
|
pub Voltage: u32,
|
||||||
|
pub Current: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct LedState {
|
||||||
|
LedState: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LedState {
|
||||||
|
fn from_str(s: &str) -> Result<Self> {
|
||||||
|
Ok(from_str(s)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct PowerState {
|
||||||
|
POWER: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PowerState {
|
||||||
|
fn from_str(s: &str) -> Result<Self> {
|
||||||
|
Ok(from_str(s)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_on(&self) -> bool {
|
||||||
|
self.POWER == "ON"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
345
src/web_server.rs
Normal file
345
src/web_server.rs
Normal file
|
@ -0,0 +1,345 @@
|
||||||
|
use actix_files::NamedFile;
|
||||||
|
use actix_web::{
|
||||||
|
get, post,
|
||||||
|
web::{Data, Json, Path},
|
||||||
|
Error, Responder, ResponseError,
|
||||||
|
};
|
||||||
|
use chrono::{Datelike, NaiveDateTime, Timelike};
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::to_string;
|
||||||
|
|
||||||
|
use crate::{db::DataBase, tasmota::Tasmota};
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
fmt::{Display, Formatter, Result as FmtResult},
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct DeviceState {
|
||||||
|
name: String,
|
||||||
|
power: bool,
|
||||||
|
led: bool,
|
||||||
|
power_draw: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct MyError {
|
||||||
|
msg: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for MyError {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
|
||||||
|
write!(f, "Error: {}", self.msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResponseError for MyError {}
|
||||||
|
|
||||||
|
impl From<anyhow::Error> for MyError {
|
||||||
|
fn from(value: anyhow::Error) -> Self {
|
||||||
|
MyError {
|
||||||
|
msg: value.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Resolution {
|
||||||
|
Raw,
|
||||||
|
Hourly,
|
||||||
|
Daily,
|
||||||
|
Monthly,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Resolution {
|
||||||
|
fn from_str(s: &str) -> anyhow::Result<Self> {
|
||||||
|
Ok(match s {
|
||||||
|
"raw" | "Raw" => Self::Raw,
|
||||||
|
"hourly" | "Hourly" => Self::Hourly,
|
||||||
|
"daily" | "Daily" => Self::Daily,
|
||||||
|
"monthly" | "Monthly" => Self::Monthly,
|
||||||
|
|
||||||
|
_ => anyhow::bail!("failed to parse {s}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/")]
|
||||||
|
async fn index() -> Result<NamedFile, impl ResponseError> {
|
||||||
|
NamedFile::open("resources/static/index.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/devices")]
|
||||||
|
async fn device_query(
|
||||||
|
db: Data<Arc<Mutex<DataBase>>>,
|
||||||
|
) -> Result<impl Responder, impl ResponseError> {
|
||||||
|
db.lock()
|
||||||
|
.unwrap()
|
||||||
|
.devices()
|
||||||
|
.map_err(|err| {
|
||||||
|
println!("{err:?}");
|
||||||
|
MyError::from(err)
|
||||||
|
})?
|
||||||
|
.to_json()
|
||||||
|
.map(|json| Json(json))
|
||||||
|
.map_err(|err| {
|
||||||
|
println!("{err:?}");
|
||||||
|
MyError::from(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/device_name/{device}/{name}")]
|
||||||
|
async fn change_device_name(
|
||||||
|
param: Path<(String, String)>,
|
||||||
|
db: Data<Arc<Mutex<DataBase>>>,
|
||||||
|
) -> Result<impl Responder, MyError> {
|
||||||
|
let (device, name) = param.into_inner();
|
||||||
|
|
||||||
|
db.lock()
|
||||||
|
.unwrap()
|
||||||
|
.change_device_name(&device, &name)
|
||||||
|
.map_err(|err| MyError::from(err))?;
|
||||||
|
|
||||||
|
return Ok("Ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn tasmota_info(tasmota: &Tasmota) -> anyhow::Result<String> {
|
||||||
|
let led = tasmota.led_state().await?;
|
||||||
|
let power = tasmota.power_state().await?;
|
||||||
|
let power_draw = tasmota.read_power_usage().await?;
|
||||||
|
|
||||||
|
Ok(to_string(&DeviceState {
|
||||||
|
name: tasmota.name().to_string(),
|
||||||
|
power,
|
||||||
|
led,
|
||||||
|
power_draw,
|
||||||
|
})?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/plug_state/{plug}")]
|
||||||
|
async fn plug_state(
|
||||||
|
plug: Path<String>,
|
||||||
|
plugs: Data<Vec<Tasmota>>,
|
||||||
|
) -> Result<impl Responder, impl ResponseError> {
|
||||||
|
let plug_name = plug.into_inner();
|
||||||
|
|
||||||
|
if let Some(tasmota) = plugs.iter().find(|tasmota| tasmota.name() == plug_name) {
|
||||||
|
return Ok(tasmota_info(tasmota)
|
||||||
|
.await
|
||||||
|
.map(|s| Json(s))
|
||||||
|
.map_err(|err| MyError::from(err))?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(MyError {
|
||||||
|
msg: format!("plug ({plug_name}) not found"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/plug/{plug}/{action}")]
|
||||||
|
async fn change_plug_state(
|
||||||
|
param: Path<(String, String)>,
|
||||||
|
plugs: Data<Vec<Tasmota>>,
|
||||||
|
) -> Result<impl Responder, impl ResponseError> {
|
||||||
|
let (plug_name, action_type) = param.into_inner();
|
||||||
|
|
||||||
|
if let Some(tasmota) = plugs.iter().find(|tasmota| tasmota.name() == plug_name) {
|
||||||
|
match action_type.as_str() {
|
||||||
|
"led_on" => tasmota
|
||||||
|
.turn_on_led()
|
||||||
|
.await
|
||||||
|
.map_err(|err| MyError::from(err))?,
|
||||||
|
"led_off" => tasmota
|
||||||
|
.turn_off_led()
|
||||||
|
.await
|
||||||
|
.map_err(|err| MyError::from(err))?,
|
||||||
|
"power_on" => tasmota
|
||||||
|
.switch_on()
|
||||||
|
.await
|
||||||
|
.map_err(|err| MyError::from(err))?,
|
||||||
|
"power_off" => tasmota
|
||||||
|
.switch_off()
|
||||||
|
.await
|
||||||
|
.map_err(|err| MyError::from(err))?,
|
||||||
|
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok("Ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(MyError {
|
||||||
|
msg: format!("plug ({plug_name}) not found"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/plug_data/{plug}")]
|
||||||
|
async fn plug_data(
|
||||||
|
param: Path<String>,
|
||||||
|
db: Data<Arc<Mutex<DataBase>>>,
|
||||||
|
) -> Result<impl Responder, Error> {
|
||||||
|
let plug = param.into_inner();
|
||||||
|
|
||||||
|
let data = db
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.read(&plug)
|
||||||
|
.map_err(|err| MyError::from(err))?;
|
||||||
|
|
||||||
|
Ok(Json(to_string(&data)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/plug_data/{plug}/{start_time}/{end_time}/{resolution}")]
|
||||||
|
async fn plug_data_range(
|
||||||
|
param: Path<(String, u64, u64, String)>,
|
||||||
|
db: Data<Arc<Mutex<DataBase>>>,
|
||||||
|
) -> Result<impl Responder, Error> {
|
||||||
|
let (plug, start, end, resolution) = param.into_inner();
|
||||||
|
|
||||||
|
let data = db
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.read_range(&plug, start, end)
|
||||||
|
.map_err(|err| MyError::from(err))?;
|
||||||
|
|
||||||
|
Ok(Json(to_string(
|
||||||
|
&match Resolution::from_str(&resolution).map_err(|err| MyError::from(err))? {
|
||||||
|
Resolution::Raw => data,
|
||||||
|
Resolution::Hourly => collapse_data(data, |datetime| {
|
||||||
|
datetime.with_minute(0).unwrap().with_second(0).unwrap()
|
||||||
|
}),
|
||||||
|
Resolution::Daily => collapse_data(data, |datetime| {
|
||||||
|
datetime
|
||||||
|
.with_hour(0)
|
||||||
|
.unwrap()
|
||||||
|
.with_minute(0)
|
||||||
|
.unwrap()
|
||||||
|
.with_second(0)
|
||||||
|
.unwrap()
|
||||||
|
}),
|
||||||
|
Resolution::Monthly => collapse_data(data, |datetime| {
|
||||||
|
datetime
|
||||||
|
.with_day(1)
|
||||||
|
.unwrap()
|
||||||
|
.with_hour(0)
|
||||||
|
.unwrap()
|
||||||
|
.with_minute(0)
|
||||||
|
.unwrap()
|
||||||
|
.with_second(0)
|
||||||
|
.unwrap()
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collapse_data<F>(data: Vec<(u64, f32)>, f: F) -> Vec<(u64, f32)>
|
||||||
|
where
|
||||||
|
F: Fn(NaiveDateTime) -> NaiveDateTime,
|
||||||
|
{
|
||||||
|
let mut frames: HashMap<NaiveDateTime, Vec<f32>> = HashMap::new();
|
||||||
|
|
||||||
|
for (timestamp, watts) in data {
|
||||||
|
let date_time = f(NaiveDateTime::from_timestamp_opt(timestamp as i64, 0).unwrap());
|
||||||
|
|
||||||
|
match frames.get_mut(&date_time) {
|
||||||
|
Some(entries) => entries.push(watts),
|
||||||
|
None => {
|
||||||
|
frames.insert(date_time, vec![watts]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut v: Vec<(u64, f32)> = frames
|
||||||
|
.into_iter()
|
||||||
|
.map(|(date_time, entries)| {
|
||||||
|
let length = entries.len();
|
||||||
|
let sum: f32 = entries.into_iter().sum();
|
||||||
|
|
||||||
|
(date_time.timestamp() as u64, sum / length as f32)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
v.sort_by_key(|(timestamp, _)| *timestamp);
|
||||||
|
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use actix_web::{http::header::ContentType, test, App};
|
||||||
|
use reqwest::Method;
|
||||||
|
use std::{thread, time::Duration};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn test_index_get() {
|
||||||
|
let app = test::init_service(App::new().service(index)).await;
|
||||||
|
let req = test::TestRequest::default()
|
||||||
|
.insert_header(ContentType::plaintext())
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.into_body();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
status.is_success(),
|
||||||
|
"status: {:?}, error: {:?}",
|
||||||
|
status,
|
||||||
|
body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn test_led_on_off() {
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.service(change_plug_state)
|
||||||
|
.app_data(Data::new(vec![Tasmota::new("Tasmota-Plug-3")])),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
{
|
||||||
|
let req = test::TestRequest::default()
|
||||||
|
.uri("/plug/Tasmota-Plug-3/led_off")
|
||||||
|
.insert_header(ContentType::plaintext())
|
||||||
|
.method(Method::POST)
|
||||||
|
.to_request();
|
||||||
|
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.into_body();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
status.is_success(),
|
||||||
|
"status: {:?}, error: {:?}",
|
||||||
|
status,
|
||||||
|
body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_secs(5));
|
||||||
|
|
||||||
|
{
|
||||||
|
let req = test::TestRequest::default()
|
||||||
|
.uri("/plug/Tasmota-Plug-3/led_on")
|
||||||
|
.insert_header(ContentType::plaintext())
|
||||||
|
.method(Method::POST)
|
||||||
|
.to_request();
|
||||||
|
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.into_body();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
status.is_success(),
|
||||||
|
"status: {:?}, error: {:?}",
|
||||||
|
status,
|
||||||
|
body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue