From 2b64e1f655c4d040b74450a6924716bced420dbe Mon Sep 17 00:00:00 2001 From: Simon Johnston Date: Tue, 18 Aug 2020 11:59:11 -0700 Subject: [PATCH] Refactored to support multiple `Device` implementations. --- Cargo.toml | 12 ++- README.md | 17 +++- src/bin/main.rs | 27 ++++-- src/lib.rs | 194 +++++++++++++--------------------------- src/usb_hid.rs | 230 ++++++++++++++++++++++++++++++++++++++++++++++++ src/webhook.rs | 165 ++++++++++++++++++++++++++++++++++ 6 files changed, 500 insertions(+), 145 deletions(-) create mode 100644 src/usb_hid.rs create mode 100644 src/webhook.rs diff --git a/Cargo.toml b/Cargo.toml index 0b71382..7c4516a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "luxafor" description = "Library, and CLI, for Luxafor lights via webhooks." -version = "0.1.0" +version = "0.2.0" authors = ["Simon Johnston "] edition = "2018" license = "MIT" @@ -14,7 +14,10 @@ targets = ["x86_64-unknown-linux-gnu"] all-features = true [features] -command-line = ["pretty_env_logger", "structopt"] +default = ["webhook"] +usb = ["hidapi"] +webhook = ["reqwest"] +command-line = ["pretty_env_logger", "structopt", "usb", "webhook"] [[bin]] name = "lux" @@ -24,8 +27,9 @@ required-features = ["command-line"] [dependencies] log = "0.4.11" error-chain = "0.12.2" -reqwest = { version = "0.10", features = ["blocking", "json"] } #[feature-dependencies] +hidapi = { version = "~0.4", optional = true } +pretty_env_logger = { version = "0.4.0", optional = true } +reqwest = { version = "0.10", features = ["blocking"], optional = true } 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/README.md b/README.md index 8b3d4c2..5a9c6b9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Crate luxafor -Library, and CLI, for [Luxafor](https://luxafor.com/products/) lights via webhooks. +Library, and CLI, for [Luxafor](https://luxafor.com/products/) lights via webhooks or USB. ![Rust](https://github.com/johnstonskj/rust-luxafor/workflows/Rust/badge.svg) ![Minimum Rust Version](https://img.shields.io/badge/Min%20Rust-1.40-green.svg) @@ -12,7 +12,7 @@ Library, and CLI, for [Luxafor](https://luxafor.com/products/) lights via webhoo 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 +## Examples The following shows the command line tool setting the color to red. @@ -36,9 +36,20 @@ The following shows the command line tool turning the light off. INFO luxafor > call successful ``` +## Features + +* **command-line**; provides the command line tool `lux`, it is not on by default for library clients. +* **usb**; provides access to USB connected devices. +* **webhook** (default); provides access to USB, or Bluetooth, devices via webhooks. ## Changes +**Version 0.2.0** + +* Refactored to provide a new `Device` trait +* Implemented the trait for webhook connected lights +* Added a new implementation for HID connected lights + **Version 0.1.0** * Initial commit, supports flag and bluetooth lights. @@ -46,4 +57,4 @@ The following shows the command line tool turning the light off. ## TODO -TBD \ No newline at end of file +* The webhook API is not as rich as the USB, need to find a way to manage this. \ No newline at end of file diff --git a/src/bin/main.rs b/src/bin/main.rs index 016aec6..b7b486b 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -1,7 +1,9 @@ +#[allow(unused_imports)] #[macro_use] extern crate log; -use luxafor::{set_pattern, set_solid_color, turn_off, DeviceID, Pattern, SolidColor}; +use luxafor::usb_hid::USBDeviceDiscovery; +use luxafor::{webhook, Device, Pattern, SolidColor}; use std::error::Error; use structopt::StructOpt; @@ -14,7 +16,7 @@ pub(crate) struct CommandLine { /// The device identifier #[structopt(long, short, env = "LUX_DEVICE")] - device: DeviceID, + device: String, #[structopt(subcommand)] cmd: SubCommand, @@ -37,7 +39,6 @@ pub(crate) enum SubCommand { /// Set the light to a to a pre-defined pattern Pattern { /// The pattern to set - #[structopt(long, short)] pattern: Pattern, }, /// Turn the light off @@ -58,11 +59,23 @@ fn main() -> Result<(), Box> { }) .init(); + if args.device == "usb" { + let discovery = USBDeviceDiscovery::new()?; + let device = discovery.device()?; + debug!("USB device: '{}'", device.id()); + set_lights(args, device) + } else { + let device_id = args.device.clone(); + set_lights(args, webhook::new_device_for(&device_id)?) + } +} + +fn set_lights(args: CommandLine, device: impl Device) -> Result<(), Box> { match args.cmd { - SubCommand::Solid { color } => set_solid_color(args.device, color, false), - SubCommand::Blink { color } => set_solid_color(args.device, color, true), - SubCommand::Pattern { pattern } => set_pattern(args.device, pattern), - SubCommand::Off => turn_off(args.device), + SubCommand::Solid { color } => device.set_solid_color(color, false), + SubCommand::Blink { color } => device.set_solid_color(color, true), + SubCommand::Pattern { pattern } => device.set_pattern(pattern), + SubCommand::Off => device.turn_off(), }?; Ok(()) diff --git a/src/lib.rs b/src/lib.rs index 99d696f..bbdddae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,7 @@ /*! -Library, and CLI, for [Luxafor](https://luxafor.com/products/) lights via webhooks. This has been +Library, and CLI, for [Luxafor](https://luxafor.com/products/) lights via webhooks or USB. + +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. @@ -27,6 +29,18 @@ The following shows the command line tool turning the light off. INFO luxafor > call successful ``` +The following shows the how to set USB connected lights. + +```bash +❯ lux -d usb solid red +``` + +# Features + +* **command-line**; provides the command line tool `lux`, it is not on by default for library clients. +* **usb**; provides access to USB connected devices. +* **webhook** (default); provides access to USB, or Bluetooth, devices via webhooks. + */ #![warn( @@ -38,7 +52,7 @@ The following shows the command line tool turning the light off. trivial_numeric_casts, // ---------- Public missing_debug_implementations, - //missing_docs, + missing_docs, unreachable_pub, // ---------- Unsafe unsafe_code, @@ -52,10 +66,10 @@ The following shows the command line tool turning the light off. #[macro_use] extern crate error_chain; +#[allow(unused_imports)] #[macro_use] extern crate log; -use reqwest::blocking::Client; use std::fmt::{Display, Formatter}; use std::str::FromStr; @@ -63,12 +77,6 @@ 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. /// @@ -89,7 +97,14 @@ pub enum SolidColor { /// A preset color Magenta, /// A custom color using standard RGB values - Custom { red: u8, green: u8, blue: u8 }, + Custom { + /// The _red_ channel + red: u8, + /// The _green_ channel + green: u8, + /// The _blue_ channel + blue: u8, + }, } /// @@ -112,112 +127,40 @@ pub enum Pattern { Synthetic, } -// ------------------------------------------------------------------------------------------------ -// Private Types -// ------------------------------------------------------------------------------------------------ - -const API_V1: &str = "https://api.luxafor.com/webhook/v1/actions"; - -// ------------------------------------------------------------------------------------------------ -// Public Functions -// ------------------------------------------------------------------------------------------------ +/// +/// Trait describing a device identifier, basically you just need to be able to `to_string()` it. +/// +pub trait DeviceIdentifier: Display {} /// -/// Turn the light off. +/// A trait implemented by different access methods to control a light. /// -pub fn turn_off(device: DeviceID) -> error::Result<()> { - set_solid_color( - device, - SolidColor::Custom { - red: 00, - green: 00, - blue: 00, - }, - false, - ) -} +pub trait Device { + /// + /// Return the identifier for the device. + /// + fn id(&self) -> &dyn DeviceIdentifier; -/// -/// 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); + /// + /// Turn the light off. + /// + fn turn_off(&self) -> error::Result<()>; - 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()) - }; + /// + /// Set the color, and blink status, of the light. + /// + fn set_solid_color(&self, color: SolidColor, blink: bool) -> error::Result<()>; - 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) + /// + /// Set the pattern displayed by the light. + /// + fn set_pattern(&self, pattern: Pattern) -> error::Result<()>; } // ------------------------------------------------------------------------------------------------ // 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 { @@ -310,37 +253,15 @@ impl FromStr for Pattern { } } -// ------------------------------------------------------------------------------------------------ -// 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 handling types. +/// +#[allow(missing_docs)] +pub mod error { error_chain! { errors { #[doc("The color value supplied was not recognized")] @@ -358,6 +279,11 @@ mod error { description("The provided device ID was incorrectly formatted") display("The provided device ID was incorrectly formatted") } + #[doc("No device was discovered, or the ID did not resolve to a device")] + DeviceNotFound { + description("No device was discovered, or the ID did not resolve to a device") + display("No device was discovered, or the ID did not resolve to a device") + } #[doc("The server indicated an invalid request")] InvalidRequest { description("The server indicated an invalid request") @@ -376,3 +302,9 @@ mod error { } } } + +#[cfg(feature = "usb")] +pub mod usb_hid; + +#[cfg(feature = "webhook")] +pub mod webhook; diff --git a/src/usb_hid.rs b/src/usb_hid.rs new file mode 100644 index 0000000..66c055a --- /dev/null +++ b/src/usb_hid.rs @@ -0,0 +1,230 @@ +/*! +Implementation of the Device trait for USB connected lights. +*/ + +use crate::{Device, DeviceIdentifier, Pattern, SolidColor}; +use hidapi::{HidApi, HidDevice}; +use std::fmt::{Display, Formatter}; + +// ------------------------------------------------------------------------------------------------ +// Public Types +// ------------------------------------------------------------------------------------------------ + +/// +/// This enables the discovery of the device using the USB HID descriptor. +/// +#[allow(missing_debug_implementations)] +pub struct USBDeviceDiscovery { + hid_api: HidApi, +} + +/// +/// The device identifier for a USB connected light. +/// +#[derive(Clone, Debug)] +pub struct USBDeviceID(String); + +/// +/// The device implementation for a USB connected light. +/// +#[allow(missing_debug_implementations)] +pub struct USBDevice<'a> { + hid_device: HidDevice<'a>, + id: USBDeviceID, +} + +// ------------------------------------------------------------------------------------------------ +// API Constants +// ------------------------------------------------------------------------------------------------ + +const LUXAFOR_VENDOR_ID: u16 = 0x04d8; +const LUXAFOR_PRODUCT_ID: u16 = 0xf372; + +const MODE_SOLID: u8 = 1; +#[allow(dead_code)] +const MODE_FADE: u8 = 2; +const MODE_STROBE: u8 = 3; +#[allow(dead_code)] +const MODE_WAVE: u8 = 4; +const MODE_PATTERN: u8 = 6; + +#[allow(dead_code)] +const LED_FRONT_TOP: u8 = 1; +#[allow(dead_code)] +const LED_FRONT_MIDDLE: u8 = 2; +#[allow(dead_code)] +const LED_FRONT_BOTTOM: u8 = 3; +#[allow(dead_code)] +const LED_BACK_TOP: u8 = 4; +#[allow(dead_code)] +const LED_BACK_MIDDLE: u8 = 5; +#[allow(dead_code)] +const LED_BACK_BOTTOM: u8 = 6; +#[allow(dead_code)] +const LED_FRONT_ALL: u8 = 65; +#[allow(dead_code)] +const LED_BACK_ALL: u8 = 66; +const LED_ALL: u8 = 255; + +const PATTERN_LUXAFOR: u8 = 1; +const PATTERN_RANDOM_1: u8 = 2; +const PATTERN_RANDOM_2: u8 = 3; +const PATTERN_RANDOM_3: u8 = 4; +const PATTERN_RANDOM_4: u8 = 6; +const PATTERN_RANDOM_5: u8 = 7; +const PATTERN_POLICE: u8 = 5; +const PATTERN_RAINBOW_WAVE: u8 = 8; + +// ------------------------------------------------------------------------------------------------ +// Public Functions +// ------------------------------------------------------------------------------------------------ + +// ------------------------------------------------------------------------------------------------ +// Implementations +// ------------------------------------------------------------------------------------------------ + +impl Display for USBDeviceID { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl DeviceIdentifier for USBDeviceID {} + +// ------------------------------------------------------------------------------------------------ + +impl USBDeviceDiscovery { + /// + /// Construct a new discovery object, this initializes the USB HID interface and thus can fail. + /// + pub fn new() -> crate::error::Result { + let hid_api = HidApi::new()?; + Ok(Self { hid_api }) + } + + /// + /// Return a device, if found, that corresponds to a Luxafor light. + /// + pub fn device(&self) -> crate::error::Result> { + let result = self.hid_api.open(LUXAFOR_VENDOR_ID, LUXAFOR_PRODUCT_ID); + match result { + Ok(hid_device) => USBDevice::new(hid_device), + Err(err) => { + error!("Could not open HID device: {:?}", err); + Err(crate::error::ErrorKind::DeviceNotFound.into()) + } + } + } +} + +// ------------------------------------------------------------------------------------------------ + +impl<'a> Device for USBDevice<'a> { + fn id(&self) -> &dyn DeviceIdentifier { + &self.id + } + + fn turn_off(&self) -> crate::error::Result<()> { + self.set_solid_color( + SolidColor::Custom { + red: 00, + green: 00, + blue: 00, + }, + false, + ) + } + + fn set_solid_color(&self, color: SolidColor, blink: bool) -> crate::error::Result<()> { + info!("Setting the color of device '{}' to {}", self.id, color); + let (r, g, b) = match color { + SolidColor::Red => (255, 0, 0), + SolidColor::Green => (0, 255, 0), + SolidColor::Yellow => (255, 255, 0), + SolidColor::Blue => (0, 0, 255), + SolidColor::White => (255, 255, 255), + SolidColor::Cyan => (0, 255, 255), + SolidColor::Magenta => (255, 0, 255), + SolidColor::Custom { red, green, blue } => (red, green, blue), + }; + let mode = if blink { MODE_STROBE } else { MODE_SOLID }; + trace!("{} ({:#04x},{:#04x},{:#04x})", mode, r, g, b); + let result = self.hid_device.write(&[mode, LED_ALL, r, g, b]); + match result { + Ok(_) => Ok(()), + Err(err) => { + error!("Could not write to HID device: {:?}", err); + Err(crate::error::ErrorKind::InvalidRequest.into()) + } + } + } + + fn set_pattern(&self, pattern: Pattern) -> crate::error::Result<()> { + info!("Setting the pattern of device '{}' to {}", self.id, pattern); + let pattern = match pattern { + Pattern::Police => PATTERN_POLICE, + Pattern::TrafficLights => PATTERN_LUXAFOR, + Pattern::Random(n) => match n { + 1 => PATTERN_RANDOM_1, + 2 => PATTERN_RANDOM_2, + 3 => PATTERN_RANDOM_3, + 4 => PATTERN_RANDOM_4, + _ => PATTERN_RANDOM_5, + }, + Pattern::Rainbow => PATTERN_RAINBOW_WAVE, + Pattern::Sea => 9, + Pattern::WhiteWave => 10, + Pattern::Synthetic => 11, + }; + let result = self.hid_device.write(&[MODE_PATTERN, LED_ALL, pattern]); + match result { + Ok(_) => Ok(()), + Err(err) => { + error!("Could not write to HID device: {:?}", err); + Err(crate::error::ErrorKind::InvalidRequest.into()) + } + } + } +} + +impl<'a> USBDevice<'a> { + fn new(hid_device: HidDevice<'a>) -> crate::error::Result> { + let id = USBDeviceID(format!( + "{}::{}::{}", + hid_device + .get_manufacturer_string() + .unwrap_or("".to_string()), + hid_device + .get_product_string() + .unwrap_or("".to_string()), + hid_device + .get_serial_number_string() + .unwrap_or("".to_string()) + )); + Ok(Self { hid_device, id }) + } +} + +// ------------------------------------------------------------------------------------------------ +// Unit Tests +// ------------------------------------------------------------------------------------------------ + +#[cfg(test)] +mod tests { + use crate::{Device, SolidColor}; + + #[test] + fn test_discovery() { + let result = super::USBDeviceDiscovery::new(); + assert!(result.is_ok()); + let discovery = result.unwrap(); + + let result = discovery.device(); + assert!(result.is_ok()); + let device = result.unwrap(); + println!("{}", device.id()); + + let result = device.set_solid_color(SolidColor::Green, false); + assert!(result.is_ok()); + } +} diff --git a/src/webhook.rs b/src/webhook.rs new file mode 100644 index 0000000..5443eee --- /dev/null +++ b/src/webhook.rs @@ -0,0 +1,165 @@ +/*! +Implementation of the Device trait for webhook connected lights. + +*/ + +use crate::{Device, DeviceIdentifier, Pattern, SolidColor}; +use reqwest::blocking::Client; +use std::fmt::{Display, Formatter}; +use std::str::FromStr; + +// ------------------------------------------------------------------------------------------------ +// Public Types +// ------------------------------------------------------------------------------------------------ + +/// +/// The device identifier for a webhook connected light. +/// +#[derive(Clone, Debug)] +pub struct WebhookDeviceID(String); + +/// +/// The device implementation for a webhook connected light. +/// +#[derive(Clone, Debug)] +pub struct WebhookDevice { + id: WebhookDeviceID, +} + +// ------------------------------------------------------------------------------------------------ +// Public Functions +// ------------------------------------------------------------------------------------------------ + +/// +/// Return a device implementation for a webhook connected light. +/// +pub fn new_device_for(device_id: &str) -> crate::error::Result { + let device_id = WebhookDeviceID::from_str(device_id)?; + Ok(WebhookDevice { id: device_id }) +} + +// ------------------------------------------------------------------------------------------------ +// Private Types +// ------------------------------------------------------------------------------------------------ + +const API_V1: &str = "https://api.luxafor.com/webhook/v1/actions"; + +// ------------------------------------------------------------------------------------------------ +// Implementations +// ------------------------------------------------------------------------------------------------ + +impl Display for WebhookDeviceID { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl DeviceIdentifier for WebhookDeviceID {} + +impl FromStr for WebhookDeviceID { + type Err = crate::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(crate::error::ErrorKind::InvalidDeviceID.into()) + } + } +} + +// ------------------------------------------------------------------------------------------------ + +impl Device for WebhookDevice { + fn id(&self) -> &dyn DeviceIdentifier { + &self.id + } + + fn turn_off(&self) -> crate::error::Result<()> { + self.set_solid_color( + SolidColor::Custom { + red: 00, + green: 00, + blue: 00, + }, + false, + ) + } + + fn set_solid_color(&self, color: SolidColor, blink: bool) -> crate::error::Result<()> { + info!("Setting the color of device '{}' to {}", self.id, color); + + let body = if let SolidColor::Custom { + red: _, + green: _, + blue: _, + } = color + { + r#"{ + "userId": "DID", + "actionFields":{ + "color": "custom", + "custom_color": "COLOR" + } +}"# + .replace("DID", &self.id.to_string()) + .replace("COLOR", &color.to_string()) + } else { + r#"{ + "userId": "DID", + "actionFields":{ + "color": "COLOR" + } +}"# + .replace("DID", &self.id.to_string()) + .replace("COLOR", &color.to_string()) + }; + + let url = &format!("{}/{}", API_V1, if blink { "blink" } else { "solid_color" }); + + send_request(url, body) + } + + fn set_pattern(&self, pattern: Pattern) -> crate::error::Result<()> { + info!("Setting the pattern of device '{}' to {}", self.id, pattern); + + let body = r#"{ + "userId": "DID", + "actionFields":{ + "pattern": "PATTERN" + } +}"# + .replace("DID", &self.id.to_string()) + .replace("PATTERN", &pattern.to_string()); + + let url = &format!("{}/{}", API_V1, "pattern"); + + send_request(url, body) + } +} + +// ------------------------------------------------------------------------------------------------ +// Private Functions +// ------------------------------------------------------------------------------------------------ + +fn send_request(api: &str, body: String) -> crate::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(crate::error::ErrorKind::UnexpectedError(status_code).into()) + } +}