commit
This commit is contained in:
parent
1904e3e96a
commit
b5a7ab3c6b
71 changed files with 921 additions and 1297 deletions
14
crates/loupedeck_serial/Cargo.toml
Normal file
14
crates/loupedeck_serial/Cargo.toml
Normal file
|
@ -0,0 +1,14 @@
|
|||
[package]
|
||||
name = "loupedeck_serial"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serialport = "4.3.0"
|
||||
enum-ordinalize = "4.3.0"
|
||||
enumset = "1.1.3"
|
||||
bytes = "1.5.0"
|
||||
thiserror = "1.0.52"
|
||||
rgb = "0.8.37"
|
||||
flume = "0.11.0"
|
||||
serde = { version = "1.0.195", features = ["derive"] }
|
271
crates/loupedeck_serial/src/characteristics.rs
Normal file
271
crates/loupedeck_serial/src/characteristics.rs
Normal file
|
@ -0,0 +1,271 @@
|
|||
use enum_ordinalize::Ordinalize;
|
||||
use enumset::{enum_set, EnumSet, EnumSetType};
|
||||
|
||||
use crate::util::Endianness;
|
||||
|
||||
#[derive(Debug, Ordinalize, EnumSetType)]
|
||||
#[repr(u8)]
|
||||
pub enum LoupedeckKnob {
|
||||
LeftTop = 0x01,
|
||||
LeftMiddle = 0x02,
|
||||
LeftBottom = 0x03,
|
||||
RightTop = 0x04,
|
||||
RightMiddle = 0x05,
|
||||
RightBottom = 0x06,
|
||||
}
|
||||
|
||||
impl LoupedeckKnob {
|
||||
fn is_left(&self) -> bool {
|
||||
matches!(self, LoupedeckKnob::LeftTop | LoupedeckKnob::LeftMiddle | LoupedeckKnob::LeftBottom)
|
||||
}
|
||||
|
||||
fn row(&self) -> u8 {
|
||||
match self {
|
||||
LoupedeckKnob::LeftTop | LoupedeckKnob::RightTop => 0,
|
||||
LoupedeckKnob::LeftMiddle | LoupedeckKnob::RightMiddle => 1,
|
||||
LoupedeckKnob::LeftBottom | LoupedeckKnob::RightBottom => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Ordinalize, EnumSetType)]
|
||||
#[repr(u8)]
|
||||
pub enum LoupedeckButton {
|
||||
N0 = 0x07,
|
||||
N1 = 0x08,
|
||||
N2 = 0x09,
|
||||
N3 = 0x0a,
|
||||
N4 = 0x0b,
|
||||
N5 = 0x0c,
|
||||
N6 = 0x0d,
|
||||
N7 = 0x0e,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct LoupedeckDeviceDisplayConfiguration {
|
||||
pub id: u8,
|
||||
pub dpi: f32,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
pub local_offset_x: u16,
|
||||
pub local_offset_y: u16,
|
||||
pub global_offset_x: u16,
|
||||
pub global_offset_y: u16,
|
||||
pub endianness: Endianness,
|
||||
}
|
||||
|
||||
impl Eq for LoupedeckDeviceDisplayConfiguration {
|
||||
fn assert_receiver_is_total_eq(&self) {}
|
||||
}
|
||||
|
||||
impl PartialEq for LoupedeckDeviceDisplayConfiguration {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
std::ptr::eq(self, other)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct LoupedeckDeviceKeyGridCharacteristics {
|
||||
pub rows: u8,
|
||||
pub columns: u8,
|
||||
pub display: LoupedeckDeviceDisplayConfiguration,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LoupedeckDisplayRect {
|
||||
pub x: u16,
|
||||
pub y: u16,
|
||||
pub w: u16,
|
||||
pub h: u16,
|
||||
}
|
||||
|
||||
impl LoupedeckDeviceKeyGridCharacteristics {
|
||||
pub fn key_size(&self) -> (u16, u16) {
|
||||
// Assuming the sizes are integers
|
||||
(self.display.width / self.columns as u16, self.display.height / self.rows as u16)
|
||||
}
|
||||
|
||||
pub fn get_key_at_local_coordinates(&self, x: u16, y: u16) -> Option<u8> {
|
||||
let (column_width, row_height) = self.key_size();
|
||||
|
||||
if x >= self.display.width || y >= self.display.height {
|
||||
return None;
|
||||
}
|
||||
|
||||
let column = (x / column_width) as u8;
|
||||
let row = (y / row_height) as u8;
|
||||
|
||||
Some(row * self.columns + column)
|
||||
}
|
||||
|
||||
pub fn get_key_at_global_coordinates(&self, x: u16, y: u16) -> Option<u8> {
|
||||
if x < self.display.global_offset_x || y < self.display.global_offset_y {
|
||||
return None;
|
||||
}
|
||||
|
||||
let local_x = x - self.display.global_offset_x;
|
||||
let local_y = y - self.display.global_offset_y;
|
||||
|
||||
self.get_key_at_local_coordinates(local_x, local_y)
|
||||
}
|
||||
|
||||
pub fn get_local_key_rect(&self, key_index: u8) -> Option<LoupedeckDisplayRect> {
|
||||
if key_index >= self.rows * self.columns {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (column_width, row_height) = self.key_size();
|
||||
let row = (key_index / self.columns) as u16;
|
||||
let column = (key_index % self.columns) as u16;
|
||||
|
||||
Some(LoupedeckDisplayRect {
|
||||
x: column * column_width,
|
||||
y: row * row_height,
|
||||
w: column_width,
|
||||
h: row_height,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct LoupedeckDeviceCharacteristics {
|
||||
pub vendor_id: u16,
|
||||
pub product_id: u16,
|
||||
pub name: &'static str,
|
||||
pub available_knobs: EnumSet<LoupedeckKnob>,
|
||||
pub available_buttons: EnumSet<LoupedeckButton>,
|
||||
pub key_grid: LoupedeckDeviceKeyGridCharacteristics,
|
||||
pub knob_displays: Option<(LoupedeckDeviceDisplayConfiguration, LoupedeckDeviceDisplayConfiguration)>,
|
||||
}
|
||||
|
||||
impl Eq for LoupedeckDeviceCharacteristics {
|
||||
fn assert_receiver_is_total_eq(&self) {}
|
||||
}
|
||||
|
||||
impl PartialEq for LoupedeckDeviceCharacteristics {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
std::ptr::eq(self, other)
|
||||
}
|
||||
}
|
||||
|
||||
impl LoupedeckDeviceCharacteristics {
|
||||
pub fn knob_rows(&self) -> u8 {
|
||||
self.available_knobs.iter().map(|k| k.row()).max().map(|v| v + 1).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn get_display_at_coordinates(&self, x: u16, y: u16) -> Option<&LoupedeckDeviceDisplayConfiguration> {
|
||||
let check = |display: &LoupedeckDeviceDisplayConfiguration| {
|
||||
x >= display.global_offset_x
|
||||
&& x <= display.global_offset_x + display.width
|
||||
&& y >= display.global_offset_y
|
||||
&& y <= display.global_offset_y + display.height
|
||||
};
|
||||
|
||||
if check(&self.key_grid.display) {
|
||||
Some(&self.key_grid.display)
|
||||
} else if let Some(knob_displays) = &self.knob_displays {
|
||||
if check(&knob_displays.0) {
|
||||
Some(&knob_displays.0)
|
||||
} else if check(&knob_displays.1) {
|
||||
Some(&knob_displays.1)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_display_and_rect_for_knob(&self, knob: LoupedeckKnob) -> Option<(&LoupedeckDeviceDisplayConfiguration, LoupedeckDisplayRect)> {
|
||||
if !self.available_knobs.contains(knob) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(knob_displays) = &self.knob_displays {
|
||||
let display = if knob.is_left() { &knob_displays.0 } else { &knob_displays.1 };
|
||||
let row = knob.row() as u16;
|
||||
let rows = self.knob_rows() as u16;
|
||||
|
||||
let row_height = display.height / rows;
|
||||
|
||||
let rect = LoupedeckDisplayRect {
|
||||
x: 0,
|
||||
y: row_height * row,
|
||||
w: display.width,
|
||||
h: row_height,
|
||||
};
|
||||
|
||||
Some((display, rect))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static LOUPEDECK_LIVE_CHARACTERISTIC: LoupedeckDeviceCharacteristics = LoupedeckDeviceCharacteristics {
|
||||
vendor_id: 0x2ec2,
|
||||
product_id: 0x0004,
|
||||
name: "Loupedeck Live",
|
||||
available_knobs: enum_set!(
|
||||
LoupedeckKnob::LeftTop
|
||||
| LoupedeckKnob::LeftMiddle
|
||||
| LoupedeckKnob::LeftBottom
|
||||
| LoupedeckKnob::RightTop
|
||||
| LoupedeckKnob::RightMiddle
|
||||
| LoupedeckKnob::RightBottom
|
||||
),
|
||||
available_buttons: enum_set!(
|
||||
LoupedeckButton::N0
|
||||
| LoupedeckButton::N1
|
||||
| LoupedeckButton::N2
|
||||
| LoupedeckButton::N3
|
||||
| LoupedeckButton::N4
|
||||
| LoupedeckButton::N5
|
||||
| LoupedeckButton::N6
|
||||
| LoupedeckButton::N7
|
||||
),
|
||||
key_grid: LoupedeckDeviceKeyGridCharacteristics {
|
||||
rows: 3,
|
||||
columns: 4,
|
||||
display: LoupedeckDeviceDisplayConfiguration {
|
||||
id: 0x4d,
|
||||
dpi: 142.875,
|
||||
width: 360,
|
||||
height: 270,
|
||||
local_offset_x: 60,
|
||||
local_offset_y: 0,
|
||||
global_offset_x: 60,
|
||||
global_offset_y: 0,
|
||||
endianness: Endianness::LittleEndian,
|
||||
},
|
||||
},
|
||||
knob_displays: Some((
|
||||
LoupedeckDeviceDisplayConfiguration {
|
||||
id: 0x4d,
|
||||
dpi: 142.875,
|
||||
width: 60,
|
||||
height: 270,
|
||||
local_offset_x: 0,
|
||||
local_offset_y: 0,
|
||||
global_offset_x: 0,
|
||||
global_offset_y: 0,
|
||||
endianness: Endianness::LittleEndian,
|
||||
},
|
||||
LoupedeckDeviceDisplayConfiguration {
|
||||
id: 0x4d,
|
||||
dpi: 142.875,
|
||||
width: 60,
|
||||
height: 270,
|
||||
local_offset_x: 420,
|
||||
local_offset_y: 0,
|
||||
global_offset_x: 420,
|
||||
global_offset_y: 0,
|
||||
endianness: Endianness::LittleEndian,
|
||||
},
|
||||
)),
|
||||
};
|
||||
|
||||
pub static CHARACTERISTICS: [&LoupedeckDeviceCharacteristics; 1] = [&LOUPEDECK_LIVE_CHARACTERISTIC];
|
65
crates/loupedeck_serial/src/commands.rs
Normal file
65
crates/loupedeck_serial/src/commands.rs
Normal file
|
@ -0,0 +1,65 @@
|
|||
use bytes::Bytes;
|
||||
use enum_ordinalize::Ordinalize;
|
||||
use rgb::RGB8;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::characteristics::LoupedeckButton;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Clone, Copy, Serialize, Deserialize, Ordinalize)]
|
||||
#[repr(u8)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum VibrationPattern {
|
||||
Short = 0x01,
|
||||
Medium = 0x0a,
|
||||
Long = 0x0f,
|
||||
Low = 0x31,
|
||||
ShortLow = 0x32,
|
||||
ShortLower = 0x33,
|
||||
Lower = 0x40,
|
||||
Lowest = 0x41,
|
||||
DescendSlow = 0x46,
|
||||
DescendMed = 0x47,
|
||||
DescendFast = 0x48,
|
||||
AscendSlow = 0x52,
|
||||
AscendMed = 0x53,
|
||||
AscendFast = 0x58,
|
||||
RevSlowest = 0x5e,
|
||||
RevSlow = 0x5f,
|
||||
RevMed = 0x60,
|
||||
RevFast = 0x61,
|
||||
RevFaster = 0x62,
|
||||
RevFastest = 0x63,
|
||||
RiseFall = 0x6a,
|
||||
Buzz = 0x70,
|
||||
Rumble5 = 0x77,
|
||||
Rumble4 = 0x78,
|
||||
Rumble3 = 0x79,
|
||||
Rumble2 = 0x7a,
|
||||
Rumble1 = 0x7b,
|
||||
VeryLong = 0x76,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum LoupedeckCommand {
|
||||
RequestSerialNumber,
|
||||
RequestFirmwareVersion,
|
||||
SetBrightness(f32),
|
||||
SetButtonColor {
|
||||
button: LoupedeckButton,
|
||||
color: RGB8,
|
||||
},
|
||||
ReplaceFramebufferArea {
|
||||
display_id: u8,
|
||||
x: u16,
|
||||
y: u16,
|
||||
width: u16,
|
||||
height: u16,
|
||||
buffer: Bytes,
|
||||
},
|
||||
RefreshDisplay {
|
||||
display_id: u8,
|
||||
},
|
||||
Vibrate {
|
||||
pattern: VibrationPattern,
|
||||
},
|
||||
}
|
345
crates/loupedeck_serial/src/device.rs
Normal file
345
crates/loupedeck_serial/src/device.rs
Normal file
|
@ -0,0 +1,345 @@
|
|||
use std::io::{Read, Write};
|
||||
use std::sync::mpsc;
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration;
|
||||
use std::{io, thread};
|
||||
|
||||
use bytes::Bytes;
|
||||
use rgb::{ComponentSlice, RGB8};
|
||||
use serialport::{ClearBuffer, DataBits, FlowControl, Parity, SerialPortType, StopBits};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::characteristics::{LoupedeckButton, LoupedeckDeviceCharacteristics, LoupedeckDeviceDisplayConfiguration, CHARACTERISTICS};
|
||||
use crate::commands::{LoupedeckCommand, VibrationPattern};
|
||||
use crate::events::{LoupedeckEvent, LoupedeckInternalEvent};
|
||||
use crate::messages::{read_messages_worker, write_messages_worker, WS_UPGRADE_REQUEST, WS_UPGRADE_RESPONSE_START};
|
||||
use crate::util::convert_rgb888_to_rgb565;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AvailableLoupedeckDevice {
|
||||
pub(crate) port_name: String,
|
||||
pub(crate) characteristics: &'static LoupedeckDeviceCharacteristics,
|
||||
}
|
||||
|
||||
impl AvailableLoupedeckDevice {
|
||||
pub fn port_name(&self) -> &str {
|
||||
&self.port_name
|
||||
}
|
||||
|
||||
pub fn characteristics(&self) -> &'static LoupedeckDeviceCharacteristics {
|
||||
self.characteristics
|
||||
}
|
||||
|
||||
pub fn connect(&self) -> Result<LoupedeckDevice, ConnectError> {
|
||||
LoupedeckDevice::connect(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LoupedeckDevice {
|
||||
pub(crate) characteristics: &'static LoupedeckDeviceCharacteristics,
|
||||
pub(crate) serial_number: String,
|
||||
pub(crate) firmware_version: String,
|
||||
events_receiver: flume::Receiver<LoupedeckEvent>,
|
||||
commands_sender: flume::Sender<LoupedeckCommand>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConnectError {
|
||||
#[error("Serial port error: {0}")]
|
||||
SerialPort(#[from] serialport::Error),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
IO(#[from] io::Error),
|
||||
|
||||
#[error("The device did not respond with the expected handshake response (early-stage).")]
|
||||
WrongEarlyHandshakeResponse,
|
||||
|
||||
#[error("The device did not respond with the expected handshake response (late-stage).")]
|
||||
WrongLateHandshakeResponse,
|
||||
|
||||
#[error("The device was already connected.")]
|
||||
AlreadyConnected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RefreshDisplayError {
|
||||
#[error("The specified display is not available for this device.")]
|
||||
UnknownDisplay,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SetButtonColorError {
|
||||
#[error("The specified button is not available for this device.")]
|
||||
UnknownButton,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ReplaceFramebufferAreaError {
|
||||
#[error("The specified display is not available for this device.")]
|
||||
UnknownDisplay,
|
||||
|
||||
#[error("The area is not (fully) within the bounds of the display.")]
|
||||
OutOfBounds,
|
||||
|
||||
#[error("Given the specified dimensions, the buffer size must be {expected} but it was {actual}.")]
|
||||
WrongBufferSize { expected: usize, actual: usize },
|
||||
}
|
||||
|
||||
impl LoupedeckDevice {
|
||||
pub fn characteristics(&self) -> &'static LoupedeckDeviceCharacteristics {
|
||||
self.characteristics
|
||||
}
|
||||
|
||||
pub fn serial_number(&self) -> &String {
|
||||
&self.serial_number
|
||||
}
|
||||
|
||||
pub fn firmware_version(&self) -> &String {
|
||||
&self.firmware_version
|
||||
}
|
||||
|
||||
pub fn events(&self) -> flume::Receiver<LoupedeckEvent> {
|
||||
self.events_receiver.clone()
|
||||
}
|
||||
|
||||
pub fn set_brightness(&self, value: f32) {
|
||||
self.commands_sender.send(LoupedeckCommand::SetBrightness(value)).unwrap();
|
||||
}
|
||||
|
||||
pub fn set_button_color(&self, button: LoupedeckButton, color: RGB8) -> Result<(), SetButtonColorError> {
|
||||
if !self.characteristics.available_buttons.contains(button) {
|
||||
return Err(SetButtonColorError::UnknownButton);
|
||||
}
|
||||
|
||||
// The write worker thread not running means the device was disconnected.
|
||||
// In that case, the read worker thread sends a LoupedeckEvent::Disconnected.
|
||||
self.commands_sender.send(LoupedeckCommand::SetButtonColor { button, color }).ok();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replaces the specified framebuffer area of the display with `buffer`.
|
||||
///
|
||||
/// `buffer` must contain exactly as many pixels as required.
|
||||
///
|
||||
/// Please note that the _internal_ color format of all currently known devices is RGB565 (16 bits in total).
|
||||
pub fn replace_framebuffer_area(
|
||||
&self,
|
||||
display: &LoupedeckDeviceDisplayConfiguration,
|
||||
x: u16,
|
||||
y: u16,
|
||||
width: u16,
|
||||
height: u16,
|
||||
buffer: &[RGB8],
|
||||
) -> Result<(), ReplaceFramebufferAreaError> {
|
||||
self.check_replace_framebuffer_area_parameters(display, x, y, width, height)?;
|
||||
|
||||
let expected_buffer_size = (height * width) as usize;
|
||||
if buffer.len() != expected_buffer_size {
|
||||
return Err(ReplaceFramebufferAreaError::WrongBufferSize {
|
||||
expected: expected_buffer_size,
|
||||
actual: buffer.len(),
|
||||
});
|
||||
}
|
||||
|
||||
if width == 0 || height == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// For some color values x, the pixel brightness is lower than for x - 1.
|
||||
// I don’t understand why and what is the pattern of these values.
|
||||
let converted_buffer = convert_rgb888_to_rgb565(Bytes::copy_from_slice(buffer.as_slice()), display.endianness);
|
||||
|
||||
self.unchecked_replace_framebuffer_area_raw(display, x, y, width, height, converted_buffer.freeze());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replaces the specified framebuffer area of the display with `buffer`.
|
||||
///
|
||||
/// `buffer` must contain exactly (`width * height`) RGB565 (16 bit) values, LE or BE depending on `display.endianness`.
|
||||
///
|
||||
/// If you have a buffer of `rgb::RGB8` values, you should use [replace_framebuffer_area](LoupedeckDevice::replace_framebuffer_area) instead.
|
||||
pub fn replace_framebuffer_area_raw(
|
||||
&self,
|
||||
display: &LoupedeckDeviceDisplayConfiguration,
|
||||
x: u16,
|
||||
y: u16,
|
||||
width: u16,
|
||||
height: u16,
|
||||
buffer: Bytes,
|
||||
) -> Result<(), ReplaceFramebufferAreaError> {
|
||||
self.check_replace_framebuffer_area_parameters(display, x, y, width, height)?;
|
||||
|
||||
let expected_buffer_size = (height * width * 2) as usize;
|
||||
if buffer.len() != expected_buffer_size {
|
||||
return Err(ReplaceFramebufferAreaError::WrongBufferSize {
|
||||
expected: expected_buffer_size,
|
||||
actual: buffer.len(),
|
||||
});
|
||||
}
|
||||
|
||||
if width == 0 || height == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.unchecked_replace_framebuffer_area_raw(display, x, y, width, height, buffer);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_replace_framebuffer_area_parameters(
|
||||
&self,
|
||||
display: &LoupedeckDeviceDisplayConfiguration,
|
||||
x: u16,
|
||||
y: u16,
|
||||
width: u16,
|
||||
height: u16,
|
||||
) -> Result<(), ReplaceFramebufferAreaError> {
|
||||
if !(display.id == self.characteristics.key_grid.display.id
|
||||
|| self
|
||||
.characteristics
|
||||
.knob_displays
|
||||
.as_ref()
|
||||
.is_some_and(|d| d.0.id == display.id || d.1.id == display.id))
|
||||
{
|
||||
return Err(ReplaceFramebufferAreaError::UnknownDisplay);
|
||||
}
|
||||
|
||||
if x + width > display.width || y + height > display.height {
|
||||
return Err(ReplaceFramebufferAreaError::OutOfBounds);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unchecked_replace_framebuffer_area_raw(&self, display: &LoupedeckDeviceDisplayConfiguration, x: u16, y: u16, width: u16, height: u16, buffer: Bytes) {
|
||||
let local_x = display.local_offset_x + x;
|
||||
let local_y = display.local_offset_y + y;
|
||||
|
||||
self.commands_sender
|
||||
.send(LoupedeckCommand::ReplaceFramebufferArea {
|
||||
display_id: display.id,
|
||||
x: local_x,
|
||||
y: local_y,
|
||||
width,
|
||||
height,
|
||||
buffer,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub fn refresh_display(&self, display: &LoupedeckDeviceDisplayConfiguration) -> Result<(), RefreshDisplayError> {
|
||||
if !(display.id == self.characteristics.key_grid.display.id
|
||||
|| self
|
||||
.characteristics
|
||||
.knob_displays
|
||||
.as_ref()
|
||||
.is_some_and(|d| d.0.id == display.id || d.1.id == display.id))
|
||||
{
|
||||
return Err(RefreshDisplayError::UnknownDisplay);
|
||||
}
|
||||
|
||||
self.commands_sender.send(LoupedeckCommand::RefreshDisplay { display_id: display.id }).unwrap();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn vibrate(&self, pattern: VibrationPattern) {
|
||||
self.commands_sender.send(LoupedeckCommand::Vibrate { pattern }).unwrap();
|
||||
}
|
||||
|
||||
pub fn discover() -> Result<Vec<AvailableLoupedeckDevice>, serialport::Error> {
|
||||
let ports = serialport::available_ports()?;
|
||||
|
||||
Ok(ports
|
||||
.iter()
|
||||
.filter_map(|port| {
|
||||
if let SerialPortType::UsbPort(info) = &port.port_type {
|
||||
let characteristics = CHARACTERISTICS.iter().find(|c| c.vendor_id == info.vid && c.product_id == info.pid);
|
||||
|
||||
if let Some(characteristics) = characteristics {
|
||||
return Some(AvailableLoupedeckDevice {
|
||||
port_name: port.port_name.clone(),
|
||||
characteristics,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
})
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
pub(crate) fn connect(AvailableLoupedeckDevice { port_name, characteristics }: &AvailableLoupedeckDevice) -> Result<LoupedeckDevice, ConnectError> {
|
||||
let mut port = LoupedeckDevice::create_port_and_send_ws_upgrade_request(port_name)?;
|
||||
|
||||
let mut buf = [0; WS_UPGRADE_RESPONSE_START.len()];
|
||||
|
||||
if port.read_exact(&mut buf).is_err() {
|
||||
drop(port);
|
||||
port = LoupedeckDevice::create_port_and_send_ws_upgrade_request(port_name)?;
|
||||
port.read_exact(&mut buf).unwrap();
|
||||
}
|
||||
|
||||
if buf != WS_UPGRADE_RESPONSE_START.as_bytes() {
|
||||
return Err(ConnectError::WrongEarlyHandshakeResponse);
|
||||
}
|
||||
|
||||
// I don’t know why, but there is garbage in the buffer without this.
|
||||
sleep(Duration::from_secs(1));
|
||||
port.clear(ClearBuffer::Input)?;
|
||||
let cloned_port = port.try_clone().expect("port must be cloneable");
|
||||
|
||||
let thread_name_base = format!("loupedeck_serial ({})", port.name().unwrap_or("<unnamed>".to_owned()));
|
||||
|
||||
let (public_events_sender, public_events_receiver) = flume::unbounded::<LoupedeckEvent>();
|
||||
let (internal_events_sender, internal_events_receiver) = mpsc::sync_channel(2);
|
||||
thread::Builder::new().name(thread_name_base.to_owned() + " read worker").spawn(move || {
|
||||
read_messages_worker(port, public_events_sender, internal_events_sender);
|
||||
})?;
|
||||
|
||||
let (commands_sender, commands_receiver) = flume::unbounded::<LoupedeckCommand>();
|
||||
thread::Builder::new().name(thread_name_base.to_owned() + " write worker").spawn(move || {
|
||||
write_messages_worker(cloned_port, commands_receiver);
|
||||
})?;
|
||||
|
||||
commands_sender.send(LoupedeckCommand::RequestSerialNumber).unwrap();
|
||||
let serial_number = match internal_events_receiver.recv_timeout(Duration::from_secs(10)) {
|
||||
Ok(LoupedeckInternalEvent::GetSerialNumberResponse { serial_number }) => Ok(serial_number),
|
||||
_ => Err(ConnectError::WrongLateHandshakeResponse),
|
||||
}?;
|
||||
|
||||
commands_sender.send(LoupedeckCommand::RequestFirmwareVersion).unwrap();
|
||||
let firmware_version = match internal_events_receiver.recv_timeout(Duration::from_secs(10)) {
|
||||
Ok(LoupedeckInternalEvent::GetFirmwareVersionResponse { firmware_version }) => Ok(firmware_version),
|
||||
_ => Err(ConnectError::WrongLateHandshakeResponse),
|
||||
}?;
|
||||
|
||||
drop(internal_events_receiver);
|
||||
|
||||
Ok(LoupedeckDevice {
|
||||
characteristics,
|
||||
serial_number,
|
||||
firmware_version,
|
||||
events_receiver: public_events_receiver,
|
||||
commands_sender,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_port_and_send_ws_upgrade_request(port_name: &str) -> serialport::Result<Box<dyn serialport::SerialPort>> {
|
||||
let mut port = serialport::new(port_name, 256000)
|
||||
.data_bits(DataBits::Eight)
|
||||
.stop_bits(StopBits::One)
|
||||
.parity(Parity::None)
|
||||
.flow_control(FlowControl::Software)
|
||||
.timeout(Duration::from_secs(1))
|
||||
.open()?;
|
||||
|
||||
port.clear(ClearBuffer::All).unwrap();
|
||||
port.write_all(WS_UPGRADE_REQUEST.as_bytes()).unwrap();
|
||||
port.flush().unwrap();
|
||||
|
||||
Ok(port)
|
||||
}
|
||||
}
|
24
crates/loupedeck_serial/src/events.rs
Normal file
24
crates/loupedeck_serial/src/events.rs
Normal file
|
@ -0,0 +1,24 @@
|
|||
use crate::characteristics::{LoupedeckButton, LoupedeckKnob};
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Copy, Clone)]
|
||||
pub enum RotationDirection {
|
||||
Clockwise,
|
||||
Counterclockwise,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum LoupedeckInternalEvent {
|
||||
GetSerialNumberResponse { serial_number: String },
|
||||
GetFirmwareVersionResponse { firmware_version: String },
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum LoupedeckEvent {
|
||||
Disconnected,
|
||||
ButtonDown { button: LoupedeckButton },
|
||||
ButtonUp { button: LoupedeckButton },
|
||||
KnobDown { knob: LoupedeckKnob },
|
||||
KnobUp { knob: LoupedeckKnob },
|
||||
KnobRotate { knob: LoupedeckKnob, direction: RotationDirection },
|
||||
Touch { touch_id: u8, x: u16, y: u16, is_end: bool },
|
||||
}
|
6
crates/loupedeck_serial/src/lib.rs
Normal file
6
crates/loupedeck_serial/src/lib.rs
Normal file
|
@ -0,0 +1,6 @@
|
|||
pub mod characteristics;
|
||||
pub mod commands;
|
||||
pub mod device;
|
||||
pub mod events;
|
||||
mod messages;
|
||||
pub mod util;
|
303
crates/loupedeck_serial/src/messages.rs
Normal file
303
crates/loupedeck_serial/src/messages.rs
Normal file
|
@ -0,0 +1,303 @@
|
|||
use std::cmp::min;
|
||||
use std::io;
|
||||
use std::io::{ErrorKind, Read, Write};
|
||||
use std::sync::mpsc;
|
||||
|
||||
use bytes::{Buf, BufMut, Bytes, BytesMut};
|
||||
use enum_ordinalize::Ordinalize;
|
||||
use serialport::SerialPort;
|
||||
|
||||
use crate::characteristics::{LoupedeckButton, LoupedeckKnob};
|
||||
use crate::commands::LoupedeckCommand;
|
||||
use crate::events::RotationDirection::{Clockwise, Counterclockwise};
|
||||
use crate::events::{LoupedeckEvent, LoupedeckInternalEvent};
|
||||
|
||||
pub(crate) const WS_UPGRADE_REQUEST: &str = r#"GET /index.html
|
||||
HTTP/1.1
|
||||
Connection: Upgrade
|
||||
Upgrade: websocket
|
||||
Sec-WebSocket-Key: 123abc
|
||||
|
||||
"#;
|
||||
|
||||
pub(crate) const WS_UPGRADE_RESPONSE_START: &str = "HTTP/1.1 101 Switching Protocols\r\n\
|
||||
Upgrade: websocket\r\n\
|
||||
Connection: Upgrade\r\n\
|
||||
Sec-WebSocket-Accept: ALtlZo9FMEUEQleXJmq++ukUQ1s=";
|
||||
|
||||
const MESSAGE_START_BYTE: u8 = 0x82;
|
||||
const MAX_MESSAGE_LENGTH: usize = u8::MAX as usize;
|
||||
|
||||
enum ParseMessageResult {
|
||||
InternalEvent(LoupedeckInternalEvent),
|
||||
PublicEvent(LoupedeckEvent),
|
||||
Nothing,
|
||||
}
|
||||
|
||||
impl From<LoupedeckInternalEvent> for ParseMessageResult {
|
||||
fn from(value: LoupedeckInternalEvent) -> Self {
|
||||
ParseMessageResult::InternalEvent(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LoupedeckEvent> for ParseMessageResult {
|
||||
fn from(value: LoupedeckEvent) -> Self {
|
||||
ParseMessageResult::PublicEvent(value)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn read_messages_worker(
|
||||
mut port: Box<dyn SerialPort>,
|
||||
public_sender: flume::Sender<LoupedeckEvent>,
|
||||
internal_sender: mpsc::SyncSender<LoupedeckInternalEvent>,
|
||||
) {
|
||||
let mut internal_sender = Some(internal_sender);
|
||||
|
||||
let mut should_stop = false;
|
||||
let mut buffer = BytesMut::new();
|
||||
|
||||
while !should_stop {
|
||||
let mut chunk = BytesMut::zeroed(MAX_MESSAGE_LENGTH);
|
||||
let read_result = port.read(&mut chunk);
|
||||
|
||||
let read_length = match read_result {
|
||||
Ok(length) => length,
|
||||
Err(err) => {
|
||||
match err.kind() {
|
||||
ErrorKind::BrokenPipe => {
|
||||
// This fails only if the other side is disconnected.
|
||||
// In that case, this thread should terminate anyway and we can ignore the error.
|
||||
public_sender.send(LoupedeckEvent::Disconnected).ok();
|
||||
break;
|
||||
}
|
||||
ErrorKind::TimedOut => continue,
|
||||
_ => panic!("{}", err),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
chunk.truncate(read_length);
|
||||
buffer.put(chunk);
|
||||
|
||||
while !should_stop {
|
||||
let start_index = buffer.iter().position(|b| *b == MESSAGE_START_BYTE);
|
||||
|
||||
if let Some(start_index) = start_index {
|
||||
if start_index > 0 {
|
||||
buffer.advance(start_index);
|
||||
|
||||
if buffer.remaining() == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let length = buffer[1] as usize + 2;
|
||||
if length > buffer.remaining() {
|
||||
break;
|
||||
} else {
|
||||
let mut message = buffer.split_to(length);
|
||||
|
||||
let command = message[3];
|
||||
// let transaction_id = message[4];
|
||||
message.advance(5);
|
||||
|
||||
let result = parse_message(command, message.freeze());
|
||||
match result {
|
||||
ParseMessageResult::InternalEvent(event) => {
|
||||
// Does nothing after the receiving side has been closed
|
||||
if let Some(sender) = internal_sender.take() {
|
||||
let is_open = sender.send(event).is_ok();
|
||||
if is_open {
|
||||
internal_sender = Some(sender);
|
||||
}
|
||||
}
|
||||
}
|
||||
ParseMessageResult::PublicEvent(event) => {
|
||||
if public_sender.send(event).is_err() {
|
||||
should_stop = false
|
||||
}
|
||||
}
|
||||
ParseMessageResult::Nothing => {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_message(command: u8, mut message: Bytes) -> ParseMessageResult {
|
||||
match command {
|
||||
0x00 => match message[1] {
|
||||
0x00 => match message[0] {
|
||||
0x01 => LoupedeckEvent::KnobDown { knob: LoupedeckKnob::LeftTop },
|
||||
0x02 => LoupedeckEvent::KnobDown {
|
||||
knob: LoupedeckKnob::LeftMiddle,
|
||||
},
|
||||
0x03 => LoupedeckEvent::KnobDown {
|
||||
knob: LoupedeckKnob::LeftBottom,
|
||||
},
|
||||
0x04 => LoupedeckEvent::KnobDown { knob: LoupedeckKnob::RightTop },
|
||||
0x05 => LoupedeckEvent::KnobDown {
|
||||
knob: LoupedeckKnob::RightMiddle,
|
||||
},
|
||||
0x06 => LoupedeckEvent::KnobDown {
|
||||
knob: LoupedeckKnob::RightBottom,
|
||||
},
|
||||
0x07 => LoupedeckEvent::ButtonDown { button: LoupedeckButton::N0 },
|
||||
0x08 => LoupedeckEvent::ButtonDown { button: LoupedeckButton::N1 },
|
||||
0x09 => LoupedeckEvent::ButtonDown { button: LoupedeckButton::N2 },
|
||||
0x0a => LoupedeckEvent::ButtonDown { button: LoupedeckButton::N3 },
|
||||
0x0b => LoupedeckEvent::ButtonDown { button: LoupedeckButton::N4 },
|
||||
0x0c => LoupedeckEvent::ButtonDown { button: LoupedeckButton::N5 },
|
||||
0x0d => LoupedeckEvent::ButtonDown { button: LoupedeckButton::N6 },
|
||||
0x0e => LoupedeckEvent::ButtonDown { button: LoupedeckButton::N7 },
|
||||
_ => panic!("Illegal button id: {}", message[1]),
|
||||
},
|
||||
_ => match message[0] {
|
||||
0x01 => LoupedeckEvent::KnobUp { knob: LoupedeckKnob::LeftTop },
|
||||
0x02 => LoupedeckEvent::KnobUp {
|
||||
knob: LoupedeckKnob::LeftMiddle,
|
||||
},
|
||||
0x03 => LoupedeckEvent::KnobUp {
|
||||
knob: LoupedeckKnob::LeftBottom,
|
||||
},
|
||||
0x04 => LoupedeckEvent::KnobUp { knob: LoupedeckKnob::RightTop },
|
||||
0x05 => LoupedeckEvent::KnobUp {
|
||||
knob: LoupedeckKnob::RightMiddle,
|
||||
},
|
||||
0x06 => LoupedeckEvent::KnobUp {
|
||||
knob: LoupedeckKnob::RightBottom,
|
||||
},
|
||||
0x07 => LoupedeckEvent::ButtonUp { button: LoupedeckButton::N0 },
|
||||
0x08 => LoupedeckEvent::ButtonUp { button: LoupedeckButton::N1 },
|
||||
0x09 => LoupedeckEvent::ButtonUp { button: LoupedeckButton::N2 },
|
||||
0x0a => LoupedeckEvent::ButtonUp { button: LoupedeckButton::N3 },
|
||||
0x0b => LoupedeckEvent::ButtonUp { button: LoupedeckButton::N4 },
|
||||
0x0c => LoupedeckEvent::ButtonUp { button: LoupedeckButton::N5 },
|
||||
0x0d => LoupedeckEvent::ButtonUp { button: LoupedeckButton::N6 },
|
||||
0x0e => LoupedeckEvent::ButtonUp { button: LoupedeckButton::N7 },
|
||||
_ => panic!("Illegal button id: {}", message[1]),
|
||||
},
|
||||
}
|
||||
.into(),
|
||||
0x01 => {
|
||||
let knob = LoupedeckKnob::from_ordinal(message[0]).expect("Invalid button ID");
|
||||
|
||||
LoupedeckEvent::KnobRotate {
|
||||
knob,
|
||||
direction: if message[1] == 1 { Clockwise } else { Counterclockwise },
|
||||
}
|
||||
.into()
|
||||
}
|
||||
0x03 => LoupedeckInternalEvent::GetSerialNumberResponse {
|
||||
serial_number: String::from_utf8_lossy(&message).into_owned(),
|
||||
}
|
||||
.into(),
|
||||
0x07 => LoupedeckInternalEvent::GetFirmwareVersionResponse {
|
||||
firmware_version: format!("{}.{}.{}", message[0], message[1], message[2]),
|
||||
}
|
||||
.into(),
|
||||
0x4d | 0x6d => {
|
||||
message.advance(1);
|
||||
let x = message.get_u16();
|
||||
let y = message.get_u16();
|
||||
let touch_id = message.get_u8();
|
||||
|
||||
LoupedeckEvent::Touch {
|
||||
touch_id,
|
||||
x,
|
||||
y,
|
||||
is_end: command == 0x6d,
|
||||
}
|
||||
.into()
|
||||
}
|
||||
_ => ParseMessageResult::Nothing,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn write_messages_worker(mut port: Box<dyn SerialPort>, receiver: flume::Receiver<LoupedeckCommand>) {
|
||||
let mut next_transaction_id = 0;
|
||||
|
||||
let mut send = |command_id: u8, data: Bytes| -> Result<(), io::Error> {
|
||||
if next_transaction_id == 0 {
|
||||
next_transaction_id += 1;
|
||||
}
|
||||
|
||||
let mut data_with_header = BytesMut::with_capacity(data.len() + 3);
|
||||
data_with_header.put_u8(min(u8::MAX as usize, data.len() + 3) as u8);
|
||||
data_with_header.put_u8(command_id);
|
||||
data_with_header.put_u8(next_transaction_id);
|
||||
data_with_header.put(data);
|
||||
|
||||
let length = data_with_header.len();
|
||||
if length > u8::MAX as usize {
|
||||
let mut prep = BytesMut::with_capacity(14);
|
||||
prep.put_u8(0x82);
|
||||
prep.put_u8(0xff);
|
||||
prep.put_bytes(0x00, 4);
|
||||
prep.put_u32(length as u32);
|
||||
prep.put_bytes(0x00, 4);
|
||||
|
||||
port.write_all(&prep)?;
|
||||
port.flush()?;
|
||||
} else {
|
||||
let mut prep = BytesMut::zeroed(6);
|
||||
prep[0] = 0x82;
|
||||
prep[1] = (0x80 + length) as u8;
|
||||
|
||||
port.write_all(&prep)?;
|
||||
port.flush()?;
|
||||
}
|
||||
|
||||
port.write_all(&data_with_header)?;
|
||||
port.flush()?;
|
||||
|
||||
next_transaction_id = next_transaction_id.wrapping_add(1);
|
||||
|
||||
Ok(())
|
||||
};
|
||||
|
||||
for command in receiver {
|
||||
let result = match command {
|
||||
LoupedeckCommand::RequestSerialNumber => send(0x03, Bytes::new()),
|
||||
LoupedeckCommand::RequestFirmwareVersion => send(0x07, Bytes::new()),
|
||||
LoupedeckCommand::SetBrightness(value) => {
|
||||
let raw_value = (value.clamp(0f32, 1f32) * 10.0) as u8;
|
||||
send(0x09, Bytes::copy_from_slice(&[raw_value]))
|
||||
}
|
||||
LoupedeckCommand::SetButtonColor { button, color } => send(0x02, Bytes::copy_from_slice(&[button.ordinal(), color.r, color.g, color.b])),
|
||||
LoupedeckCommand::ReplaceFramebufferArea {
|
||||
display_id,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
buffer,
|
||||
} => {
|
||||
let mut data = BytesMut::with_capacity(10 + buffer.len());
|
||||
data.put_u8(0);
|
||||
data.put_u8(display_id);
|
||||
data.put_u16(x);
|
||||
data.put_u16(y);
|
||||
data.put_u16(width);
|
||||
data.put_u16(height);
|
||||
data.put(buffer);
|
||||
|
||||
send(0x10, data.freeze())
|
||||
}
|
||||
LoupedeckCommand::RefreshDisplay { display_id } => send(0x0f, Bytes::copy_from_slice(&[0, display_id])),
|
||||
LoupedeckCommand::Vibrate { pattern } => send(0x1b, Bytes::copy_from_slice(&[pattern.ordinal()])),
|
||||
};
|
||||
|
||||
if let Err(error) = result {
|
||||
match error.kind() {
|
||||
ErrorKind::TimedOut | ErrorKind::BrokenPipe => break,
|
||||
_ => {
|
||||
panic!("IO error during write: {}", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
32
crates/loupedeck_serial/src/util.rs
Normal file
32
crates/loupedeck_serial/src/util.rs
Normal file
|
@ -0,0 +1,32 @@
|
|||
use bytes::{BufMut, Bytes, BytesMut};
|
||||
|
||||
pub fn convert_rgb888_to_rgb565(original: Bytes, endianness: Endianness) -> BytesMut {
|
||||
let pixel_count = original.len() / 3;
|
||||
let excess_bytes = original.len() % 3;
|
||||
|
||||
if excess_bytes != 0 {
|
||||
panic!("Found {} excess bytes at the end of the original buffer", excess_bytes);
|
||||
}
|
||||
|
||||
let mut result = BytesMut::with_capacity(pixel_count * 2);
|
||||
|
||||
for index in 0..pixel_count {
|
||||
let red = original[index * 3] as u16;
|
||||
let green = original[index * 3 + 1] as u16;
|
||||
let blue = original[index * 3 + 2] as u16;
|
||||
let color = ((red & 0b11111000) << 8) | ((green & 0b11111100) << 3) | (blue.wrapping_shr(3));
|
||||
|
||||
match endianness {
|
||||
Endianness::LittleEndian => result.put_u16_le(color),
|
||||
Endianness::BigEndian => result.put_u16(color),
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub enum Endianness {
|
||||
BigEndian,
|
||||
LittleEndian,
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue