From 121f82bff3014c5056ce44beb85007d890df980b Mon Sep 17 00:00:00 2001 From: Edward Yang Date: Wed, 11 Aug 2021 00:39:03 -0500 Subject: [PATCH] Add a gui implementation for led control (#7) * Add a gui implementation for led control (and maybe more) * Fix slider clamping * Feature parity with all commands, collapsing * Minor typo fix --- Cargo.toml | 2 + examples/gui/app.rs | 277 ++++++++++++++++++++++++++++++++++++++++++ examples/gui/main.rs | 63 ++++++++++ examples/gui/panel.rs | 56 +++++++++ 4 files changed, 398 insertions(+) create mode 100644 examples/gui/app.rs create mode 100644 examples/gui/main.rs create mode 100644 examples/gui/panel.rs diff --git a/Cargo.toml b/Cargo.toml index b399f87..4e72b1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,3 +24,5 @@ serial-core = "0.4" serial-unix = "0.4" failure = "0.1" ron = "0.6" +eframe = "0.13" +anyhow = "1.0" \ No newline at end of file diff --git a/examples/gui/app.rs b/examples/gui/app.rs new file mode 100644 index 0000000..a96b500 --- /dev/null +++ b/examples/gui/app.rs @@ -0,0 +1,277 @@ +use std::{ + collections::VecDeque, + num::NonZeroU16, + sync::mpsc::{channel, Receiver, Sender}, + time::Duration, +}; + +use eframe::{ + egui::{self, FontDefinitions, FontFamily, ScrollArea, Vec2}, + epi::{self, Storage}, +}; +use panel_protocol::{Command, PulseMode, Report}; + +const SHOW_LAST_COMMAND_NUM: usize = 15; + +#[derive(Clone, Copy, PartialEq)] +struct LedState { + r: u8, + g: u8, + b: u8, + if_breathing_interval_ms: u16, + pulse_mode: PulseMode, +} + +impl Default for LedState { + fn default() -> Self { + Self { + r: 255, + g: 255, + b: 255, + if_breathing_interval_ms: 4000, + pulse_mode: PulseMode::Solid, + } + } +} + +impl From for Command { + fn from(led_state: LedState) -> Command { + Command::Led { + r: led_state.r, + g: led_state.g, + b: led_state.b, + pulse_mode: led_state.pulse_mode, + } + } +} + +#[derive(Default, Clone, Copy, PartialEq)] +struct LightState { + brightness: u16, + temperature: u16, +} + +pub struct App { + report_rx: Receiver, + command_tx: Sender, + led_state: LedState, + light_state: [LightState; 2], + last_recv_reports: VecDeque, + kill_updater: Option>, +} + +impl App { + pub fn new(report_rx: Receiver, command_tx: Sender) -> Self { + Self { + report_rx, + command_tx, + led_state: Default::default(), + light_state: Default::default(), + last_recv_reports: VecDeque::new(), + kill_updater: None, + } + } + + fn led_configuration_section(&mut self, ui: &mut eframe::egui::Ui) { + ui.add( + egui::Slider::new(&mut self.led_state.r, 0..=255).text("LED Red").clamp_to_range(true), + ); + ui.add( + egui::Slider::new(&mut self.led_state.g, 0..=255) + .text("LED Green") + .clamp_to_range(true), + ); + ui.add( + egui::Slider::new(&mut self.led_state.b, 0..=255).text("LED Blue").clamp_to_range(true), + ); + + // Pulse mode + egui::ComboBox::from_label("Pulse Mode") + .selected_text(format!("{:?}", self.led_state.pulse_mode)) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.led_state.pulse_mode, PulseMode::Solid, "Solid"); + ui.selectable_value( + &mut self.led_state.pulse_mode, + PulseMode::DialTurn, + "DialTurn", + ); + ui.selectable_value( + &mut self.led_state.pulse_mode, + PulseMode::Breathing { + interval_ms: NonZeroU16::new(self.led_state.if_breathing_interval_ms) + .unwrap(), + }, + "Breathing", + ); + }); + + // Duration for pulse mode if breathing + ui.scope(|ui| { + ui.set_visible(matches!(self.led_state.pulse_mode, PulseMode::Breathing { .. })); + + let response = ui.add( + egui::Slider::new(&mut self.led_state.if_breathing_interval_ms, 1..=u16::MAX) + .text("Breathing (half) interval (ms)") + .clamp_to_range(true), + ); + if response.changed() { + self.led_state.pulse_mode = PulseMode::Breathing { + interval_ms: NonZeroU16::new(self.led_state.if_breathing_interval_ms).unwrap(), + }; + } + }); + } + + fn lighting_configuration_section(&mut self, ui: &mut eframe::egui::Ui) { + ui.label("Front Lights"); + ui.group(|ui| { + ui.add( + egui::Slider::new(&mut self.light_state[0].brightness, 0..=u16::MAX) + .text("Brightness") + .clamp_to_range(true), + ); + ui.add( + egui::Slider::new(&mut self.light_state[0].temperature, 0..=u16::MAX) + .text("Temperature") + .clamp_to_range(true), + ); + }); + ui.label("Back Lights"); + ui.group(|ui| { + ui.add( + egui::Slider::new(&mut self.light_state[1].brightness, 0..=u16::MAX) + .text("Brightness") + .clamp_to_range(true), + ); + ui.add( + egui::Slider::new(&mut self.light_state[1].temperature, 0..=u16::MAX) + .text("Temperature") + .clamp_to_range(true), + ); + }); + } + + fn other_commands_section(&mut self, ui: &mut eframe::egui::Ui) { + if ui.button(format!("Send {:?} command", Command::Bootload)).clicked() { + self.command_tx.send(Command::Bootload).unwrap(); + } + } + + fn serial_monitor_section(&mut self, ui: &mut eframe::egui::Ui) { + ui.group(|ui| { + let commands_strings = self + .last_recv_reports + .iter() + .map(|report| format!("New serial message received: {:?}", report)) + .collect::>(); + ui.add(egui::Label::new(commands_strings.join("\n")).code()) + }); + } +} + +impl epi::App for App { + fn setup( + &mut self, + _ctx: &eframe::egui::CtxRef, + _frame: &mut epi::Frame<'_>, + _: Option<&dyn Storage>, + ) { + // Add another thread to force a repaint on new reports being received, forwards those reports + let (report_tx, mut report_rx) = channel(); + let (kill_updater_tx, kill_updater_rx) = channel(); + std::mem::swap(&mut self.report_rx, &mut report_rx); + self.kill_updater = Some(kill_updater_tx); + let repaint_signal = _frame.repaint_signal().clone(); + std::thread::spawn(move || loop { + if kill_updater_rx.try_recv().is_ok() { + println!("Killed updater thread."); + break; + } + while let Ok(report) = report_rx.try_recv() { + report_tx.send(report).unwrap(); + repaint_signal.request_repaint(); + } + std::thread::sleep(Duration::from_millis(1)); + }); + + // Update the led on startup + self.command_tx.send(self.led_state.into()).unwrap(); + + // Setup some fonts + let mut fonts = FontDefinitions::default(); + fonts.family_and_size.insert(egui::TextStyle::Body, (FontFamily::Proportional, 18.0)); + fonts.family_and_size.insert(egui::TextStyle::Button, (FontFamily::Proportional, 18.0)); + fonts.family_and_size.insert(egui::TextStyle::Monospace, (FontFamily::Monospace, 18.0)); + fonts.family_and_size.insert(egui::TextStyle::Heading, (FontFamily::Proportional, 24.0)); + _ctx.set_fonts(fonts); + } + + fn update(&mut self, ctx: &egui::CtxRef, _frame: &mut epi::Frame<'_>) { + let current_led_state = self.led_state.clone(); + let current_light_state = self.light_state.clone(); + egui::CentralPanel::default().show(ctx, |ui| { + if let Ok(report) = self.report_rx.try_recv() { + self.last_recv_reports.push_back(report); + while self.last_recv_reports.len() > SHOW_LAST_COMMAND_NUM { + self.last_recv_reports.pop_front(); + } + } + + ScrollArea::auto_sized().show(ui, |ui| { + ui.spacing_mut().slider_width = ui.available_width() - 300.0; + ui.spacing_mut().item_spacing = Vec2::new(10.0, 10.0); + ui.spacing_mut().button_padding = Vec2::new(10.0, 10.0); + ui.vertical_centered_justified(|ui| { + ui.heading("Panel Configurator"); + // RGB sliders + ui.separator(); + ui.collapsing("RGB LED Configuration", |ui| self.led_configuration_section(ui)); + + // Lighting + ui.separator(); + ui.collapsing("Lighting", |ui| self.lighting_configuration_section(ui)); + + // Bootloader command + ui.separator(); + ui.collapsing("Other Commands", |ui| self.other_commands_section(ui)); + + // Show last few commands + ui.separator(); + ui.collapsing( + format!("Serial Monitor (last {} messages)", SHOW_LAST_COMMAND_NUM), + |ui| self.serial_monitor_section(ui), + ); + + // Warn if debug build + egui::warn_if_debug_build(ui); + }); + }); + }); + + if self.led_state != current_led_state { + self.command_tx.send(self.led_state.into()).unwrap(); + } + + if self.light_state != current_light_state { + for (target, state) in self.light_state.iter().enumerate() { + let target = target as u8; + self.command_tx + .send(Command::Brightness { target, value: state.brightness }) + .unwrap(); + self.command_tx + .send(Command::Temperature { target, value: state.temperature }) + .unwrap(); + } + } + } + + fn name(&self) -> &str { + "Panel Configurator" + } + + fn on_exit(&mut self) { + if let Some(kill_updater) = &self.kill_updater { + kill_updater.send(()).unwrap(); + } + } +} diff --git a/examples/gui/main.rs b/examples/gui/main.rs new file mode 100644 index 0000000..8afe882 --- /dev/null +++ b/examples/gui/main.rs @@ -0,0 +1,63 @@ +use anyhow::Result; +use eframe::run_native; +use std::{ + env, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + thread, + time::Duration, +}; +mod app; +mod panel; + +fn print_usage(args: &[String]) { + println!("Usage: {} ", args[0]); + println!(""); + println!("The program initiates a serial connection with the device specified by the "); + println!("tty_port, and prints every Report that comes in"); + println!(""); +} + +fn main() -> Result<()> { + let args: Vec = env::args().collect(); + if args.len() != 2 { + print_usage(&args); + return Ok(()); + } + + let port = &args[1]; + let (report_tx, report_rx) = std::sync::mpsc::channel(); + let (command_tx, command_rx) = std::sync::mpsc::channel(); + + let should_exit = Arc::new(AtomicBool::new(false)); + thread::spawn({ + let mut panel = panel::Panel::new(port)?; + let should_exit = should_exit.clone(); + move || loop { + match panel.poll() { + Ok(reports) => { + for report in reports { + println!("New serial message: {:?}", &report); + report_tx.send(report).unwrap(); + } + }, + Err(e) => { + eprintln!("Failed to poll reports: {}", e); + should_exit.store(true, Ordering::SeqCst); + return; + }, + } + + while let Ok(command) = command_rx.try_recv() { + panel.send(&command).unwrap(); + } + thread::sleep(Duration::from_micros(50)); + } + }); + + let app = app::App::new(report_rx, command_tx); + + run_native(Box::new(app), Default::default()); +} diff --git a/examples/gui/panel.rs b/examples/gui/panel.rs new file mode 100644 index 0000000..2e9c021 --- /dev/null +++ b/examples/gui/panel.rs @@ -0,0 +1,56 @@ +use anyhow::{format_err, Error, Result}; +use panel_protocol::{ + ArrayVec, Command, Report, ReportReader, MAX_REPORT_LEN, MAX_REPORT_QUEUE_LEN, +}; +use serial_core::{BaudRate, SerialDevice, SerialPortSettings}; +use serial_unix::TTYPort; +use std::{ + self, io, + io::{Read, Write}, + path::PathBuf, + time::Duration, +}; + +static TTY_TIMEOUT: Duration = Duration::from_millis(500); + +pub struct Panel { + tty: TTYPort, + protocol: ReportReader, + read_buf: [u8; MAX_REPORT_LEN], +} + +impl Panel { + pub fn new(tty_port: &str) -> Result { + let mut tty = TTYPort::open(&PathBuf::from(tty_port))?; + tty.set_timeout(TTY_TIMEOUT)?; + + // The panel firmware runs at 115200 baud. + // TODO: Remove this after switching to the native USB connection. + let mut tty_settings = tty.read_settings()?; + tty_settings.set_baud_rate(BaudRate::Baud115200)?; + tty.write_settings(&tty_settings)?; + + let protocol = ReportReader::new(); + let read_buf = [0u8; MAX_REPORT_LEN]; + + Ok(Self { tty, protocol, read_buf }) + } + + pub fn poll(&mut self) -> Result, Error> { + match self.tty.read(&mut self.read_buf) { + Ok(0) => Err(format_err!("End of file reached")), + Ok(count) => self + .protocol + .process_bytes(&self.read_buf[..count]) + .map_err(|e| format_err!("Failed to process bytes: {:?}", e)), + Err(e) if e.kind() != io::ErrorKind::TimedOut => Err(e.into()), + Err(_) => Ok(ArrayVec::new()), + } + } + + pub fn send(&mut self, command: &Command) -> Result<(), Error> { + self.tty.write_all(&command.as_arrayvec()[..])?; + + Ok(()) + } +}