From ad978e8007a0b6c0bc02fb1c27363a7933bb23e3 Mon Sep 17 00:00:00 2001 From: Simon Johnston Date: Mon, 17 Aug 2020 16:06:18 -0700 Subject: [PATCH] Initial commit. --- .github/workflows/rust.yml | 22 +++ .gitignore | 18 ++ .idea/luxafor.iml | 12 ++ .idea/modules.xml | 8 + .idea/vcs.xml | 6 + Cargo.toml | 31 +++ LICENSE | 21 +++ README.md | 47 +++++ src/bin/main.rs | 81 ++++++++ src/lib.rs | 376 +++++++++++++++++++++++++++++++++++++ 10 files changed, 622 insertions(+) create mode 100644 .github/workflows/rust.yml create mode 100644 .gitignore create mode 100644 .idea/luxafor.iml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 src/bin/main.rs create mode 100644 src/lib.rs diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..3c13d1b --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,22 @@ +name: Rust + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da1fa39 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +## ------------------------------------------------------------------------------------------------ +# Files created by development tools +/.idea/ +*.iml +**/*.rs~ +**/*.md~ + +## ------------------------------------------------------------------------------------------------ +# Files created by cargo (build, fmt, make, etc.) +/.cargo +/target +/docs +Cargo.lock +**/*.rs.bk + +## ------------------------------------------------------------------------------------------------ +# Files required for CI integration (Travis) +/ci diff --git a/.idea/luxafor.iml b/.idea/luxafor.iml new file mode 100644 index 0000000..9b4cf84 --- /dev/null +++ b/.idea/luxafor.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..7e3ce71 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0b71382 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "luxafor" +description = "Library, and CLI, for Luxafor lights via webhooks." +version = "0.1.0" +authors = ["Simon Johnston "] +edition = "2018" +license = "MIT" +readme = "README.md" +publish = true +default-run = "lux" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] +all-features = true + +[features] +command-line = ["pretty_env_logger", "structopt"] + +[[bin]] +name = "lux" +path = "src/bin/main.rs" +required-features = ["command-line"] + +[dependencies] +log = "0.4.11" +error-chain = "0.12.2" +reqwest = { version = "0.10", features = ["blocking", "json"] } + +#[feature-dependencies] +structopt = { version = "0.3.14", optional = true } +pretty_env_logger = { version = "0.4.0", optional = true } \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..96ea553 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Simon Johnston + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b62488 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +#Crate luxafor + +Library, and CLI, for [Luxafor](https://luxafor.com/products/) lights via webhooks. + +![Rust](https://github.com/johnstonskj/luxafor/workflows/Rust/badge.svg) +![Minimum Rust Version](https://img.shields.io/badge/Min%20Rust-1.40-green.svg) +[![crates.io](https://img.shields.io/crates/v/atelier_core.svg)](https://crates.io/crates/atelier_core) +[![docs.rs](https://docs.rs/atelier_core/badge.svg)](https://docs.rs/atelier_core) +![MIT License](https://img.shields.io/badge/license-mit-118811.svg) +[![GitHub stars](https://img.shields.io/github/stars/johnstonskj/luxafor.svg)](https://github.com/johnstonskj/luxafor/stargazers) + +This has been tested with the USB connected [flag](https://luxafor.com/flag-usb-busylight-availability-indicator/) +as well as the [Bluetooth](https://luxafor.com/bluetooth-busy-light-availability-indicator/) lights. + +# Examples + +The following shows the command line tool setting the color to red. + +```bash +❯ lux solid red -d 2a0f2c73b72 +``` + +The following shows the command line tool setting the color to a blinking green. + +```bash +❯ lux blink green -d 2a0f2c73b72 +``` + +The following shows the command line tool turning the light off. + +```bash +❯ lux -vvv off -d 2a0f2c73b72 + INFO luxafor > Setting the color of device '2a0f2c73b72e' to 000000 + INFO luxafor > call successful +``` + + +## Changes + +**Version 0.1.0** + +* Initial commit, supports flag and bluetooth lights. + + +## TODO + +TBD \ No newline at end of file diff --git a/src/bin/main.rs b/src/bin/main.rs new file mode 100644 index 0000000..7fcbd49 --- /dev/null +++ b/src/bin/main.rs @@ -0,0 +1,81 @@ +#[macro_use] +extern crate log; + +use luxafor::{set_pattern, set_solid_color, turn_off, DeviceID, Pattern, SolidColor}; +use std::error::Error; +use structopt::StructOpt; + +#[derive(Debug, StructOpt)] +#[structopt(name = "lux", about = "CLI for Luxafor lights")] +pub(crate) struct CommandLine { + /// The level of logging to perform; from off to trace + #[structopt(long, short = "v", parse(from_occurrences))] + verbose: i8, + + #[structopt(subcommand)] + cmd: SubCommand, +} + +#[derive(Debug, StructOpt)] +pub(crate) enum SubCommand { + /// Set the light to a to a solid color + Solid { + /// The device identifier + #[structopt(long, short)] + device: DeviceID, + + /// The color to set + #[structopt(name = "COLOR")] + color: SolidColor, + }, + /// Set the light to a to a blinking color + Blink { + /// The device identifier + #[structopt(long, short)] + device: DeviceID, + + /// The color to set + #[structopt(name = "COLOR")] + color: SolidColor, + }, + /// Set the light to a to a pre-defined pattern + Pattern { + /// The device identifier + #[structopt(long, short)] + device: DeviceID, + + /// The pattern to set + #[structopt(long, short)] + pattern: Pattern, + }, + /// Turn the light off + Off { + /// The device identifier + #[structopt(long, short)] + device: DeviceID, + }, +} + +fn main() -> Result<(), Box> { + let args = CommandLine::from_args(); + + pretty_env_logger::formatted_builder() + .filter_level(match args.verbose { + 0 => log::LevelFilter::Off, + 1 => log::LevelFilter::Error, + 2 => log::LevelFilter::Warn, + 3 => log::LevelFilter::Info, + 4 => log::LevelFilter::Debug, + _ => log::LevelFilter::Trace, + }) + .init(); + + match args.cmd { + SubCommand::Solid { device, color } => set_solid_color(device, color, false), + SubCommand::Blink { device, color } => set_solid_color(device, color, true), + SubCommand::Pattern { device, pattern } => set_pattern(device, pattern), + SubCommand::Off { device } => turn_off(device), + }?; + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8af06cd --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,376 @@ +/*! +Library, and CLI, for [Luxafor](https://luxafor.com/products/) lights via webhooks. This has been +tested with the USB connected [flag](https://luxafor.com/flag-usb-busylight-availability-indicator/) +as well as the [Bluetooth](https://luxafor.com/bluetooth-busy-light-availability-indicator/) lights. + +# Examples + +The following shows the command line tool setting the color to red. + +```bash +❯ lux solid red -d 2a0f2c73b72 +``` + +The following shows the command line tool setting the color to a blinking green. + +```bash +❯ lux blink green -d 2a0f2c73b72 +``` + +The following shows the command line tool turning the light off. + +```bash +❯ lux -vvv off -d 2a0f2c73b72 + INFO luxafor > Setting the color of device '2a0f2c73b72e' to 000000 + INFO luxafor > call successful +``` + +*/ + +#![warn( + // ---------- Stylistic + future_incompatible, + nonstandard_style, + rust_2018_idioms, + trivial_casts, + trivial_numeric_casts, + // ---------- Public + missing_debug_implementations, + //missing_docs, + unreachable_pub, + // ---------- Unsafe + unsafe_code, + // ---------- Unused + unused_extern_crates, + unused_import_braces, + unused_qualifications, + unused_results, +)] + +#[macro_use] +extern crate error_chain; + +#[macro_use] +extern crate log; + +use reqwest::blocking::Client; +use std::fmt::{Display, Formatter}; +use std::str::FromStr; + +// ------------------------------------------------------------------------------------------------ +// Public Types +// ------------------------------------------------------------------------------------------------ + +/// +/// This wraps a simple string and ensures it only contains valid characters. +/// +#[derive(Clone, Debug)] +pub struct DeviceID(String); + +/// +/// A color that the light can be set to. +/// +#[derive(Clone, Debug)] +pub enum SolidColor { + /// A preset color + Red, + /// A preset color + Green, + /// A preset color + Yellow, + /// A preset color + Blue, + /// A preset color + White, + /// A preset color + Cyan, + /// A preset color + Magenta, + /// A custom color using standard RGB values + Custom { red: u8, green: u8, blue: u8 }, +} + +/// +/// A pattern the light can be set to show. +#[derive(Clone, Debug)] +pub enum Pattern { + /// A preset pattern + Police, + /// A preset pattern + TrafficLights, + /// A preset pattern + Random(u8), + /// A preset pattern (accepted on Windows only) + Rainbow, + /// A preset pattern (accepted on Windows only) + Sea, + /// A preset pattern (accepted on Windows only) + WhiteWave, + /// A preset pattern (accepted on Windows only) + Synthetic, +} + +// ------------------------------------------------------------------------------------------------ +// Private Types +// ------------------------------------------------------------------------------------------------ + +const API_V1: &str = "https://api.luxafor.com/webhook/v1/actions"; + +// ------------------------------------------------------------------------------------------------ +// Public Functions +// ------------------------------------------------------------------------------------------------ + +/// +/// Turn the light off. +/// +pub fn turn_off(device: DeviceID) -> error::Result<()> { + set_solid_color( + device, + SolidColor::Custom { + red: 00, + green: 00, + blue: 00, + }, + false, + ) +} + +/// +/// Set the color, and blink status, of the light. +/// +pub fn set_solid_color(device: DeviceID, color: SolidColor, blink: bool) -> error::Result<()> { + info!("Setting the color of device '{}' to {}", device, color); + + let body = if let SolidColor::Custom { + red: _, + green: _, + blue: _, + } = color + { + r#"{ + "userId": "DID", + "actionFields":{ + "color": "custom", + "custom_color": "COLOR" + } +}"# + .replace("DID", &device.to_string()) + .replace("COLOR", &color.to_string()) + } else { + r#"{ + "userId": "DID", + "actionFields":{ + "color": "COLOR" + } +}"# + .replace("DID", &device.to_string()) + .replace("COLOR", &color.to_string()) + }; + + let url = &format!("{}/{}", API_V1, if blink { "blink" } else { "solid_color" }); + + send_request(url, body) +} + +/// +/// Set the pattern displayed by the light. +/// +pub fn set_pattern(device: DeviceID, pattern: Pattern) -> error::Result<()> { + info!("Setting the pattern of device '{}' to {}", device, pattern); + + let body = r#"{ + "userId": "DID", + "actionFields":{ + "pattern": "PATTERN" + } +}"# + .replace("DID", &device.to_string()) + .replace("PATTERN", &pattern.to_string()); + + let url = &format!("{}/{}", API_V1, "pattern"); + + send_request(url, body) +} + +// ------------------------------------------------------------------------------------------------ +// Implementations +// ------------------------------------------------------------------------------------------------ + +impl Display for DeviceID { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for DeviceID { + type Err = error::Error; + + fn from_str(s: &str) -> Result { + if !s.is_empty() && s.chars().all(|c| c.is_ascii_hexdigit()) { + Ok(Self(s.to_string())) + } else { + Err(error::ErrorKind::InvalidDeviceID.into()) + } + } +} + +// ------------------------------------------------------------------------------------------------ + +impl Display for SolidColor { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + fn to_hex(v: &u8) -> String { + format!("{:#04x}", v)[2..].to_string() + } + write!( + f, + "{}", + match self { + SolidColor::Red => "red".to_string(), + SolidColor::Green => "green".to_string(), + SolidColor::Yellow => "yellow".to_string(), + SolidColor::Blue => "blue".to_string(), + SolidColor::White => "white".to_string(), + SolidColor::Cyan => "cyan".to_string(), + SolidColor::Magenta => "magenta".to_string(), + SolidColor::Custom { red, green, blue } => + format!("{}{}{}", to_hex(red), to_hex(green), to_hex(blue)), + } + ) + } +} + +impl FromStr for SolidColor { + type Err = error::Error; + + fn from_str(s: &str) -> Result { + let s = s.to_lowercase(); + match s.as_str() { + "red" => Ok(SolidColor::Red), + "green" => Ok(SolidColor::Green), + "yellow" => Ok(SolidColor::Yellow), + "blue" => Ok(SolidColor::Blue), + "white" => Ok(SolidColor::White), + "cyan" => Ok(SolidColor::Cyan), + "magenta" => Ok(SolidColor::Magenta), + _ => { + if s.len() == 6 && s.chars().all(|c| c.is_ascii_hexdigit()) { + Ok(SolidColor::Custom { + red: u8::from_str_radix(&s[0..1], 16)?, + green: u8::from_str_radix(&s[2..3], 16)?, + blue: u8::from_str_radix(&s[4..5], 16)?, + }) + } else { + Err(error::ErrorKind::InvalidColor.into()) + } + } + } + } +} + +// ------------------------------------------------------------------------------------------------ + +impl Display for Pattern { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Pattern::Police => "police".to_string(), + Pattern::TrafficLights => "traffic lights".to_string(), + Pattern::Random(n) => format!("random {}", n), + Pattern::Rainbow => "rainbow".to_string(), + Pattern::Sea => "sea".to_string(), + Pattern::WhiteWave => "white wave".to_string(), + Pattern::Synthetic => "synthetic".to_string(), + } + ) + } +} + +impl FromStr for Pattern { + type Err = error::Error; + + fn from_str(s: &str) -> Result { + let s = s.to_lowercase(); + match s.as_str() { + "police" => Ok(Pattern::Police), + "traffic lights" => Ok(Pattern::TrafficLights), + "random 1" => Ok(Pattern::Random(1)), + "random 2" => Ok(Pattern::Random(2)), + "random 3" => Ok(Pattern::Random(3)), + "random 4" => Ok(Pattern::Random(4)), + "random 5" => Ok(Pattern::Random(5)), + "sea" => Ok(Pattern::Sea), + "white wave" => Ok(Pattern::WhiteWave), + "synthetic" => Ok(Pattern::Synthetic), + _ => Err(error::ErrorKind::InvalidPattern.into()), + } + } +} + +// ------------------------------------------------------------------------------------------------ +// Private Functions +// ------------------------------------------------------------------------------------------------ + +fn send_request(api: &str, body: String) -> error::Result<()> { + debug!("Sending to: {}", api); + debug!("Sending data: {:?}", body); + + let client = Client::new(); + let result = client + .post(api) + .header("Content-Type", "application/json") + .body(body) + .send()?; + + if result.status().is_success() { + info!("call successful"); + Ok(()) + } else { + let status_code = result.status().as_u16(); + error!("call failed"); + error!("{:?}", result.text()); + Err(error::ErrorKind::UnexpectedError(status_code).into()) + } +} + +// ------------------------------------------------------------------------------------------------ +// Modules +// ------------------------------------------------------------------------------------------------ + +mod error { + error_chain! { + errors { + #[doc("The color value supplied was not recognized")] + InvalidColor { + description("The color value supplied was not recognized") + display("The color value supplied was not recognized") + } + #[doc("The pattern value supplied was not recognized")] + InvalidPattern { + description("The pattern value supplied was not recognized") + display("The pattern value supplied was not recognized") + } + #[doc("The provided device ID was incorrectly formatted")] + InvalidDeviceID { + description("The provided device ID was incorrectly formatted") + display("The provided device ID was incorrectly formatted") + } + #[doc("The server indicated an invalid request")] + InvalidRequest { + description("The server indicated an invalid request") + display("The server indicated an invalid request") + } + #[doc("An unexpected HTTP error was returned")] + UnexpectedError(sc: u16) { + description("An unexpected HTTP error was returned") + display("An unexpected HTTP error was returned: {}", sc) + } + } + foreign_links { + CustomFmt(::std::num::ParseIntError); + Request(::reqwest::Error); + Fmt(::std::fmt::Error); + } + } +}