init
This commit is contained in:
commit
8a2f255e94
11 changed files with 1116 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
Cargo.lock
|
25
Cargo.toml
Normal file
25
Cargo.toml
Normal file
|
@ -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]
|
165
LICENSE
Normal file
165
LICENSE
Normal file
|
@ -0,0 +1,165 @@
|
|||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
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.
|
12
rustfmt.toml
Normal file
12
rustfmt.toml
Normal file
|
@ -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
|
76
src/config.rs
Normal file
76
src/config.rs
Normal file
|
@ -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<Config> {
|
||||
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<Url>,
|
||||
pub bridge_username: Option<String>,
|
||||
}
|
48
src/gui/headerbar.rs
Normal file
48
src/gui/headerbar.rs
Normal file
|
@ -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<Settings>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Msg)]
|
||||
pub enum HeaderBarMsg {
|
||||
OpenSettings,
|
||||
}
|
||||
|
||||
#[widget]
|
||||
impl Widget for HeaderBar {
|
||||
fn model(data: (UnboundedSender<RuntimeMsg>, Sender<Msg>, String, String)) -> HeaderBarModel {
|
||||
HeaderBarModel {
|
||||
settings: relm::init::<Settings>((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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
168
src/gui/light_entry.rs
Normal file
168
src/gui/light_entry.rs
Normal file
|
@ -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<RuntimeMsg>,
|
||||
on: bool,
|
||||
brightness: u8,
|
||||
color: Rc<Cell<Rgb<f64>>>,
|
||||
}
|
||||
|
||||
#[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<RuntimeMsg>)) -> 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
227
src/gui/mod.rs
Normal file
227
src/gui/mod.rs
Normal file
|
@ -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<Msg>,
|
||||
runtime: UnboundedSender<RuntimeMsg>,
|
||||
tx: Sender<Msg>,
|
||||
headerbar: Component<HeaderBar>,
|
||||
spinning: bool,
|
||||
light_widgets: Vec<Component<LightEntry>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Msg)]
|
||||
pub enum Msg {
|
||||
Quit,
|
||||
PropertyChanged(LightProperty),
|
||||
SetLights(HashMap<String, Light>),
|
||||
RefreshLights,
|
||||
SelectAll,
|
||||
DeselectAll,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum LightProperty {
|
||||
On,
|
||||
Color,
|
||||
}
|
||||
|
||||
#[widget]
|
||||
impl Widget for Win {
|
||||
fn model(relm: &Relm<Self>, params: (UnboundedSender<RuntimeMsg>, 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::<HeaderBar>((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::<Vec<_>>();
|
||||
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::<LightEntry>((
|
||||
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)),
|
||||
}
|
||||
}
|
||||
}
|
176
src/gui/settings.rs
Normal file
176
src/gui/settings.rs
Normal file
|
@ -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<SettingsMsg>,
|
||||
runtime: UnboundedSender<RuntimeMsg>,
|
||||
tx: Sender<SettingsMsg>,
|
||||
status: String,
|
||||
addr_spinning: bool,
|
||||
username_spinning: bool,
|
||||
username: String,
|
||||
addr: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Msg)]
|
||||
pub enum SettingsMsg {
|
||||
Close,
|
||||
Discover,
|
||||
Register,
|
||||
Save,
|
||||
SetAddr(Option<String>),
|
||||
SetUsername(Result<String, String>),
|
||||
}
|
||||
|
||||
#[widget]
|
||||
impl Widget for Settings {
|
||||
fn model(
|
||||
relm: &Relm<Settings>,
|
||||
params: (UnboundedSender<RuntimeMsg>, 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)),
|
||||
}
|
||||
}
|
||||
}
|
55
src/main.rs
Normal file
55
src/main.rs
Normal file
|
@ -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(())
|
||||
}
|
162
src/runtime.rs
Normal file
162
src/runtime.rs
Normal file
|
@ -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<RwLock<Config>>,
|
||||
bridge: Arc<RwLock<Option<Bridge>>>,
|
||||
rx: UnboundedReceiver<RuntimeMsg>,
|
||||
http: Arc<reqwest::Client>,
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
pub async fn init(rx: UnboundedReceiver<RuntimeMsg>) -> miette::Result<Self> {
|
||||
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<RwLock<Config>>,
|
||||
bridge: Arc<RwLock<Option<Bridge>>>,
|
||||
http: Arc<reqwest::Client>,
|
||||
) {
|
||||
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<RwLock<Config>>,
|
||||
bridge: Arc<RwLock<Option<Bridge>>>,
|
||||
http: Arc<reqwest::Client>,
|
||||
) -> miette::Result<bool> {
|
||||
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<Msg>),
|
||||
DiscoverBridge(Sender<SettingsMsg>),
|
||||
Register(Url, Sender<SettingsMsg>),
|
||||
UpdateLight(String, StateUpdate),
|
||||
}
|
Loading…
Reference in a new issue