feat: initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+2264
File diff suppressed because it is too large
Load Diff
+16
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "machinetui"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.104"
|
||||
crossterm = { version = "0.29.0", features = ["event-stream"] }
|
||||
futures = "0.3.34"
|
||||
rand = "0.10.2"
|
||||
ratatui = "0.30.2"
|
||||
rumqttc = "0.25.1"
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.151"
|
||||
time = { version = "0.3.55", features = ["formatting", "macros", "parsing", "serde"] }
|
||||
tokio = { version = "1.53.1", features = ["full"] }
|
||||
+112
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user