Merge remote-tracking branch 'refs/remotes/origin/master'

This commit is contained in:
hodasemi 2023-10-16 13:46:25 +02:00
commit 85ed9c1983
18 changed files with 1604 additions and 168 deletions

4
.gitignore vendored
View file

@ -3,4 +3,6 @@ Cargo.lock
test_devices.conf
*.db
*.db
midea_ac_lan/

View file

@ -3,5 +3,6 @@
"activityBar.background": "#4C0C61",
"titleBar.activeBackground": "#6B1188",
"titleBar.activeForeground": "#FDFAFE"
}
},
"rust-analyzer.showUnlinkedFileNotification": false
}

View file

@ -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"] }
tibber = "0.5.0"
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
View 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/

View file

@ -1,6 +1,8 @@
{
"plugs": [
"Dev1",
"Dev2"
["Tasmota-Plug-1", false],
["Tasmota-Plug-2", false],
["Tasmota-Plug-3", true],
["Tasmota-Plug-4", true]
]
}

View file

@ -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()

View file

@ -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
View file

@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:base"
]
}

3
resources/css/index.css Normal file
View file

@ -0,0 +1,3 @@
td>*:not(:last-child) {
margin-right: 5px;
}

293
resources/js/main.js Normal file
View 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);
}

View 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
View 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
View file

@ -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 data (
"CREATE TABLE IF NOT EXISTS meta (
id INTEGER PRIMARY KEY,
device TEXT NOT NULL,
time BIGINT NOT NULL,
watts REAL NOT NULL
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,
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(())
}
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<()> {
let params: &[&dyn ToSql] = &[&device_name, &time, &watts];
let params: &[&dyn ToSql] = &[&time, &watts];
self.sql.execute(
"INSERT INTO data (device, time, watts)
VALUES (?1, ?2, ?3)",
&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")?;

View file

@ -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")

View file

@ -5,56 +5,126 @@ 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())
}
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]
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 (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))
.map(|(plug, _)| Tasmota::new(plug))
.collect();
loop {
try_join_all(tasmota_plugs.iter().map(|plug| async {
if let Ok(usage) = plug.read_power_usage().await {
shared_db
.lock()
.unwrap()
.write(plug.name(), since_epoch()?, usage)?;
}
let dishwasher = MideaDishwasher::create(midea, shared_db.clone())
.await?
.into_iter()
.map(|d| Arc::new(d))
.collect();
Ok::<(), anyhow::Error>(())
}))
.await?;
try_join!(
read_power_usage(tasmota_plugs.clone(), shared_db.clone()),
run_web_server(devices, tasmota_plugs, shared_db, dishwasher)
)?;
thread::sleep(Duration::from_secs(3));
}
Ok(())
}

95
src/midea_helper.rs Normal file
View 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)
}
}

View file

@ -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";
}
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()
.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
View 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
);
}
}
}