commit 8a2f255e949ec3db0459bc0b839df4d551aee785 Author: LordMZTE Date: Sat Oct 23 23:25:56 2021 +0200 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7fefb9e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "gue" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +angular-units = "0.2.4" +dirs = "4.0.0" +gtk = "0.14.3" +log = "0.4.14" +miette = { version = "3.2.0", features = ["fancy"] } +prisma = "0.1.1" +relm = "0.22.0" +relm-derive = "0.22.0" +reqwest = "0.11.6" +rhue = { git = "https://mzte.de/git/lordmzte/rhue.git" } +serde = { version = "1.0.130", features = ["derive"] } +serde_json = "1.0.68" +simplelog = "0.10.2" +tokio = { version = "1.12.0", features = ["rt-multi-thread", "macros", "net", "sync", "fs"] } +url = { version = "2.2.2", features = ["serde"] } + +[features] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0a04128 --- /dev/null +++ b/LICENSE @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..1059111 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,12 @@ +unstable_features = true +binop_separator = "Back" +format_code_in_doc_comments = true +format_macro_matchers = true +format_strings = true +imports_layout = "HorizontalVertical" +match_block_trailing_comma = true +merge_imports = true +normalize_comments = true +use_field_init_shorthand = true +use_try_shorthand = true +wrap_comments = true diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..ca17e64 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,76 @@ +use log::{info, warn}; +use std::path::PathBuf; +use tokio::{ + fs::File, + io::{AsyncReadExt, AsyncWriteExt}, +}; + +use miette::{Context, IntoDiagnostic}; +use serde::{Deserialize, Serialize}; +use url::Url; + +pub struct Config { + pub data: ConfigData, + path: PathBuf, +} + +impl Config { + pub async fn new(path: PathBuf) -> miette::Result { + if !path.exists() { + warn!("Config doesn't exist, creating."); + if let Some(p) = path.parent() { + tokio::fs::create_dir_all(p) + .await + .into_diagnostic() + .wrap_err("failed to create config dir")?; + } + + Ok(Self { + data: ConfigData::default(), + path, + }) + } else { + let mut f = File::open(&path) + .await + .into_diagnostic() + .wrap_err("Couldn't open config file!")?; + let mut data = vec![]; + + f.read_to_end(&mut data) + .await + .into_diagnostic() + .wrap_err("Failed to read config file")?; + + let data = serde_json::from_slice(&data) + .into_diagnostic() + .wrap_err("Failed to deserialize config")?; + + Ok(Self { path, data }) + } + } + + pub async fn write(&self) -> miette::Result<()> { + info!("writing config"); + let mut file = File::create(&self.path) + .await + .into_diagnostic() + .wrap_err("Couldn't create config file")?; + + let data = serde_json::to_vec(&self.data) + .into_diagnostic() + .wrap_err("Failed to write config")?; + + file.write_all(&data) + .await + .into_diagnostic() + .wrap_err("Failed to write config")?; + + Ok(()) + } +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct ConfigData { + pub bridge_addr: Option, + pub bridge_username: Option, +} diff --git a/src/gui/headerbar.rs b/src/gui/headerbar.rs new file mode 100644 index 0000000..a236033 --- /dev/null +++ b/src/gui/headerbar.rs @@ -0,0 +1,48 @@ +use crate::gui::settings::Settings; +use gtk::prelude::*; +use relm::{Component, Sender, Widget}; +use relm_derive::widget; +use tokio::sync::mpsc::UnboundedSender; + +use crate::runtime::RuntimeMsg; + +use super::Msg; + +pub struct HeaderBarModel { + settings: Component, +} + +#[derive(Clone, Msg)] +pub enum HeaderBarMsg { + OpenSettings, +} + +#[widget] +impl Widget for HeaderBar { + fn model(data: (UnboundedSender, Sender, String, String)) -> HeaderBarModel { + HeaderBarModel { + settings: relm::init::((data.0.clone(), data.2, data.3)) + .expect("failed to create settings window"), + } + } + fn update(&mut self, msg: HeaderBarMsg) { + match msg { + HeaderBarMsg::OpenSettings => { + self.model.settings.widget().show(); + }, + } + } + + view! { + gtk::HeaderBar { + title: Some("gue"), + show_close_button: true, + + gtk::Button { + image: Some(>k::Image::from_icon_name(Some("preferences-system"), gtk::IconSize::LargeToolbar)), + + clicked => HeaderBarMsg::OpenSettings, + } + } + } +} diff --git a/src/gui/light_entry.rs b/src/gui/light_entry.rs new file mode 100644 index 0000000..2fb47de --- /dev/null +++ b/src/gui/light_entry.rs @@ -0,0 +1,168 @@ +use angular_units::Turns; +use gtk::{prelude::*, Orientation}; +use log::debug; +use prisma::{Hsv, Rgb}; +use relm::Widget; +use relm_derive::{widget, Msg}; +use rhue::{api::Light, bridge::StateUpdate}; +use std::{cell::Cell, rc::Rc}; +use tokio::sync::mpsc::UnboundedSender; + +use crate::runtime::RuntimeMsg; + +pub struct LightEntryModel { + id: String, + name: String, + runtime: UnboundedSender, + on: bool, + brightness: u8, + color: Rc>>, +} + +#[derive(Clone, Msg)] +pub enum LightEntryMsg { + Update(StateUpdate), + PropertyChanged(EntryLightProperty), +} + +#[derive(Clone, PartialEq, Eq)] +pub enum EntryLightProperty { + On, + Brightness, +} + +#[widget] +impl Widget for LightEntry { + fn model(params: (String, Light, UnboundedSender)) -> LightEntryModel { + LightEntryModel { + id: params.0, + name: params.1.name, + runtime: params.2, + on: params.1.state.on, + brightness: params.1.state.bri, + color: Rc::new(Cell::new( + Hsv::new( + Turns(params.1.state.hue as f64 / 65535f64), + params.1.state.sat as f64 / 255f64, + params.1.state.bri as f64 / 255f64, + ) + .into(), + )), + } + } + + fn update(&mut self, msg: LightEntryMsg) { + match msg { + LightEntryMsg::Update(upd) => { + debug!("updating light {}", &self.model.id); + if let (Some(h), Some(s), Some(v)) = (upd.hue, upd.sat, upd.bri) { + self.model.color.set( + Hsv::new( + Turns(h as f64 / 65535f64), + s as f64 / 255f64, + v as f64 / 255f64, + ) + .into(), + ); + + self.widgets.color_indicator.queue_draw(); + } + + if let Some(on) = upd.on { + self.model.on = on; + } + + let _ = self + .model + .runtime + .send(RuntimeMsg::UpdateLight(self.model.id.clone(), upd)); + }, + + LightEntryMsg::PropertyChanged(prop) => { + debug!("updating light {}", &self.model.id); + let upd = StateUpdate { + on: if prop == EntryLightProperty::On { + Some(self.widgets.on_switch.is_active()) + } else { + None + }, + + bri: if prop == EntryLightProperty::Brightness { + let col = self.model.color.get(); + let mut col = Hsv::<_, Turns<_>>::from(col); + col.set_value(self.widgets.brightness_slider.value() / 255f64); + self.model.color.set(col.into()); + + self.widgets.color_indicator.queue_draw(); + Some(self.widgets.brightness_slider.value() as u8) + } else { + None + }, + + ..Default::default() + }; + + let _ = self + .model + .runtime + .send(RuntimeMsg::UpdateLight(self.model.id.clone(), upd)); + }, + } + } + + fn init_view(&mut self) { + self.widgets.brightness_slider.set_range(0., 255.); + self.widgets.brightness_slider.set_increments(1., 0.); + self.widgets + .brightness_slider + .set_value(self.model.brightness as f64); + + let col = Rc::clone(&self.model.color); + self.widgets.color_indicator.connect_draw(move |_, c| { + let col = col.get(); + c.set_source_rgb(col.red(), col.green(), col.blue()); + c.paint().unwrap(); + + Inhibit(false) + }); + } + + view! { + gtk::Box { + orientation: Orientation::Horizontal, + + #[name = "color_indicator"] + gtk::DrawingArea { + width_request: 25, + height_request: 25, + margin: 4, + }, + + #[name = "on_switch"] + gtk::Switch { + active: self.model.on, + state_set(_, _) => (LightEntryMsg::PropertyChanged(EntryLightProperty::On), Inhibit(false)), + margin: 4, + }, + + #[name = "brightness_slider"] + gtk::Scale { + value: self.model.brightness as f64, + value_changed => LightEntryMsg::PropertyChanged(EntryLightProperty::Brightness), + width_request: 200, + draw_value: false, + margin: 4, + }, + + gtk::Label { + text: &self.model.id, + margin: 4, + }, + + gtk::Label { + text: &self.model.name, + margin: 4, + }, + } + } +} diff --git a/src/gui/mod.rs b/src/gui/mod.rs new file mode 100644 index 0000000..2750334 --- /dev/null +++ b/src/gui/mod.rs @@ -0,0 +1,227 @@ +use crate::gui::{headerbar::HeaderBar, light_entry::LightEntry}; +use angular_units::Turns; +use gtk::{prelude::*, Orientation, SelectionMode}; +use prisma::{Hsv, Rgb}; +use relm::{Channel, Component, ContainerWidget, Relm, Sender, Widget}; +use relm_derive::{widget, Msg}; +use rhue::{api::Light, bridge::StateUpdate}; +use std::collections::HashMap; +use tokio::sync::mpsc::UnboundedSender; + +use crate::runtime::RuntimeMsg; + +use self::light_entry::LightEntryMsg; + +pub mod headerbar; +pub mod light_entry; +pub mod settings; + +pub struct Model { + _channel: Channel, + runtime: UnboundedSender, + tx: Sender, + headerbar: Component, + spinning: bool, + light_widgets: Vec>, +} + +#[derive(Clone, Msg)] +pub enum Msg { + Quit, + PropertyChanged(LightProperty), + SetLights(HashMap), + RefreshLights, + SelectAll, + DeselectAll, +} + +#[derive(Clone, PartialEq, Eq)] +pub enum LightProperty { + On, + Color, +} + +#[widget] +impl Widget for Win { + fn model(relm: &Relm, params: (UnboundedSender, String, String)) -> Model { + let stream = relm.stream().clone(); + let (ch, tx) = Channel::new(move |msg| stream.emit(msg)); + + let _ = params.0.send(RuntimeMsg::FindLights(tx.clone())); + + Model { + _channel: ch, + headerbar: relm::init::((params.0.clone(), tx.clone(), params.1, params.2)) + .expect("header init"), + runtime: params.0, + tx, + spinning: false, + light_widgets: vec![], + } + } + + fn update(&mut self, msg: Msg) { + match msg { + Msg::Quit => { + let _ = self.model.runtime.send(RuntimeMsg::Quit); + gtk::main_quit(); + }, + + Msg::PropertyChanged(prop) => { + let rgba = self.widgets.color_chooser.rgba(); + let rgb = Rgb::new(rgba.red, rgba.green, rgba.blue); + let hsv = Hsv::<_, Turns<_>>::from(rgb); + + let upd = StateUpdate { + hue: if prop == LightProperty::Color { + Some((hsv.hue().0 * 65535f64) as u16) + } else { + None + }, + sat: if prop == LightProperty::Color { + Some((hsv.saturation() * 255f64) as u8) + } else { + None + }, + bri: if prop == LightProperty::Color { + Some((hsv.value() * 255f64) as u8) + } else { + None + }, + on: if prop == LightProperty::On { + Some(self.widgets.on_switch.is_active()) + } else { + None + }, + ..Default::default() + }; + + for i in self + .widgets + .lights_list + .selected_rows() + .iter() + .map(|r| r.index() as usize) + { + if let Some(l) = self.model.light_widgets.get(i) { + l.emit(LightEntryMsg::Update(upd.clone())); + } + } + }, + + Msg::RefreshLights => { + self.model.spinning = true; + let _ = self + .model + .runtime + .send(RuntimeMsg::FindLights(self.model.tx.clone())); + }, + + Msg::SetLights(lights) => { + let mut lights = lights.into_iter().collect::>(); + lights.sort_by(|&(ref a, _), &(ref b, _)| a.cmp(b)); + + for c in self.widgets.lights_list.children() { + self.widgets.lights_list.remove(&c); + } + + self.model.light_widgets.clear(); + for l in lights { + self.model.light_widgets.push( + self.widgets.lights_list.add_widget::(( + l.0, + l.1, + self.model.runtime.clone(), + )), + ); + } + + self.model.spinning = false; + }, + + Msg::SelectAll => self.widgets.lights_list.select_all(), + Msg::DeselectAll => self.widgets.lights_list.unselect_all(), + } + } + + view! { + gtk::Window { + titlebar: Some(self.model.headerbar.widget()), + + gtk::Box { + orientation: Orientation::Horizontal, + + gtk::Box { + orientation: Orientation::Vertical, + + gtk::Box { + orientation: Orientation::Horizontal, + + gtk::Spinner { + active: self.model.spinning, + }, + + gtk::Button { + image: Some(>k::Image::from_icon_name(Some("view-refresh"), gtk::IconSize::LargeToolbar)), + + clicked => Msg::RefreshLights, + }, + + gtk::Button { + label: "Select All", + + clicked => Msg::SelectAll, + }, + + gtk::Button { + label: "Deselect All", + + clicked => Msg::DeselectAll, + }, + }, + + gtk::Frame { + label: Some("Lights"), + hexpand: true, + + #[name = "lights_list"] + gtk::ListBox { + selection_mode: SelectionMode::Multiple, + } + } + }, + + gtk::Frame { + label: Some("Light Properties"), + halign: gtk::Align::End, + hexpand: false, + gtk::Box { + orientation: Orientation::Vertical, + + #[name = "color_chooser"] + gtk::ColorChooserWidget { + show_editor: true, + use_alpha: false, + rgba_notify => Msg::PropertyChanged(LightProperty::Color), + }, + + gtk::Box { + orientation: Orientation::Horizontal, + + gtk::Label { + text: "On", + }, + + #[name = "on_switch"] + gtk::Switch { + state_set(_, _) => (Msg::PropertyChanged(LightProperty::On), Inhibit(false)), + }, + } + } + } + }, + + delete_event(_, _) => (Msg::Quit, Inhibit(false)), + } + } +} diff --git a/src/gui/settings.rs b/src/gui/settings.rs new file mode 100644 index 0000000..7318e24 --- /dev/null +++ b/src/gui/settings.rs @@ -0,0 +1,176 @@ +use gtk::{prelude::*, Orientation}; +use relm::{Channel, Relm, Sender, Widget}; +use relm_derive::{widget, Msg}; +use reqwest::Url; +use tokio::sync::mpsc::UnboundedSender; + +use crate::runtime::RuntimeMsg; + +pub struct SettingsModel { + _channel: Channel, + runtime: UnboundedSender, + tx: Sender, + status: String, + addr_spinning: bool, + username_spinning: bool, + username: String, + addr: String, +} + +#[derive(Clone, Msg)] +pub enum SettingsMsg { + Close, + Discover, + Register, + Save, + SetAddr(Option), + SetUsername(Result), +} + +#[widget] +impl Widget for Settings { + fn model( + relm: &Relm, + params: (UnboundedSender, String, String), + ) -> SettingsModel { + let stream = relm.stream().clone(); + let (ch, tx) = Channel::new(move |msg| stream.emit(msg)); + + SettingsModel { + _channel: ch, + runtime: params.0, + tx, + status: String::new(), + addr_spinning: false, + username_spinning: false, + addr: params.1, + username: params.2, + } + } + + fn update(&mut self, msg: SettingsMsg) { + match msg { + SettingsMsg::Close => self.widgets.window.hide(), + SettingsMsg::Discover => { + self.model.addr_spinning = true; + let _ = self + .model + .runtime + .send(RuntimeMsg::DiscoverBridge(self.model.tx.clone())); + }, + + SettingsMsg::Register => { + if let Ok(url) = Url::parse(self.widgets.addr_entry.text().as_str()) { + self.model.username_spinning = true; + let _ = self + .model + .runtime + .send(RuntimeMsg::Register(url, self.model.tx.clone())); + } else { + self.model.status = "Bridge address is not a valid URL.".to_string(); + } + }, + SettingsMsg::Save => { + if let Ok(url) = Url::parse(self.widgets.addr_entry.text().as_str()) { + let _ = self.model.runtime.send(RuntimeMsg::SetAndSaveConfig( + url, + self.widgets.username_entry.text().to_string(), + )); + } else { + self.model.status = "Bridge address is not a valid URL.".to_string(); + } + }, + + SettingsMsg::SetAddr(addr) => { + self.model.addr_spinning = false; + if let Some(addr) = addr { + self.model.addr = addr; + } else { + self.model.status = "Couldn't find a Bridge.".to_string(); + } + }, + + SettingsMsg::SetUsername(username) => { + self.model.username_spinning = false; + match username { + Ok(u) => { + self.model.username = u; + }, + + Err(e) => { + self.model.status = format!("Failed to register at bridge: {}", e); + }, + } + }, + } + } + + fn init_view(&mut self) { + self.widgets.window.hide(); + } + + view! { + #[name = "window"] + gtk::Window { + title: "Settings", + + gtk::Box { + orientation: Orientation::Vertical, + + gtk::Box { + orientation: Orientation::Horizontal, + + #[name = "addr_entry"] + gtk::Entry { + placeholder_text: Some("Bridge Address"), + hexpand: true, + text: &self.model.addr, + }, + + gtk::Button { + label: "Discover", + clicked => SettingsMsg::Discover, + }, + + gtk::Spinner { + hexpand: false, + active: self.model.addr_spinning, + }, + }, + + gtk::Box { + orientation: Orientation::Horizontal, + + #[name = "username_entry"] + gtk::Entry { + placeholder_text: Some("Username"), + hexpand: true, + text: &self.model.username, + }, + + gtk::Button { + label: "Register", + clicked => SettingsMsg::Register, + }, + + gtk::Spinner { + hexpand: false, + active: self.model.username_spinning, + }, + }, + + gtk::Button { + label: "Save", + clicked => SettingsMsg::Save, + }, + + #[name = "status"] + gtk::Label { + text: &self.model.status, + } + }, + + delete_event(_, _) => (SettingsMsg::Close, Inhibit(true)), + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..db542bc --- /dev/null +++ b/src/main.rs @@ -0,0 +1,55 @@ +use crate::config::ConfigData; +use log::LevelFilter; +use miette::{Context, IntoDiagnostic}; +use relm::Widget; + +use crate::runtime::Runtime; + +mod config; +mod gui; +mod runtime; + +#[tokio::main] +async fn main() -> miette::Result<()> { + simplelog::TermLogger::init( + if cfg!(debug_assertions) { + LevelFilter::Debug + } else { + LevelFilter::Info + }, + simplelog::ConfigBuilder::new() + .set_time_to_local(true) + .set_location_level(LevelFilter::Error) + .build(), + simplelog::TerminalMode::Stdout, + simplelog::ColorChoice::Auto, + ) + .into_diagnostic() + .wrap_err("Failed to init logger")?; + + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + + let runtime = Runtime::init(rx).await.wrap_err("Failed to init runtime")?; + let ConfigData { + bridge_addr, + bridge_username, + } = runtime.config.read().await.data.clone(); + let runtime_handle = tokio::spawn(runtime.run()); + + gui::Win::run(( + tx, + bridge_addr + .map(|u| u.to_string()) + .unwrap_or_else(String::new), + bridge_username.unwrap_or_else(String::new), + )) + .into_diagnostic() + .wrap_err("Window crashed :(")?; + + runtime_handle + .await + .into_diagnostic()? + .wrap_err("RUNTIME CRASH")?; + + Ok(()) +} diff --git a/src/runtime.rs b/src/runtime.rs new file mode 100644 index 0000000..ac8b244 --- /dev/null +++ b/src/runtime.rs @@ -0,0 +1,162 @@ +use reqwest::Url; +use std::{sync::Arc, time::Duration}; +use tokio::sync::{mpsc, RwLock}; + +use crate::{ + config::{Config, ConfigData}, + gui::{settings::SettingsMsg, Msg}, +}; +use log::{error, info}; +use miette::{miette, Context, IntoDiagnostic}; +use relm::Sender; +use rhue::bridge::{Bridge, StateUpdate}; +use tokio::sync::mpsc::UnboundedReceiver; + +pub struct Runtime { + pub config: Arc>, + bridge: Arc>>, + rx: UnboundedReceiver, + http: Arc, +} + +impl Runtime { + pub async fn init(rx: UnboundedReceiver) -> miette::Result { + info!("initializing runtime"); + let conf_path = dirs::config_dir() + .ok_or_else(|| miette!("Couldn't get config dir"))? + .join("gue/config.json"); + + let config = Config::new(conf_path) + .await + .wrap_err("Failed to init config")?; + info!("initialized runtime"); + + let bridge = if let (Some(endpoint), Some(username)) = + (&config.data.bridge_addr, &config.data.bridge_username) + { + Some(Bridge::new(username, endpoint.clone())) + } else { + None + }; + + Ok(Self { + config: Arc::new(RwLock::new(config)), + rx, + bridge: Arc::new(RwLock::new(bridge)), + http: Arc::new(reqwest::Client::new()), + }) + } + + pub async fn run(self) -> miette::Result<()> { + async fn handle( + quit_ch: mpsc::Sender<()>, + msg: RuntimeMsg, + config: Arc>, + bridge: Arc>>, + http: Arc, + ) { + match Runtime::handle_msg(msg, config, bridge, http).await { + Err(e) => { + error!("Error handling runtime message: {:?}", e); + }, + + Ok(true) => { + let _ = quit_ch.send(()); + }, + + _ => {}, + } + } + let Self { + config, + bridge, + mut rx, + http, + } = self; + + let (quit_tx, mut quit_rx) = mpsc::channel(1); + + tokio::select! { + _ = async { + while let Some(msg) = rx.recv().await { + tokio::spawn(handle( + quit_tx.clone(), + msg, + Arc::clone(&config), + Arc::clone(&bridge), + Arc::clone(&http), + )); + } + } => {}, + + _ = quit_rx.recv() => {}, + } + Ok(()) + } + + async fn handle_msg( + msg: RuntimeMsg, + config: Arc>, + bridge: Arc>>, + http: Arc, + ) -> miette::Result { + match msg { + RuntimeMsg::Quit => { + info!("Shutting down runtime"); + return Ok(true); + }, + + RuntimeMsg::FindLights(tx) => { + if let Some(bridge) = bridge.read().await.as_ref() { + let lights = bridge.get_lights().await?; + let _ = tx.send(Msg::SetLights(lights)); + } + }, + + RuntimeMsg::SetAndSaveConfig(url, username) => { + config.write().await.data = ConfigData { + bridge_addr: Some(url), + bridge_username: Some(username), + }; + config.read().await.write().await?; + }, + + RuntimeMsg::DiscoverBridge(tx) => { + let addr = rhue::discover_bridge(Arc::clone(&http), Duration::from_secs(5)).await?; + tx.send(SettingsMsg::SetAddr(addr.map(|u| u.to_string()))) + .into_diagnostic()?; + }, + RuntimeMsg::Register(url, tx) => { + match Bridge::register(http, "GUE", url).await?.to_result() { + Ok(b) => { + let username = b.username.clone(); + *bridge.write().await = Some(b); + tx.send(SettingsMsg::SetUsername(Ok(username))) + .into_diagnostic()?; + }, + Err(e) => { + tx.send(SettingsMsg::SetUsername(Err(e.description))) + .into_diagnostic()?; + }, + } + }, + + RuntimeMsg::UpdateLight(id, upd) => { + if let Some(bridge) = &*bridge.read().await { + bridge.update_light(&id, upd).await?; + } + }, + } + + Ok(false) + } +} + +pub enum RuntimeMsg { + Quit, + SetAndSaveConfig(Url, String), + FindLights(Sender), + DiscoverBridge(Sender), + Register(Url, Sender), + UpdateLight(String, StateUpdate), +}