feat: initial commit

This commit is contained in:
2026-08-17 18:53:55 +02:00
commit 59c6fb43a0
8 changed files with 2638 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
use std::time::Duration;
use anyhow::Result;
use rumqttc::{AsyncClient, Event, MqttOptions, Packet, Publish, QoS};
use serde::Deserialize;
use time::{OffsetDateTime, PrimitiveDateTime};
use crate::{data, events};
time::serde::format_description!(
tasmota_time,
PrimitiveDateTime,
"[year]-[month]-[day]T[hour]:[minute]:[second]"
);
const MACHINE1_TOPIC: &str = "tele/tasmota_F68B94/SENSOR";
const MACHINE2_TOPIC: &str = "tele/tasmota_F81A3C/SENSOR";
pub struct MachineData {
pub name: String,
pub voltage: f32,
pub current: f32,
pub last_updated: OffsetDateTime,
}
#[derive(Deserialize)]
pub struct TasmotaData {
#[serde(rename = "Time")]
#[serde(with = "tasmota_time")]
time: PrimitiveDateTime,
#[serde(rename = "ENERGY")]
energy: TasmotaEnergyData,
}
#[derive(Deserialize)]
pub struct TasmotaEnergyData {
#[serde(rename = "Voltage")]
voltage: f32,
#[serde(rename = "Current")]
current: f32,
}
fn publish_to_machine_data(name: String, publish: Publish) -> Result<data::MachineData> {
let data = publish.payload.to_vec();
let data: TasmotaData = serde_json::from_slice(&data)?;
Ok(data::MachineData {
name,
voltage: data.energy.voltage,
current: data.energy.current,
last_updated: data.time.assume_utc(),
})
}
fn start_timestamp_task(event_sender: events::EventSender) {
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(1)).await;
let now = OffsetDateTime::now_local().unwrap();
event_sender
.send(events::Event::Timestamp(now))
.await
.unwrap();
}
});
}
async fn start_data_fetch_task(event_sender: events::EventSender) -> Result<()> {
let options = MqttOptions::new("machinetui", "192.168.178.251", 1883);
let (client, mut event_loop) = AsyncClient::new(options, 10);
client.subscribe(MACHINE1_TOPIC, QoS::AtLeastOnce).await?;
client.subscribe(MACHINE2_TOPIC, QoS::AtLeastOnce).await?;
tokio::spawn(async move {
let _client = client;
loop {
match event_loop.poll().await {
Ok(Event::Incoming(Packet::Publish(publish))) => match publish.topic.as_str() {
MACHINE1_TOPIC => {
let machine_data =
publish_to_machine_data("machine 1".into(), publish).unwrap();
event_sender
.send(events::Event::MachineUpdate(machine_data))
.await
.unwrap();
}
MACHINE2_TOPIC => {
let machine_data =
publish_to_machine_data("machine 2".into(), publish).unwrap();
event_sender
.send(events::Event::MachineUpdate(machine_data))
.await
.unwrap();
}
_ => {}
},
Ok(_) => {}
Err(_err) => {}
}
}
});
Ok(())
}
pub async fn start(event_sender: events::EventSender) -> Result<()> {
start_timestamp_task(event_sender.clone());
start_data_fetch_task(event_sender).await?;
Ok(())
}
+17
View File
@@ -0,0 +1,17 @@
use time::OffsetDateTime;
use tokio::sync::mpsc;
use crate::data;
pub enum Event {
Timestamp(OffsetDateTime),
MachineUpdate(data::MachineData),
Exit,
}
pub type EventSender = mpsc::Sender<Event>;
pub type EventReceiver = mpsc::Receiver<Event>;
pub fn channel() -> (EventSender, EventReceiver) {
mpsc::channel(100)
}
+31
View File
@@ -0,0 +1,31 @@
use anyhow::Result;
use crossterm::event::{EventStream, KeyCode, KeyModifiers};
use futures::StreamExt;
use crate::events;
pub async fn start(event_sender: events::EventSender) -> Result<()> {
let mut reader = EventStream::new();
while let Some(result) = reader.next().await {
let event = result?;
match event {
crossterm::event::Event::Key(key) => {
if key.is_press() {
let exit = key.code == KeyCode::Char('q')
|| (key.code == KeyCode::Char('c')
&& key.modifiers.contains(KeyModifiers::CONTROL));
if exit {
event_sender.send(events::Event::Exit).await?;
break;
}
}
}
_ => {}
}
}
Ok(())
}
+16
View File
@@ -0,0 +1,16 @@
use anyhow::Result;
mod data;
mod events;
mod interaction;
mod tui;
#[tokio::main]
async fn main() -> Result<()> {
let (event_sender, event_receiver) = events::channel();
data::start(event_sender.clone()).await?;
tokio::try_join!(tui::start(event_receiver), interaction::start(event_sender))?;
Ok(())
}
+181
View File
@@ -0,0 +1,181 @@
use anyhow::Result;
use ratatui::{
prelude::*,
symbols::border,
widgets::{Block, Padding, Paragraph},
};
use time::{OffsetDateTime, format_description::BorrowedFormatItem, macros::format_description};
use crate::{data, events};
const TIME_FORMAT: &'static [BorrowedFormatItem] = format_description!("[hour]:[minute]:[second]");
pub struct App {
pub timestamp: OffsetDateTime,
pub machines: Vec<data::MachineData>,
}
impl App {
pub fn new() -> Self {
Self {
timestamp: OffsetDateTime::now_local().unwrap(),
machines: vec![
data::MachineData {
name: "machine 1".into(),
voltage: 0f32,
current: 0f32,
last_updated: OffsetDateTime::now_utc(),
},
data::MachineData {
name: "machine 2".into(),
voltage: 0f32,
current: 0f32,
last_updated: OffsetDateTime::now_utc(),
},
],
}
}
}
impl Widget for &App {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
let [headline_area, machines_area, _] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(6),
Constraint::Min(0),
])
.areas(area);
let [machine1_area, machine2_area] =
Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(machines_area);
Headline {
timestamp: &self.timestamp,
}
.render(headline_area, buf);
MachineData {
data: &self.machines[0],
}
.render(machine1_area, buf);
MachineData {
data: &self.machines[1],
}
.render(machine2_area, buf);
}
}
pub struct Headline<'a> {
timestamp: &'a OffsetDateTime,
}
impl<'a> Widget for &Headline<'a> {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
// First render the border and get the inner area
let block = Block::bordered()
.border_set(border::THICK)
.padding(Padding::horizontal(1));
let block_area = block.inner(area);
block.render(area, buf);
// Now create a layout for the left and the right side
let layout_const = [Constraint::Fill(1), Constraint::Fill(1)];
let [left_area, right_area] = Layout::horizontal(layout_const).areas(block_area);
// Render the text in the left area
let text = Line::from("Machine data").bold();
Paragraph::new(text).render(left_area, buf);
// Render the timestamp in the right area
let text = self.timestamp.format(TIME_FORMAT).unwrap();
Paragraph::new(text)
.alignment(Alignment::Right)
.render(right_area, buf);
}
}
pub struct MachineData<'a> {
data: &'a data::MachineData,
}
impl<'a> Widget for &MachineData<'a> {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
// First render the border and get the inner area
let block = Block::bordered()
.border_set(border::THICK)
.padding(Padding::horizontal(1));
let block_area = block.inner(area);
block.render(area, buf);
let layout_const = [
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
];
let [name_area, voltage_area, current_area, date_area] =
Layout::vertical(layout_const).areas(block_area);
let format = format_description!("[year]-[month]-[day] [hour]:[month]:[second]");
let last_updated = self.data.last_updated.format(format).unwrap();
let text = format!("Name: {}", self.data.name).bold().fg(Color::Blue);
Paragraph::new(text).render(name_area, buf);
let text = Line::from(vec![
"Voltage: ".bold(),
format!("{:.0} V", self.data.voltage).into(),
]);
Paragraph::new(text).render(voltage_area, buf);
let text = Line::from(vec![
"Current: ".bold(),
format!("{:.03} A", self.data.current).into(),
]);
Paragraph::new(text).render(current_area, buf);
let text = Line::from(vec!["Last updated: ".bold(), last_updated.into()]);
Paragraph::new(text).render(date_area, buf);
}
}
pub async fn start(event_receiver: events::EventReceiver) -> Result<()> {
let mut event_receiver = event_receiver;
let mut terminal = ratatui::init();
let mut running = true;
let mut app = App::new();
while running {
// render tui
terminal.draw(|frame| {
frame.render_widget(&app, frame.area());
})?;
// poll event for tui update
match event_receiver.recv().await {
Some(events::Event::Timestamp(t)) => app.timestamp = t,
Some(events::Event::MachineUpdate(data)) => {
for machine in &mut app.machines {
if machine.name == data.name {
machine.voltage = data.voltage;
machine.current = data.current;
machine.last_updated = data.last_updated;
}
}
}
Some(events::Event::Exit) => running = false,
None => running = false,
}
}
ratatui::restore();
Ok(())
}