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
|
||||
|
||||
*.db
|
||||
|
||||
midea_ac_lan/
|
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
@ -3,5 +3,6 @@
|
|||
"activityBar.background": "#4C0C61",
|
||||
"titleBar.activeBackground": "#6B1188",
|
||||
"titleBar.activeForeground": "#FDFAFE"
|
||||
}
|
||||
},
|
||||
"rust-analyzer.showUnlinkedFileNotification": false
|
||||
}
|
12
Cargo.toml
12
Cargo.toml
|
@ -7,10 +7,14 @@ edition = "2021"
|
|||
|
||||
[dependencies]
|
||||
rusqlite = "0.29.0"
|
||||
anyhow = { version = "1.0.71", features = ["backtrace"] }
|
||||
reqwest = "0.11.20"
|
||||
serde = { version="1.0", features = ["derive"] }
|
||||
anyhow = { version = "1.0.75", features = ["backtrace"] }
|
||||
reqwest = "0.11.22"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
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"
|
||||
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": [
|
||||
"Dev1",
|
||||
"Dev2"
|
||||
["Tasmota-Plug-1", false],
|
||||
["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(())
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
266
src/db.rs
266
src/db.rs
|
@ -1,30 +1,65 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use rusqlite::{Connection, ToSql};
|
||||
use rusqlite::{Connection, OptionalExtension, ToSql};
|
||||
|
||||
use crate::devices::{Devices, DevicesWithName};
|
||||
|
||||
pub struct DataBase {
|
||||
sql: Connection,
|
||||
}
|
||||
|
||||
impl DataBase {
|
||||
const VERSION_0_1_0: &'static str = "0.1.0";
|
||||
|
||||
pub async fn new(path: impl AsRef<Path>) -> Result<Self> {
|
||||
let me = Self {
|
||||
sql: Connection::open(path)?,
|
||||
};
|
||||
|
||||
me.generate_tables()?;
|
||||
me.init()?;
|
||||
|
||||
Ok(me)
|
||||
}
|
||||
|
||||
fn generate_tables(&self) -> Result<()> {
|
||||
self.sql.execute(
|
||||
"CREATE TABLE IF NOT EXISTS meta (
|
||||
id INTEGER PRIMARY KEY,
|
||||
version INTEGER 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,
|
||||
device TEXT NOT NULL,
|
||||
time BIGINT NOT NULL,
|
||||
watts REAL 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(())
|
||||
}
|
||||
|
||||
pub fn write(&self, device_name: &str, time: u64, watts: f32) -> Result<()> {
|
||||
let params: &[&dyn ToSql] = &[&device_name, &time, &watts];
|
||||
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(
|
||||
"INSERT INTO data (device, time, watts)
|
||||
VALUES (?1, ?2, ?3)",
|
||||
&format!(
|
||||
"
|
||||
UPDATE devices
|
||||
SET control=\"{ctl}\"
|
||||
WHERE device=\"{device}\"
|
||||
"
|
||||
),
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write(&self, device_name: &str, time: u64, watts: f32) -> Result<()> {
|
||||
let params: &[&dyn ToSql] = &[&time, &watts];
|
||||
|
||||
self.sql.execute(
|
||||
&format!(
|
||||
"INSERT INTO data (time, watts, device_id)
|
||||
VALUES (?1, ?2, (SELECT id FROM devices WHERE device=\"{device_name}\") )"
|
||||
),
|
||||
params,
|
||||
)?;
|
||||
|
||||
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)]
|
||||
|
@ -51,22 +269,52 @@ mod test {
|
|||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::devices::Devices;
|
||||
|
||||
use super::DataBase;
|
||||
|
||||
#[tokio::test]
|
||||
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")?;
|
||||
|
||||
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]
|
||||
async fn test_write() -> Result<()> {
|
||||
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")?;
|
||||
|
||||
|
|
|
@ -2,11 +2,11 @@ use std::fs;
|
|||
|
||||
use anyhow::Result;
|
||||
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)]
|
||||
pub struct Devices {
|
||||
pub plugs: Vec<String>,
|
||||
pub plugs: Vec<(String, bool)>,
|
||||
}
|
||||
|
||||
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)]
|
||||
mod test {
|
||||
use super::Devices;
|
||||
|
@ -30,7 +41,7 @@ mod test {
|
|||
#[test]
|
||||
fn create_conf() -> Result<()> {
|
||||
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")
|
||||
|
|
122
src/main.rs
122
src/main.rs
|
@ -5,48 +5,41 @@ use std::{
|
|||
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 devices;
|
||||
mod midea_helper;
|
||||
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 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 tibber::TibberHandler;
|
||||
use tibber_handler::TibberHandler;
|
||||
use web_server::*;
|
||||
|
||||
fn since_epoch() -> Result<u64> {
|
||||
Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let db_future = DataBase::new("home_server.db");
|
||||
let devices_future = Devices::read("devices.conf");
|
||||
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 prices_today = tibber.prices_today().await?;
|
||||
let prices_tomorrow = tibber.prices_tomorrow().await?;
|
||||
let consumption = tibber.consumption(Daily, 1).await?;
|
||||
|
||||
let shared_db = Arc::new(Mutex::new(db));
|
||||
let tasmota_plugs: Vec<Tasmota> = devices
|
||||
.plugs
|
||||
.iter()
|
||||
.map(|plug| Tasmota::new(plug))
|
||||
.collect();
|
||||
|
||||
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 {
|
||||
shared_db
|
||||
.lock()
|
||||
db.lock()
|
||||
.unwrap()
|
||||
.write(plug.name(), since_epoch()?, usage)?;
|
||||
}
|
||||
|
@ -57,4 +50,81 @@ async fn main() -> Result<()> {
|
|||
|
||||
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]
|
||||
async fn main() -> Result<()> {
|
||||
let db_future = DataBase::new("home_server.db");
|
||||
let devices_future = Devices::read("devices.conf");
|
||||
let tibber_future = TibberHandler::new(fs::read_to_string("tibber_token.txt")?);
|
||||
|
||||
let (db, devices, tibber, midea) = try_join!(
|
||||
db_future,
|
||||
devices_future,
|
||||
tibber_future,
|
||||
MideaDiscovery::discover()
|
||||
)?;
|
||||
|
||||
let prices_today = tibber.prices_today().await?;
|
||||
let prices_tomorrow = tibber.prices_tomorrow().await?;
|
||||
let consumption = tibber.consumption(Daily, 1).await?;
|
||||
|
||||
db.register_devices(&devices)?;
|
||||
let shared_db = Arc::new(Mutex::new(db));
|
||||
|
||||
let tasmota_plugs: Vec<Tasmota> = devices
|
||||
.plugs
|
||||
.iter()
|
||||
.map(|(plug, _)| Tasmota::new(plug))
|
||||
.collect();
|
||||
|
||||
let dishwasher = MideaDishwasher::create(midea, shared_db.clone())
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|d| Arc::new(d))
|
||||
.collect();
|
||||
|
||||
try_join!(
|
||||
read_power_usage(tasmota_plugs.clone(), shared_db.clone()),
|
||||
run_web_server(devices, tasmota_plugs, shared_db, dishwasher)
|
||||
)?;
|
||||
|
||||
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 serde::Deserialize;
|
||||
use serde_json::from_str;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Tasmota {
|
||||
device: String,
|
||||
}
|
||||
|
@ -15,11 +18,18 @@ impl Tasmota {
|
|||
&self.device
|
||||
}
|
||||
|
||||
fn command(&self, command: &str) -> String {
|
||||
format!("http://{}/cm?cmnd={}", self.device, command)
|
||||
fn command<'a>(&self, command: impl IntoIterator<Item = &'a str>) -> String {
|
||||
let mut str = String::new();
|
||||
|
||||
for s in command.into_iter() {
|
||||
str += s;
|
||||
str += "%20";
|
||||
}
|
||||
|
||||
async fn post(&self, command: &str) -> Result<String> {
|
||||
format!("http://{}/cm?cmnd={}", self.device, str)
|
||||
}
|
||||
|
||||
async fn post<'a>(&self, command: impl IntoIterator<Item = &'a str>) -> Result<String> {
|
||||
Ok(reqwest::Client::new()
|
||||
.post(&self.command(command))
|
||||
.send()
|
||||
|
@ -28,7 +38,7 @@ impl Tasmota {
|
|||
.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()
|
||||
.get(&self.command(command))
|
||||
.send()
|
||||
|
@ -38,32 +48,162 @@ impl Tasmota {
|
|||
}
|
||||
|
||||
pub async fn turn_on_led(&self) -> Result<()> {
|
||||
self.post("LedPower=1").await?;
|
||||
self.post(["LedPower", "1"]).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn turn_off_led(&self) -> Result<()> {
|
||||
self.post("LedPower=2").await?;
|
||||
self.post(["LedPower", "0"]).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn switch_on(&self) -> Result<()> {
|
||||
self.post("Power0=1").await?;
|
||||
self.post(["Power0", "1"]).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn switch_off(&self) -> Result<()> {
|
||||
self.post("Power0=0").await?;
|
||||
self.post(["Power0", "0"]).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn read_power_usage(&self) -> Result<f32> {
|
||||
let res = self.get("Status=8").await?;
|
||||
pub async fn power_state(&self) -> Result<bool> {
|
||||
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