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