Refactored to support multiple Device implementations.

This commit is contained in:
Simon Johnston 2020-08-18 11:59:11 -07:00
parent 6b5fb893ef
commit 2b64e1f655
6 changed files with 500 additions and 145 deletions

View File

@ -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 <johnstonskj@gmail.com>"]
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 }

View File

@ -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
* The webhook API is not as rich as the USB, need to find a way to manage this.

View File

@ -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<dyn Error>> {
})
.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<dyn Error>> {
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(())

View File

@ -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<Self, Self::Err> {
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;

230
src/usb_hid.rs Normal file
View File

@ -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<Self> {
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<USBDevice<'_>> {
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<USBDevice<'a>> {
let id = USBDeviceID(format!(
"{}::{}::{}",
hid_device
.get_manufacturer_string()
.unwrap_or("<unknown>".to_string()),
hid_device
.get_product_string()
.unwrap_or("<unknown>".to_string()),
hid_device
.get_serial_number_string()
.unwrap_or("<unknown>".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());
}
}

165
src/webhook.rs Normal file
View File

@ -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<impl Device> {
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<Self, Self::Err> {
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())
}
}