diff --git a/Cargo.lock b/Cargo.lock index 9efbff1..364c691 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3101,7 +3101,7 @@ dependencies = [ [[package]] name = "tether-artnet-controller" -version = "0.3.3" +version = "0.4.0" dependencies = [ "anyhow", "artnet_protocol", diff --git a/Cargo.toml b/Cargo.toml index 7d717f3..b7655f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tether-artnet-controller" -version = "0.3.3" +version = "0.4.0" edition = "2021" repository = "https://github.com/RandomStudio/tether-artnet-controller" authors = ["Stephen Buchanan"] diff --git a/README.md b/README.md index 82ad30f..3ff601d 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ If you have Tether Egui installed (`cargo install tether-egui`) then the easiest - [ ] Project JSON should save ArtNet configuration (but can override via CLI args) - [x] Colour conversions should be possible manually, e.g. RGB -> CMY - [x] With macros, add some visual indicators of state, e.g. Colour, Brightness and Pan/Tilt -- [ ] Allow the app to launch just fine without Tether +- [x] Allow the app to launch just fine without Tether - [ ] Allow the app to launch without any project file at all - [ ] Add 16-bit control, at least for macros (single slider adjusts the two channels as split between first and second 8-bit digits) - [ ] ArtNet on separate thread, with more precise timing; this might require some messaging back and forth and/or mutex diff --git a/src/main.rs b/src/main.rs index cbe7d0b..f49728a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use std::{net::SocketAddr, sync::mpsc, time::Duration}; use env_logger::Env; -use log::{debug, error, info}; +use log::{debug, info}; use clap::Parser; @@ -9,7 +9,6 @@ use crate::{ artnet::{ArtNetInterface, ArtNetMode}, model::Model, settings::Cli, - tether_interface::start_tether_thread, ui::SIMPLE_WIN_SIZE, }; @@ -33,14 +32,6 @@ fn main() { debug!("Started with settings: {:?}", cli); - let mut handles = Vec::new(); - - let (tether_tx, tether_rx) = mpsc::channel(); - let (quit_tether_tx, quit_tether_rx) = mpsc::channel(); - let tether_handle = start_tether_thread(tether_tx.clone(), quit_tether_rx); - - handles.push(tether_handle); - let artnet = { if cli.artnet_broadcast { ArtNetInterface::new(ArtNetMode::Broadcast, cli.artnet_update_frequency) @@ -55,7 +46,7 @@ fn main() { } }; - let mut model = Model::new(tether_rx, cli.clone(), artnet); + let mut model = Model::new(cli.clone(), artnet); if cli.headless_mode { info!("Running in headless mode; Ctrl+C to quit"); @@ -77,7 +68,6 @@ fn main() { std::thread::sleep(Duration::from_millis(1)); model.update(); } - model.reset_before_quit(); } else { info!("Running graphics mode; close the window to quit"); let options = eframe::NativeOptions { @@ -93,19 +83,7 @@ fn main() { .expect("Failed to launch GUI"); info!("GUI ended; exit soon..."); } - quit_tether_tx - .send(()) - .expect("failed to send quit message via channel"); - for h in handles { - match h.join() { - Ok(()) => { - debug!("Thread join OK"); - } - Err(e) => { - error!("Thread joined with error, {:?}", e); - } - } - } + std::thread::sleep(Duration::from_secs(1)); info!("...Exit now"); std::process::exit(0); diff --git a/src/model.rs b/src/model.rs index c62095e..3f463af 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,5 +1,6 @@ use std::{ - sync::mpsc::Receiver, + sync::{Arc, Mutex}, + thread::JoinHandle, time::{Duration, SystemTime}, }; @@ -14,9 +15,9 @@ use crate::{ settings::{Cli, CHANNELS_PER_UNIVERSE}, tether_interface::{ RemoteControlMessage, RemoteMacroMessage, RemoteMacroValue, RemoteSceneMessage, - TetherControlChangePayload, TetherMidiMessage, TetherNotePayload, + TetherControlChangePayload, TetherInterface, TetherMidiMessage, TetherNotePayload, }, - ui::{render_gui, ViewMode}, + ui::{attempt_connection, render_gui, ViewMode}, }; #[derive(PartialEq)] @@ -26,10 +27,18 @@ pub enum BehaviourOnExit { Zero, } +pub enum TetherStatus { + NotConnected, + Connected, + Errored(String), +} + pub struct Model { + pub handles: Vec>, pub channels_state: Vec, pub channels_assigned: Vec, - pub tether_rx: Receiver, + pub tether_interface: TetherInterface, + pub tether_status: TetherStatus, pub settings: Cli, pub artnet: ArtNetInterface, pub project: Project, @@ -43,6 +52,7 @@ pub struct Model { pub save_on_exit: bool, pub show_confirm_exit: bool, pub allowed_to_close: bool, + pub should_quit: Arc>, } impl eframe::App for Model { @@ -57,11 +67,7 @@ impl eframe::App for Model { } impl Model { - pub fn new( - tether_rx: Receiver, - settings: Cli, - artnet: ArtNetInterface, - ) -> Model { + pub fn new(settings: Cli, artnet: ArtNetInterface) -> Model { let mut current_project_path = None; let project = match Project::load(&settings.project_path) { @@ -90,8 +96,16 @@ impl Model { } } + let should_quit = Arc::new(Mutex::new(false)); + + let tether_interface = TetherInterface::new(); + + let should_auto_connect = !settings.tether_disable_autoconnect; + let mut model = Model { - tether_rx, + tether_status: TetherStatus::NotConnected, + handles: Vec::new(), + tether_interface, channels_state: Vec::new(), channels_assigned, settings, @@ -105,8 +119,14 @@ impl Model { save_on_exit: true, show_confirm_exit: false, allowed_to_close: false, + should_quit, }; + if should_auto_connect { + info!("Auto connect Tether enabled; will attempt to connect now..."); + attempt_connection(&mut model) + } + model.apply_home_values(); model @@ -115,7 +135,7 @@ impl Model { pub fn update(&mut self) { let mut work_done = false; - while let Ok(m) = self.tether_rx.try_recv() { + while let Ok(m) = self.tether_interface.message_rx.try_recv() { work_done = true; self.apply_macros = true; match m { @@ -513,6 +533,7 @@ impl Model { } pub fn reset_before_quit(&mut self) { + *self.should_quit.lock().unwrap() = true; if self.save_on_exit { info!("Save-on-exit enabled; will save current project if loaded..."); if let Some(existing_project_path) = &self.current_project_path { diff --git a/src/settings.rs b/src/settings.rs index efcfd79..c9dd011 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -45,4 +45,12 @@ pub struct Cli { #[arg(long = "auto.random")] pub auto_random: bool, + + /// Flag to disable Tether connect on start (GUI only) + #[arg(long = "tether.noAutoConnect")] + pub tether_disable_autoconnect: bool, + + /// Host/IP for Tether MQTT Broker + #[arg(long = "tether.host")] + pub tether_host: Option, } diff --git a/src/tether_interface.rs b/src/tether_interface.rs index 97ac8bd..cc1911e 100644 --- a/src/tether_interface.rs +++ b/src/tether_interface.rs @@ -1,11 +1,16 @@ use std::{ - sync::mpsc::{Receiver, Sender}, - thread::{sleep, spawn, JoinHandle}, + sync::{ + self, + mpsc::{Receiver, Sender}, + Arc, Mutex, + }, + thread::{sleep, spawn}, time::Duration, }; +use anyhow::anyhow; use egui::Color32; -use log::{debug, info}; +use log::{debug, error, info}; use serde::{Deserialize, Serialize}; use tether_agent::{PlugOptionsBuilder, TetherAgentOptionsBuilder}; @@ -64,68 +69,99 @@ pub enum RemoteControlMessage { SceneAnimation(RemoteSceneMessage), } -pub fn start_tether_thread( - tx: Sender, - rx_quit: Receiver<()>, -) -> JoinHandle<()> { - let tether_agent = TetherAgentOptionsBuilder::new("ArtnetController") - .build() - .expect("failed to init Tether Agent"); - - let input_midi_cc = PlugOptionsBuilder::create_input("controlChange") - .build(&tether_agent) - .expect("failed to create Input Plug"); - - let input_midi_notes = PlugOptionsBuilder::create_input("notesOn") - .build(&tether_agent) - .expect("failed to create Input Plug"); - - let input_macros = PlugOptionsBuilder::create_input("macros") - .build(&tether_agent) - .expect("failed to create Input Plug"); - - let input_scenes = PlugOptionsBuilder::create_input("scenes") - .build(&tether_agent) - .expect("failed to create Input Plug"); - - let mut should_quit = false; - - spawn(move || { - while !should_quit { - while let Some((topic, message)) = tether_agent.check_messages() { - if input_midi_cc.matches(&topic) { - debug!("MIDI CC"); - let m = rmp_serde::from_slice::(message.payload()) - .unwrap(); - tx.send(RemoteControlMessage::Midi( - TetherMidiMessage::ControlChange(m), - )) - .expect("failed to send from Tether Interface thread") - } - if input_midi_notes.matches(&topic) { - debug!("MIDI Note"); - let m = rmp_serde::from_slice::(message.payload()).unwrap(); - tx.send(RemoteControlMessage::Midi(TetherMidiMessage::NoteOn(m))) - .expect("failed to send from Tether Interface thread") - } - if input_macros.matches(&topic) { - debug!("Macro (direct) control message"); - let m = rmp_serde::from_slice::(message.payload()).unwrap(); - tx.send(RemoteControlMessage::MacroAnimation(m)) - .expect("failed to send from Tether Interface thread"); - } - if input_scenes.matches(&topic) { - debug!("Remote Scene message"); - let m = rmp_serde::from_slice::(message.payload()).unwrap(); - tx.send(RemoteControlMessage::SceneAnimation(m)) - .expect("failed to send from Tether Interface thread"); +pub struct TetherInterface { + pub message_rx: Receiver, + pub quit_channel: (Sender<()>, Receiver<()>), + // --- + message_tx: Sender, +} + +impl TetherInterface { + pub fn new() -> Self { + let (message_tx, message_rx) = sync::mpsc::channel(); + let (quit_tx, quit_rx) = sync::mpsc::channel(); + + TetherInterface { + message_tx, + message_rx, + quit_channel: (quit_tx, quit_rx), + } + } + + pub fn connect( + &mut self, + should_quit: Arc>, + tether_host: Option<&str>, + ) -> Result<(), anyhow::Error> { + info!("Attempt to connect Tether Agent..."); + + if let Ok(tether_agent) = TetherAgentOptionsBuilder::new("ArtnetController") + .host(tether_host) + .build() + { + let input_midi_cc = PlugOptionsBuilder::create_input("controlChange") + .build(&tether_agent) + .expect("failed to create Input Plug"); + + let input_midi_notes = PlugOptionsBuilder::create_input("notesOn") + .build(&tether_agent) + .expect("failed to create Input Plug"); + + let input_macros = PlugOptionsBuilder::create_input("macros") + .build(&tether_agent) + .expect("failed to create Input Plug"); + + let input_scenes = PlugOptionsBuilder::create_input("scenes") + .build(&tether_agent) + .expect("failed to create Input Plug"); + + let tx = self.message_tx.clone(); + + spawn(move || { + while !*should_quit.lock().unwrap() { + while let Some((topic, message)) = tether_agent.check_messages() { + if input_midi_cc.matches(&topic) { + debug!("MIDI CC"); + let m = rmp_serde::from_slice::( + message.payload(), + ) + .unwrap(); + tx.send(RemoteControlMessage::Midi( + TetherMidiMessage::ControlChange(m), + )) + .expect("failed to send from Tether Interface thread") + } + if input_midi_notes.matches(&topic) { + debug!("MIDI Note"); + let m = rmp_serde::from_slice::(message.payload()) + .unwrap(); + tx.send(RemoteControlMessage::Midi(TetherMidiMessage::NoteOn(m))) + .expect("failed to send from Tether Interface thread") + } + if input_macros.matches(&topic) { + debug!("Macro (direct) control message"); + let m = rmp_serde::from_slice::(message.payload()) + .unwrap(); + tx.send(RemoteControlMessage::MacroAnimation(m)) + .expect("failed to send from Tether Interface thread"); + } + if input_scenes.matches(&topic) { + debug!("Remote Scene message"); + let m = rmp_serde::from_slice::(message.payload()) + .unwrap(); + tx.send(RemoteControlMessage::SceneAnimation(m)) + .expect("failed to send from Tether Interface thread"); + } + } + sleep(Duration::from_millis(1)); } - } - if rx_quit.try_recv().is_ok() { - info!("Tether thread got quit request"); - should_quit = true; - } - sleep(Duration::from_millis(1)); + info!("Tether Interface: Thread loop end"); + }); + + Ok(()) + } else { + error!("Failed to connect Tether"); + Err(anyhow!("Tether failed to connect")) } - }) + } } diff --git a/src/ui/macro_controls.rs b/src/ui/macro_controls.rs index 0bfbbda..5e4a22b 100644 --- a/src/ui/macro_controls.rs +++ b/src/ui/macro_controls.rs @@ -7,8 +7,8 @@ use crate::{ }; pub fn render_macro_controls(model: &mut Model, ui: &mut Ui) { - ui.heading("All"); ui.horizontal(|ui| { + ui.heading("All"); if ui.button("HOME").clicked() { model.apply_macros = false; model.apply_home_values(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 282249a..a3a7f74 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -2,7 +2,7 @@ use egui::{Color32, Grid, RichText, ScrollArea, Slider, Ui, Vec2}; use log::{error, info, warn}; use crate::{ - model::{BehaviourOnExit, Model}, + model::{BehaviourOnExit, Model, TetherStatus}, project::Project, settings::CHANNELS_PER_UNIVERSE, }; @@ -45,6 +45,7 @@ pub fn render_gui(model: &mut Model, ctx: &eframe::egui::Context, frame: &mut ef match model.view_mode { ViewMode::Advanced => { egui::SidePanel::left("LeftPanel").show(ctx, |ui| { + render_tether_controls(model, ui); render_macro_controls(model, ui); }); @@ -58,11 +59,13 @@ pub fn render_gui(model: &mut Model, ctx: &eframe::egui::Context, frame: &mut ef } ViewMode::Simple => { egui::CentralPanel::default().show(ctx, |ui| { + render_tether_controls(model, ui); render_macro_controls(model, ui); }); } ViewMode::Scenes => { egui::SidePanel::left("LeftPanel").show(ctx, |ui| { + render_tether_controls(model, ui); render_macro_controls(model, ui); }); egui::CentralPanel::default().show(ctx, |ui| { @@ -231,3 +234,44 @@ pub fn render_sliders(model: &mut Model, ui: &mut Ui) { }); }); } + +fn render_tether_controls(model: &mut Model, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.heading("Tether"); + + match &model.tether_status { + TetherStatus::NotConnected => { + ui.label(RichText::new("Not (yet) connected").color(Color32::YELLOW)); + offer_tether_connect(model, ui); + } + TetherStatus::Connected => { + ui.label(RichText::new("Connected").color(Color32::LIGHT_GREEN)); + } + TetherStatus::Errored(msg) => { + ui.label(RichText::new(msg).color(Color32::RED)); + offer_tether_connect(model, ui); + } + } + }); + ui.separator(); +} + +fn offer_tether_connect(model: &mut Model, ui: &mut Ui) { + if ui.button("Connect").clicked() { + attempt_connection(model); + } +} + +pub fn attempt_connection(model: &mut Model) { + match model.tether_interface.connect( + model.should_quit.clone(), + model.settings.tether_host.as_deref(), + ) { + Ok(_) => { + model.tether_status = TetherStatus::Connected; + } + Err(e) => { + model.tether_status = TetherStatus::Errored(format!("Error: {e}")); + } + } +}