Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ Features:
- [x] Stream Deck Original (untested)
- [x] Stream Deck Original V2
- [x] Stream Deck XL
- [x] Stream Deck Module 6Keys
- [x] Stream Deck Module 15Keys
- [x] Stream Deck Module 32Keys (untested)


## Getting started
Expand All @@ -50,8 +53,8 @@ Building requires `libusb` and `hidapi` packages.
`streamdeck-cli --help` displays available subcommands and options, passing `--help` to subcommands (ie. `streamdeck set-image --help`) displays options for that subcommand

```
streamdeck-cli 0.4.1
A CLI for the Elgato StreamDeck
streamdeck-cli 0.9.0
Helper object for filtering device connections

USAGE:
streamdeck-cli [OPTIONS] <SUBCOMMAND>
Expand All @@ -69,10 +72,11 @@ OPTIONS:
SUBCOMMANDS:
get-buttons Fetch button states
help Prints this message or the help of the given subcommand(s)
probe Search for connected streamdecks
reset Reset the attached device
set-brightness Set device display brightness
set-colour Set button colours
set-image Set button images
set-colour Simple Colour object for re-writing backgrounds etc
set-image Options for image loading and editing
version Fetch the device firmware version

```
Expand Down
4 changes: 2 additions & 2 deletions src/images.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::str::FromStr;

use image::codecs::jpeg::JpegEncoder;
use image::io::Reader;
use image::ImageReader;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use image::{imageops::FilterType, Pixel, Rgba};
use image::{DynamicImage, ExtendedColorType};

Expand Down Expand Up @@ -102,7 +102,7 @@ pub(crate) fn load_image(
colour_order: ColourOrder,
) -> Result<Vec<u8>, Error> {
// Open image reader
let reader = match Reader::open(path) {
let reader = match ImageReader::open(path) {
Ok(v) => v,
Err(e) => {
error!("error loading file '{}': {:?}", path, e);
Expand Down
49 changes: 33 additions & 16 deletions src/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ pub enum Kind {
Xl,
Mk2,
Plus,
Module6Keys,
Module15Keys,
Module32Keys,
}

/// Stream Deck key layout direction
Expand Down Expand Up @@ -56,6 +59,9 @@ impl Kind {
Kind::Mini | Kind::RevisedMini => 6,
Kind::Xl => 32,
Kind::Plus => 8,
Kind::Module6Keys => 6,
Kind::Module15Keys => 15,
Kind::Module32Keys => 32,
}
}

Expand All @@ -67,6 +73,9 @@ impl Kind {
Kind::Mini | Kind::RevisedMini => 0,
Kind::Xl => 3,
Kind::Plus => 3,
Kind::Module6Keys => 1,
Kind::Module15Keys => 3,
Kind::Module32Keys => 3,
}
}

Expand All @@ -86,40 +95,41 @@ impl Kind {

pub(crate) fn key_columns(&self) -> u8 {
match self {
Kind::Mini | Kind::RevisedMini => 3,
Kind::Original | Kind::OriginalV2 | Kind::Mk2 => 5,
Kind::Xl => 8,
Kind::Mini | Kind::RevisedMini | Kind::Module6Keys => 3,
Kind::Original | Kind::OriginalV2 | Kind::Mk2 | Kind::Module15Keys => 5,
Kind::Xl | Kind::Module32Keys => 8,
Kind::Plus => 4,
}
}

pub fn image_mode(&self) -> ImageMode {
match self {
Kind::Original | Kind::Mini | Kind::RevisedMini => ImageMode::Bmp,
Kind::OriginalV2 | Kind::Xl | Kind::Mk2 | Kind::Plus => ImageMode::Jpeg,
Kind::Original | Kind::Mini | Kind::RevisedMini | Kind::Module6Keys => ImageMode::Bmp,
Kind::OriginalV2 | Kind::Xl | Kind::Mk2 | Kind::Plus | Kind::Module15Keys | Kind::Module32Keys => ImageMode::Jpeg,
}
}

pub fn image_size(&self) -> (usize, usize) {
match self {
Kind::Original | Kind::OriginalV2 | Kind::Mk2 => (72, 72),
Kind::Mini | Kind::RevisedMini => (80, 80),
Kind::Xl => (96, 96),
Kind::Original | Kind::OriginalV2 | Kind::Mk2 | Kind::Module15Keys => (72, 72),
Kind::Mini | Kind::RevisedMini | Kind::Module6Keys => (80, 80),
Kind::Xl | Kind::Module32Keys => (96, 96),
Kind::Plus => (120, 120),
}
}

pub fn image_rotation(&self) -> Rotation {
match self {
Kind::Mini | Kind::RevisedMini => Rotation::Rot270,
Kind::Mini | Kind::RevisedMini | Kind::Module6Keys => Rotation::Rot270,
Kind::Module15Keys | Kind::Module32Keys => Rotation::Rot180,
_ => Rotation::Rot0,
}
}

pub fn image_mirror(&self) -> Mirroring {
match self {
// Mini has rotation, not mirror
Kind::Mini | Kind::RevisedMini | Kind::Plus => Mirroring::None,
Kind::Mini | Kind::RevisedMini | Kind::Plus | Kind::Module6Keys | Kind::Module15Keys | Kind::Module32Keys => Mirroring::None,
// On the original the image is flipped across the Y axis
Kind::Original => Mirroring::Y,
// On the V2 devices, both X and Y need to flip
Expand All @@ -141,25 +151,25 @@ impl Kind {

pub(crate) fn image_report_header_len(&self) -> usize {
match self {
Kind::Original | Kind::Mini | Kind::RevisedMini => 16,
Kind::OriginalV2 | Kind::Xl | Kind::Mk2 | Kind::Plus => 8,
Kind::Original | Kind::Mini | Kind::RevisedMini | Kind::Module6Keys => 16,
Kind::OriginalV2 | Kind::Xl | Kind::Mk2 | Kind::Plus | Kind::Module15Keys | Kind::Module32Keys => 8
}
}

pub fn image_base(&self) -> &[u8] {
match self {
// BMP headers for the original and mini
Kind::Original => &ORIGINAL_IMAGE_BASE,
Kind::Mini | Kind::RevisedMini => &MINI_IMAGE_BASE,
Kind::Mini | Kind::RevisedMini | Kind::Module6Keys => &MINI_IMAGE_BASE,

Kind::OriginalV2 | Kind::Xl | Kind::Mk2 | Kind::Plus => &[],
Kind::OriginalV2 | Kind::Xl | Kind::Mk2 | Kind::Plus | Kind::Module15Keys | Kind::Module32Keys => &[],
}
}

pub(crate) fn image_colour_order(&self) -> ColourOrder {
match self {
Kind::Original | Kind::Mini | Kind::RevisedMini => ColourOrder::BGR,
Kind::OriginalV2 | Kind::Xl | Kind::Mk2 | Kind::Plus => ColourOrder::RGB,
Kind::Original | Kind::Mini | Kind::RevisedMini | Kind::Module6Keys => ColourOrder::BGR,
Kind::OriginalV2 | Kind::Xl | Kind::Mk2 | Kind::Plus | Kind::Module15Keys | Kind::Module32Keys => ColourOrder::RGB,
}
}

Expand All @@ -169,6 +179,13 @@ impl Kind {
_ => false,
}
}

pub(crate) fn is_module(&self) -> bool {
match self {
Kind::Module6Keys | Kind::Module15Keys | Kind::Module32Keys => true,
_ => false,
}
}
}

pub const ORIGINAL_IMAGE_BASE: [u8; 54] = [
Expand Down
158 changes: 121 additions & 37 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ pub mod pids {
pub const MK2: u16 = 0x0080;
pub const REVISED_MINI: u16 = 0x0090;
pub const PLUS: u16 = 0x0084;
pub const MODULE_6_KEYS: u16 = 0x00B8;
pub const MODULE_15_KEYS: u16 = 0x00B9;
pub const MODULE_32_KEYS: u16 = 0x00BA;
Comment on lines +99 to +101
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

impl StreamDeck {
Expand Down Expand Up @@ -124,6 +127,10 @@ impl StreamDeck {
pids::REVISED_MINI => Kind::RevisedMini,
pids::PLUS => Kind::Plus,

pids::MODULE_6_KEYS => Kind::Module6Keys,
pids::MODULE_15_KEYS => Kind::Module15Keys,
pids::MODULE_32_KEYS => Kind::Module32Keys,

_ => return Err(Error::UnrecognisedPID),
};

Expand Down Expand Up @@ -167,17 +174,41 @@ impl StreamDeck {

/// Fetch the device firmware version
pub fn version(&mut self) -> Result<String, Error> {
let mut buff = [0u8; 17];
buff[0] = if self.kind.is_v2() { 0x05 } else { 0x04 };
if self.kind().is_module() {
// Module devices
let mut buff = [0u8; 32];
buff[0] = if self.kind == Kind::Module6Keys {
0xA1 // 0xA0:LD / 0xA1:AP2(Primary Firmware) / 0xA2:AP1(Backup Firmware)
} else {
0x05 // 0x04:LD / 0x05:AP2(Primary Firmware) / 0x06:AP1(Backup Firmware)
};

let _s = self.device.get_feature_report(&mut buff)?;

let offset = 6;
Ok(std::str::from_utf8(&buff[offset..]).unwrap().to_string())
} else {
// Non-module devices
let mut buff = [0u8; 17];
buff[0] = if self.kind.is_v2() {
0x05
} else {
0x04
};

let _s = self.device.get_feature_report(&mut buff)?;
let _s = self.device.get_feature_report(&mut buff)?;

let offset = if self.kind.is_v2() { 6 } else { 5 };
Ok(std::str::from_utf8(&buff[offset..]).unwrap().to_string())
let offset = if self.kind.is_v2() { 6 } else { 5 };
Ok(std::str::from_utf8(&buff[offset..]).unwrap().to_string())
}
}

/// Reset the connected device
pub fn reset(&mut self) -> Result<(), Error> {
// Module devices does not support reset command
if self.kind().is_module() {
return Ok(());
}
Comment on lines +208 to +211
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let mut cmd = [0u8; 17];

if self.kind.is_v2() {
Expand All @@ -193,19 +224,32 @@ impl StreamDeck {

/// Set the device display brightness (in percent)
pub fn set_brightness(&mut self, brightness: u8) -> Result<(), Error> {
let mut cmd = [0u8; 17];

let brightness = brightness.min(100);
if self.kind().is_module() {
let mut cmd = [0u8; 32];
let brightness = brightness.min(100);

if self.kind == Kind::Module6Keys {
cmd[..6].copy_from_slice(&[0x05, 0x55, 0xAA, 0xD1, 0x01, brightness]);
} else {
cmd[..3].copy_from_slice(&[0x03, 0x08, brightness]);
}

if self.kind.is_v2() {
cmd[..3].copy_from_slice(&[0x03, 0x08, brightness]);
self.device.send_feature_report(&cmd)?;
return Ok(());
} else {
cmd[..6].copy_from_slice(&[0x05, 0x55, 0xaa, 0xd1, 0x01, brightness]);
}
let mut cmd = [0u8; 17];

self.device.send_feature_report(&cmd)?;
let brightness = brightness.min(100);

Ok(())
if self.kind.is_v2() {
cmd[..3].copy_from_slice(&[0x03, 0x08, brightness]);
} else {
cmd[..6].copy_from_slice(&[0x05, 0x55, 0xaa, 0xd1, 0x01, brightness]);
}

self.device.send_feature_report(&cmd)?;
return Ok(());
}
}

/// Set blocking mode
Expand Down Expand Up @@ -233,6 +277,9 @@ impl StreamDeck {
pids::ORIGINAL => Ok((Kind::Original, pids::ORIGINAL)),
pids::MINI => Ok((Kind::Mini, pids::MINI)),
pids::PLUS => Ok((Kind::Plus, pids::PLUS)),
pids::MODULE_6_KEYS => Ok((Kind::Module6Keys, pids::MODULE_6_KEYS)),
pids::MODULE_15_KEYS => Ok((Kind::Module15Keys, pids::MODULE_15_KEYS)),
pids::MODULE_32_KEYS => Ok((Kind::Module32Keys, pids::MODULE_32_KEYS)),
_ => Err(Error::UnrecognisedPID)
};
available_devices.push(deck);
Expand Down Expand Up @@ -310,30 +357,42 @@ impl StreamDeck {

/// Set a button to the provided RGB colour
pub fn set_button_rgb(&mut self, key: u8, colour: &Colour) -> Result<(), Error> {
let mut image = vec![0u8; self.kind.image_size_bytes()];
let colour_order = self.kind.image_colour_order();

for i in 0..image.len() {
match i % 3 {
0 => {
image[i] = match colour_order {
ColourOrder::BGR => colour.b,
ColourOrder::RGB => colour.r,
}
}
1 => image[i] = colour.g,
2 => {
image[i] = match colour_order {
ColourOrder::BGR => colour.r,
ColourOrder::RGB => colour.b,
}
match self.kind {
// Module 15/32Keys supports setting colour directly
Kind::Module15Keys | Kind::Module32Keys => {
let mut cmd = [0u8; 32];
cmd[..6].copy_from_slice(&[0x03, 0x06, key, colour.r, colour.g, colour.b]);
self.device.send_feature_report(&cmd)?;
Ok(())
}
// Other models
_ => {
let mut image = vec![0u8; self.kind.image_size_bytes()];
let colour_order = self.kind.image_colour_order();

for i in 0..image.len() {
match i % 3 {
0 => {
image[i] = match colour_order {
ColourOrder::BGR => colour.b,
ColourOrder::RGB => colour.r,
}
}
1 => image[i] = colour.g,
2 => {
image[i] = match colour_order {
ColourOrder::BGR => colour.r,
ColourOrder::RGB => colour.b,
}
}
_ => unreachable!(),
};
}
_ => unreachable!(),
};
}
self.write_button_image(key, &self.convert_image(image)?)?;
self.write_button_image(key, &self.convert_image(image)?)?;

Ok(())
Ok(())
}
}
}

/// Set a button to the provided image
Expand Down Expand Up @@ -498,7 +557,32 @@ impl StreamDeck {
is_last: bool,
payload_len: usize,
) {
if self.kind.is_v2() {
if self.kind.is_module() {
match self.kind {
// https://docs.elgato.com/streamdeck/hid/module-6/#upload-data-to-image-memory-bank
Kind::Module6Keys => {
buf[0] = 0x02; // ReportID
buf[1] = 0x01; // Command
buf[2] = sequence as u8; // Chunk Index
buf[3] = 0x00; // Reserved
buf[4] = if is_last { 0x01 } else { 0x00 }; // Show Image flag
buf[5] = key;
buf[6..10].copy_from_slice(&[0x00,0x00,0x00,0x00]);
}
// https://docs.elgato.com/streamdeck/hid/module-15_32#output-reports
// basically same as v2
Kind::Module15Keys | Kind::Module32Keys => {
buf[0] = 0x02; // Report ID
buf[1] = 0x07; // Command
buf[2] = key; // Key Index
buf[3] = if is_last { 1 } else { 0 }; // Transfer is Done flag (0x01 = last chunk)
buf[4..6].copy_from_slice(&(payload_len as u16).to_le_bytes()); // Chunk Contents Size
buf[6..8].copy_from_slice(&sequence.to_le_bytes()); // Chunk Index (zero-based)
}
_ => unreachable!(),
}
}
else if self.kind.is_v2() {
buf[0] = 0x02;
buf[1] = 0x07;
buf[2] = key;
Expand Down