commit
This commit is contained in:
parent
1904e3e96a
commit
b5a7ab3c6b
71 changed files with 921 additions and 1297 deletions
12
crates/deckster_mode/Cargo.toml
Normal file
12
crates/deckster_mode/Cargo.toml
Normal file
|
@ -0,0 +1,12 @@
|
|||
[package]
|
||||
name = "deckster_mode"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
deckster_shared = { path = "../deckster_shared" }
|
||||
thiserror = "1.0.56"
|
||||
im = "15.1.0"
|
||||
either = "1.9.0"
|
||||
serde = { version = "1.0.196", default-features = false }
|
||||
serde_json = "1.0.113"
|
88
crates/deckster_mode/src/lib.rs
Normal file
88
crates/deckster_mode/src/lib.rs
Normal file
|
@ -0,0 +1,88 @@
|
|||
use std::any::TypeId;
|
||||
use std::io;
|
||||
use std::io::BufRead;
|
||||
|
||||
use either::Either;
|
||||
use serde::de::DeserializeOwned;
|
||||
use thiserror::Error;
|
||||
|
||||
use deckster_shared::handler_communication::HandlerInitializationResultMessage;
|
||||
pub use deckster_shared::handler_communication::{HandlerEvent, HandlerInitializationError, InitialHandlerMessage};
|
||||
pub use deckster_shared::path::*;
|
||||
pub use deckster_shared::state::{KeyStyleByStateMap, KnobStyleByStateMap};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RunError {
|
||||
#[error("A stdin line could not be read.")]
|
||||
LineIo(#[from] io::Error),
|
||||
|
||||
#[error("A stdin line could not be deserialized: {description}")]
|
||||
LineDeserialization { line: String, description: String },
|
||||
}
|
||||
|
||||
pub trait DecksterHandler {
|
||||
fn handle(&mut self, event: HandlerEvent);
|
||||
}
|
||||
|
||||
pub fn run<
|
||||
KeyConfig: Clone + DeserializeOwned + 'static,
|
||||
KnobConfig: Clone + DeserializeOwned + 'static,
|
||||
H: DecksterHandler,
|
||||
I: FnOnce(InitialHandlerMessage<KeyConfig, KnobConfig>) -> Result<H, HandlerInitializationError>,
|
||||
>(
|
||||
init_handler: I,
|
||||
) -> Result<(), RunError> {
|
||||
let mut handler: Either<H, I> = Either::Right(init_handler);
|
||||
|
||||
let supports_keys = TypeId::of::<KeyConfig>() != TypeId::of::<()>();
|
||||
let supports_knobs = TypeId::of::<KnobConfig>() != TypeId::of::<()>();
|
||||
|
||||
let handle = io::stdin().lock();
|
||||
for line in handle.lines() {
|
||||
let line = line?;
|
||||
|
||||
match handler {
|
||||
Either::Left(mut h) => {
|
||||
let event: HandlerEvent = serde_json::from_str(&line).map_err(|e| RunError::LineDeserialization {
|
||||
line,
|
||||
description: e.to_string(),
|
||||
})?;
|
||||
|
||||
h.handle(event);
|
||||
handler = Either::Left(h);
|
||||
}
|
||||
Either::Right(init_handler) => {
|
||||
let initial_message = serde_json::from_str::<InitialHandlerMessage<KeyConfig, KnobConfig>>(&line);
|
||||
|
||||
match initial_message {
|
||||
Ok(initial_message) => match init_handler(initial_message) {
|
||||
Ok(h) => {
|
||||
println!("{}", serde_json::to_string(&HandlerInitializationResultMessage::Ready).unwrap());
|
||||
handler = Either::Left(h)
|
||||
}
|
||||
Err(error) => {
|
||||
println!("{}", serde_json::to_string(&HandlerInitializationResultMessage::Error { error }).unwrap());
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&HandlerInitializationResultMessage::Error {
|
||||
error: HandlerInitializationError::InvalidConfig {
|
||||
supports_keys,
|
||||
supports_knobs,
|
||||
message: err.to_string().into_boxed_str(),
|
||||
}
|
||||
})
|
||||
.unwrap()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
15
crates/deckster_shared/Cargo.toml
Normal file
15
crates/deckster_shared/Cargo.toml
Normal file
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "deckster_shared"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0.195", features = ["derive", "rc"] }
|
||||
serde_with = "3.4.0"
|
||||
thiserror = "1.0.56"
|
||||
derive_more = "0.99.17"
|
||||
rgb = "0.8.37"
|
||||
enum-ordinalize = "4.3.0"
|
||||
enum-map = "3.0.0-beta.2"
|
||||
im = { version = "15.1.0", features = ["serde"] }
|
||||
parse-display = "0.8.2"
|
77
crates/deckster_shared/src/handler_communication.rs
Normal file
77
crates/deckster_shared/src/handler_communication.rs
Normal file
|
@ -0,0 +1,77 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::path::{KeyPath, KnobPath};
|
||||
use crate::style::{KeyStyle, KnobStyle};
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Copy, Clone, Serialize, Deserialize)]
|
||||
pub enum RotationDirection {
|
||||
Clockwise,
|
||||
Counterclockwise,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
|
||||
pub enum KnobEvent {
|
||||
Press,
|
||||
ButtonDown,
|
||||
ButtonUp,
|
||||
Rotate { direction: RotationDirection },
|
||||
VisibilityChange { is_visible: bool },
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)]
|
||||
pub enum KeyTouchEventKind {
|
||||
Start,
|
||||
Move,
|
||||
End,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
|
||||
pub enum KeyEvent {
|
||||
Press,
|
||||
Touch { touch_id: u8, x: u16, y: u16, kind: KeyTouchEventKind },
|
||||
VisibilityChange { is_visible: bool },
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||
pub enum HandlerEvent {
|
||||
Knob { path: KnobPath, event: KnobEvent },
|
||||
Key { path: KeyPath, event: KeyEvent },
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "command", rename_all = "kebab-case")]
|
||||
pub enum HandlerCommand {
|
||||
SetActivePages { key_page_id: String, knob_page_id: String },
|
||||
SetKeyStyle { path: KeyPath, value: Option<KeyStyle> },
|
||||
SetKnobStyle { path: KnobPath, value: Option<KnobStyle> },
|
||||
SetKnobValue { path: KnobPath, value: Option<f32> },
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
|
||||
pub struct InitialHandlerMessage<KeyConfig: Clone, KnobConfig: Clone> {
|
||||
pub key_configs: im::HashMap<KeyPath, (Box<str>, KeyConfig)>,
|
||||
pub knob_configs: im::HashMap<KnobPath, (Box<str>, KnobConfig)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum HandlerInitializationResultMessage {
|
||||
Ready,
|
||||
Error { error: HandlerInitializationError },
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Error)]
|
||||
pub enum HandlerInitializationError {
|
||||
#[error("The provided mode string is invalid: {message}")]
|
||||
InvalidModeString { message: Box<str> },
|
||||
#[error("The provided handler config is invalid: {message}")]
|
||||
InvalidConfig {
|
||||
supports_keys: bool,
|
||||
supports_knobs: bool,
|
||||
message: Box<str>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
Other { message: Box<str> },
|
||||
}
|
89
crates/deckster_shared/src/icon_descriptor.rs
Normal file
89
crates/deckster_shared/src/icon_descriptor.rs
Normal file
|
@ -0,0 +1,89 @@
|
|||
use std::fmt::{Display, Formatter};
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::{DeserializeFromStr, SerializeDisplay};
|
||||
|
||||
use crate::image_filter::{ImageFilter, ImageFilterFromStringError};
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Hash, Clone, SerializeDisplay, DeserializeFromStr)]
|
||||
pub struct IconDescriptor {
|
||||
pub source: IconDescriptorSource,
|
||||
pub filter: ImageFilter,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum IconDescriptorFromStrError {
|
||||
#[error("Not a valid icon identifier: {0}")]
|
||||
InvalidIconPackSource(String),
|
||||
|
||||
#[error("The image filter is invalid: {0}")]
|
||||
InvalidImageFilter(#[source] ImageFilterFromStringError),
|
||||
|
||||
#[error("The image filter is missing the closing ']'")]
|
||||
MissingImageFilterClosingBracket,
|
||||
}
|
||||
|
||||
impl FromStr for IconDescriptor {
|
||||
type Err = IconDescriptorFromStrError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let (raw_source, raw_filter) = s.split_once('[').unwrap_or((s, ""));
|
||||
|
||||
let source = if let Some(raw_source) = raw_source.strip_prefix('@') {
|
||||
let (pack_id, icon_id) = raw_source
|
||||
.split_once('/')
|
||||
.ok_or_else(|| IconDescriptorFromStrError::InvalidIconPackSource(raw_source.to_string()))?;
|
||||
IconDescriptorSource::IconPack {
|
||||
pack_id: pack_id.to_owned(),
|
||||
icon_id: icon_id.to_owned(),
|
||||
}
|
||||
} else {
|
||||
IconDescriptorSource::Path(PathBuf::from(raw_source))
|
||||
};
|
||||
|
||||
let filter: ImageFilter = if raw_filter.is_empty() {
|
||||
ImageFilter::default()
|
||||
} else {
|
||||
let mut raw_filter = raw_filter.to_owned();
|
||||
if raw_filter.pop().expect("emptiness was eliminated a few lines earlier") != ']' {
|
||||
return Err(IconDescriptorFromStrError::MissingImageFilterClosingBracket);
|
||||
}
|
||||
|
||||
ImageFilter::from_str(&raw_filter).map_err(IconDescriptorFromStrError::InvalidImageFilter)?
|
||||
};
|
||||
|
||||
Ok(IconDescriptor { source, filter })
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for IconDescriptor {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_fmt(format_args!("{}[{}]", self.source, self.filter))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Hash, Clone, Serialize, Deserialize)]
|
||||
pub enum IconDescriptorSource {
|
||||
IconPack { pack_id: String, icon_id: String },
|
||||
Path(PathBuf),
|
||||
}
|
||||
|
||||
impl IconDescriptorSource {
|
||||
pub fn pack_id(&self) -> Option<&String> {
|
||||
match self {
|
||||
IconDescriptorSource::IconPack { pack_id, .. } => Some(pack_id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for IconDescriptorSource {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
IconDescriptorSource::IconPack { pack_id, icon_id } => f.write_fmt(format_args!("@{}/{}", pack_id, icon_id)),
|
||||
IconDescriptorSource::Path(path) => f.write_str(&path.to_string_lossy()),
|
||||
}
|
||||
}
|
||||
}
|
282
crates/deckster_shared/src/image_filter.rs
Normal file
282
crates/deckster_shared/src/image_filter.rs
Normal file
|
@ -0,0 +1,282 @@
|
|||
use std::fmt::{Debug, Display, Formatter};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde_with::{DeserializeFromStr, SerializeDisplay};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::rgb::RGB8Wrapper;
|
||||
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
pub struct ImageFilterTransform {
|
||||
pub scale: f32,
|
||||
/// Must be in 0..=3
|
||||
pub clockwise_quarter_rotations: u8,
|
||||
pub alpha: f32,
|
||||
}
|
||||
|
||||
impl ImageFilterTransform {
|
||||
pub fn merge_over(&self, other: &ImageFilterTransform) -> ImageFilterTransform {
|
||||
ImageFilterTransform {
|
||||
scale: self.scale * other.scale,
|
||||
alpha: self.alpha * other.alpha,
|
||||
clockwise_quarter_rotations: (self.clockwise_quarter_rotations + other.clockwise_quarter_rotations) % 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Hash, Clone)]
|
||||
pub struct ImageFilterDestructive {
|
||||
pub color: Option<RGB8Wrapper>,
|
||||
pub grayscale: bool,
|
||||
pub invert: bool,
|
||||
}
|
||||
|
||||
impl ImageFilterDestructive {
|
||||
pub fn merge_over(&self, other: &ImageFilterDestructive) -> ImageFilterDestructive {
|
||||
ImageFilterDestructive {
|
||||
color: self.color.or(other.color),
|
||||
grayscale: self.grayscale || other.grayscale,
|
||||
invert: self.invert ^ other.invert,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, SerializeDisplay, DeserializeFromStr)]
|
||||
pub struct ImageFilter {
|
||||
pub transform: ImageFilterTransform,
|
||||
pub destructive: ImageFilterDestructive,
|
||||
}
|
||||
|
||||
impl ImageFilter {
|
||||
pub fn merge_over(&self, other: &ImageFilter) -> ImageFilter {
|
||||
ImageFilter {
|
||||
transform: self.transform.merge_over(&other.transform),
|
||||
destructive: self.destructive.merge_over(&other.destructive),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for ImageFilter {
|
||||
fn assert_receiver_is_total_eq(&self) {}
|
||||
}
|
||||
|
||||
impl Hash for ImageFilter {
|
||||
fn hash<H: Hasher>(&self, hasher: &mut H) {
|
||||
self.to_string().hash(hasher)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ImageFilter {
|
||||
fn default() -> Self {
|
||||
DEFAULT_IMAGE_FILTER.clone()
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_IMAGE_FILTER: ImageFilter = ImageFilter {
|
||||
transform: ImageFilterTransform {
|
||||
scale: 1.0,
|
||||
clockwise_quarter_rotations: 0,
|
||||
alpha: 1.0,
|
||||
},
|
||||
destructive: ImageFilterDestructive {
|
||||
color: None,
|
||||
grayscale: false,
|
||||
invert: false,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ImageFilterFromStringError {
|
||||
#[error("Unknown filter: {name}")]
|
||||
UnknownFilter { name: String },
|
||||
|
||||
#[error("Filter {name} can only be used once.")]
|
||||
FilterUsedMoreThanOnce { name: String },
|
||||
|
||||
#[error("Only the following values are allowed for rotate: -90, 90, 180, 270")]
|
||||
RotationNotAllowed,
|
||||
|
||||
#[error("Filter {filter_name} requires a value ({filter_name}=<value>).")]
|
||||
FilterValueMissing { filter_name: String },
|
||||
|
||||
#[error("Filter {filter_name} does not require or allow a value.")]
|
||||
FilterValueIgnored { filter_name: String },
|
||||
|
||||
#[error("The following value supplied to {filter_name} could not be parsed: {raw_value}")]
|
||||
FilterValueNotParsable { filter_name: String, raw_value: String },
|
||||
|
||||
#[error("{value} is not in the range ({range}) required by {filter_name}.")]
|
||||
FilterValueNotInRange { filter_name: String, value: String, range: String },
|
||||
}
|
||||
|
||||
impl FromStr for ImageFilter {
|
||||
type Err = ImageFilterFromStringError;
|
||||
|
||||
fn from_str<'a>(s: &str) -> Result<Self, Self::Err> {
|
||||
fn parse_f32_filter_value(
|
||||
filter_name: &str,
|
||||
raw_value: String,
|
||||
range_string: &str,
|
||||
check_range: Box<dyn Fn(&f32) -> bool>,
|
||||
) -> Result<f32, ImageFilterFromStringError> {
|
||||
let value = f32::from_str(&raw_value).map_err(|_| ImageFilterFromStringError::FilterValueNotParsable {
|
||||
filter_name: filter_name.to_string(),
|
||||
raw_value,
|
||||
})?;
|
||||
|
||||
if !check_range(&value) {
|
||||
return Err(ImageFilterFromStringError::FilterValueNotInRange {
|
||||
filter_name: filter_name.to_string(),
|
||||
range: range_string.to_string(),
|
||||
value: value.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
let mut result = ImageFilter::default();
|
||||
let mut previous_filter_names: Vec<String> = Vec::new();
|
||||
|
||||
let filters: Vec<&str> = s.split('|').map(|f| f.trim()).filter(|s| !s.is_empty()).collect();
|
||||
|
||||
if filters.is_empty() {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
for filter in filters {
|
||||
let split_filter = filter.split_once('=');
|
||||
let (filter_name, optional_raw_value) = if let Some((filter_name, raw_value)) = split_filter {
|
||||
(filter_name.to_owned(), Some(raw_value.to_owned()))
|
||||
} else {
|
||||
(filter.to_owned(), None)
|
||||
};
|
||||
|
||||
if previous_filter_names.contains(&filter_name) {
|
||||
return Err(ImageFilterFromStringError::FilterUsedMoreThanOnce { name: filter_name });
|
||||
} else {
|
||||
previous_filter_names.push(filter_name.clone());
|
||||
}
|
||||
|
||||
let value_is_present = optional_raw_value.is_some();
|
||||
let use_bool_value = || -> Result<bool, ImageFilterFromStringError> {
|
||||
if value_is_present {
|
||||
Err(ImageFilterFromStringError::FilterValueIgnored {
|
||||
filter_name: filter_name.clone(),
|
||||
})
|
||||
} else {
|
||||
Ok(true)
|
||||
}
|
||||
};
|
||||
|
||||
let use_raw_value = || -> Result<String, ImageFilterFromStringError> {
|
||||
optional_raw_value.ok_or_else(|| ImageFilterFromStringError::FilterValueMissing {
|
||||
filter_name: filter_name.clone(),
|
||||
})
|
||||
};
|
||||
|
||||
match filter_name.as_str() {
|
||||
"scale" => result.transform.scale = parse_f32_filter_value(&filter_name, use_raw_value()?, "0..=100", Box::new(|v| (0.0..=100.0).contains(v)))?,
|
||||
"rotate" => {
|
||||
let raw_value = use_raw_value()?;
|
||||
let value = i32::from_str(&raw_value).map_err(|_| ImageFilterFromStringError::FilterValueNotParsable {
|
||||
filter_name: filter_name.to_string(),
|
||||
raw_value,
|
||||
})?;
|
||||
|
||||
result.transform.clockwise_quarter_rotations = match value {
|
||||
0 => 0,
|
||||
90 => 1,
|
||||
180 => 2,
|
||||
270 | -90 => 3,
|
||||
_ => return Err(ImageFilterFromStringError::RotationNotAllowed),
|
||||
};
|
||||
}
|
||||
"alpha" => result.transform.alpha = parse_f32_filter_value(&filter_name, use_raw_value()?, "0..<1", Box::new(|v| (0.0..1.0).contains(v)))?,
|
||||
"color" => {
|
||||
let raw_value = use_raw_value()?;
|
||||
|
||||
result.destructive.color = Some(
|
||||
RGB8Wrapper::from_str(&raw_value).map_err(|_| ImageFilterFromStringError::FilterValueNotParsable {
|
||||
filter_name: filter_name.to_string(),
|
||||
raw_value,
|
||||
})?,
|
||||
)
|
||||
}
|
||||
"grayscale" => result.destructive.grayscale = use_bool_value()?,
|
||||
"invert" => result.destructive.invert = use_bool_value()?,
|
||||
_ => return Err(ImageFilterFromStringError::UnknownFilter { name: filter_name }),
|
||||
};
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ImageFilter {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
let mut is_first = true;
|
||||
|
||||
if self.transform.scale != DEFAULT_IMAGE_FILTER.transform.scale {
|
||||
if !is_first {
|
||||
f.write_str("|")?
|
||||
}
|
||||
f.write_fmt(format_args!("scale={}", self.transform.scale))?;
|
||||
is_first = false;
|
||||
}
|
||||
|
||||
if self.transform.clockwise_quarter_rotations != DEFAULT_IMAGE_FILTER.transform.clockwise_quarter_rotations {
|
||||
if !is_first {
|
||||
f.write_str("|")?
|
||||
}
|
||||
|
||||
f.write_fmt(format_args!("rotate={}", self.transform.clockwise_quarter_rotations as u16 * 90))?;
|
||||
is_first = false;
|
||||
}
|
||||
|
||||
if self.transform.alpha != DEFAULT_IMAGE_FILTER.transform.alpha {
|
||||
if !is_first {
|
||||
f.write_str("|")?
|
||||
}
|
||||
f.write_fmt(format_args!("alpha={}", self.transform.alpha))?;
|
||||
is_first = false;
|
||||
}
|
||||
|
||||
if let Some(color) = self.destructive.color {
|
||||
if !is_first {
|
||||
f.write_str("|")?
|
||||
}
|
||||
|
||||
f.write_fmt(format_args!("color={}", color))?;
|
||||
is_first = false;
|
||||
}
|
||||
|
||||
if self.destructive.grayscale {
|
||||
if !is_first {
|
||||
f.write_str("|")?
|
||||
}
|
||||
f.write_str("grayscale")?;
|
||||
is_first = false;
|
||||
}
|
||||
|
||||
if self.destructive.invert {
|
||||
if !is_first {
|
||||
f.write_str("|")?
|
||||
}
|
||||
f.write_str("invert")?;
|
||||
is_first = false;
|
||||
}
|
||||
|
||||
if let Some(color) = self.destructive.color {
|
||||
if !is_first {
|
||||
f.write_str("|")?
|
||||
}
|
||||
|
||||
f.write_fmt(format_args!("color={}", color))?;
|
||||
// is_first = false;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
7
crates/deckster_shared/src/lib.rs
Normal file
7
crates/deckster_shared/src/lib.rs
Normal file
|
@ -0,0 +1,7 @@
|
|||
pub mod handler_communication;
|
||||
pub mod icon_descriptor;
|
||||
pub mod image_filter;
|
||||
pub mod path;
|
||||
pub mod rgb;
|
||||
pub mod state;
|
||||
pub mod style;
|
43
crates/deckster_shared/src/path.rs
Normal file
43
crates/deckster_shared/src/path.rs
Normal file
|
@ -0,0 +1,43 @@
|
|||
use enum_map::Enum;
|
||||
use enum_ordinalize::Ordinalize;
|
||||
use parse_display::{Display, FromStr};
|
||||
use serde_with::{DeserializeFromStr, SerializeDisplay};
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Display, FromStr, SerializeDisplay, DeserializeFromStr)]
|
||||
/// One-based coordinates of a specific virtual (not physical) key.
|
||||
#[display("{x}x{y}")]
|
||||
pub struct KeyPosition {
|
||||
pub x: u16,
|
||||
pub y: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Hash, Clone, Display, FromStr, SerializeDisplay, DeserializeFromStr)]
|
||||
#[display("{page_id}/{position}")]
|
||||
pub struct KeyPath {
|
||||
pub page_id: String,
|
||||
pub position: KeyPosition,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Enum, Ordinalize, Display, FromStr, SerializeDisplay, DeserializeFromStr)]
|
||||
#[display(style = "kebab-case")]
|
||||
pub enum KnobPosition {
|
||||
LeftTop,
|
||||
LeftMiddle,
|
||||
LeftBottom,
|
||||
RightTop,
|
||||
RightMiddle,
|
||||
RightBottom,
|
||||
}
|
||||
|
||||
impl KnobPosition {
|
||||
pub fn is_left(&self) -> bool {
|
||||
matches!(self, KnobPosition::LeftBottom | KnobPosition::LeftMiddle | KnobPosition::LeftTop)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Hash, Clone, Display, FromStr, SerializeDisplay, DeserializeFromStr)]
|
||||
#[display("{page_id}/{position}")]
|
||||
pub struct KnobPath {
|
||||
pub page_id: String,
|
||||
pub position: KnobPosition,
|
||||
}
|
108
crates/deckster_shared/src/rgb.rs
Normal file
108
crates/deckster_shared/src/rgb.rs
Normal file
|
@ -0,0 +1,108 @@
|
|||
use std::fmt::{Display, Formatter};
|
||||
use std::str::FromStr;
|
||||
|
||||
use derive_more::{Deref, From, Into};
|
||||
use rgb::{RGB8, RGBA8};
|
||||
use serde_with::{DeserializeFromStr, SerializeDisplay};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error("The input value does not match the required format.")]
|
||||
pub struct ParsingError {}
|
||||
|
||||
fn parse_rgb8_from_hex_str(s: &str) -> Result<RGB8, ParsingError> {
|
||||
let first_index = if s.starts_with('#') { 1 } else { 0 };
|
||||
if s.len() - first_index == 6 {
|
||||
let r = u8::from_str_radix(&s[first_index..(first_index + 2)], 16).map_err(|_| ParsingError {})?;
|
||||
let g = u8::from_str_radix(&s[(first_index + 2)..(first_index + 4)], 16).map_err(|_| ParsingError {})?;
|
||||
let b = u8::from_str_radix(&s[(first_index + 4)..(first_index + 6)], 16).map_err(|_| ParsingError {})?;
|
||||
|
||||
return Ok(RGB8::new(r, g, b));
|
||||
}
|
||||
|
||||
Err(ParsingError {})
|
||||
}
|
||||
|
||||
fn fmt_rgb8_as_hex_string(v: &RGB8, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_fmt(format_args!("{:#04x}{:#04x}{:#04x}", v.r, v.g, v.b))
|
||||
}
|
||||
|
||||
fn parse_rgba8_from_hex_str(s: &str) -> Result<RGBA8, ParsingError> {
|
||||
let first_index = if s.starts_with('#') { 1 } else { 0 };
|
||||
if s.len() - first_index == 8 {
|
||||
let r = u8::from_str_radix(&s[first_index..(first_index + 2)], 16).map_err(|_| ParsingError {})?;
|
||||
let g = u8::from_str_radix(&s[(first_index + 2)..(first_index + 4)], 16).map_err(|_| ParsingError {})?;
|
||||
let b = u8::from_str_radix(&s[(first_index + 4)..(first_index + 6)], 16).map_err(|_| ParsingError {})?;
|
||||
let a = u8::from_str_radix(&s[(first_index + 6)..(first_index + 8)], 16).map_err(|_| ParsingError {})?;
|
||||
|
||||
return Ok(RGBA8::new(r, g, b, a));
|
||||
}
|
||||
|
||||
Err(ParsingError {})
|
||||
}
|
||||
|
||||
fn fmt_rgba8_as_hex_string(v: &RGBA8, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_fmt(format_args!("{:#04x}{:#04x}{:#04x}{:#04x}", v.r, v.g, v.b, v.a))
|
||||
}
|
||||
|
||||
fn parse_rgb8_with_optional_alpha_from_hex_str(s: &str, fallback_alpha: u8) -> Result<RGBA8, ParsingError> {
|
||||
// optionally +1 for the '#'
|
||||
match s.len() {
|
||||
6 | 7 => Ok(parse_rgb8_from_hex_str(s)?.alpha(fallback_alpha)),
|
||||
8 | 9 => Ok(parse_rgba8_from_hex_str(s)?),
|
||||
_ => Err(ParsingError {}),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Deref, From, Into, SerializeDisplay, DeserializeFromStr)]
|
||||
pub struct RGB8Wrapper(RGB8);
|
||||
|
||||
impl FromStr for RGB8Wrapper {
|
||||
type Err = ParsingError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
parse_rgb8_from_hex_str(s).map(RGB8Wrapper)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for RGB8Wrapper {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
fmt_rgb8_as_hex_string(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Deref, From, Into, SerializeDisplay, DeserializeFromStr)]
|
||||
#[repr(transparent)]
|
||||
pub struct RGBA8Wrapper(pub RGBA8);
|
||||
|
||||
impl FromStr for RGBA8Wrapper {
|
||||
type Err = ParsingError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
parse_rgba8_from_hex_str(s).map(RGBA8Wrapper)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for RGBA8Wrapper {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
fmt_rgba8_as_hex_string(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Deref, From, Into, SerializeDisplay, DeserializeFromStr)]
|
||||
#[repr(transparent)]
|
||||
pub struct RGB8WithOptionalA(RGBA8);
|
||||
|
||||
impl FromStr for RGB8WithOptionalA {
|
||||
type Err = ParsingError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
parse_rgb8_with_optional_alpha_from_hex_str(s, 0xff).map(RGB8WithOptionalA)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for RGB8WithOptionalA {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
fmt_rgba8_as_hex_string(&self.0, f)
|
||||
}
|
||||
}
|
22
crates/deckster_shared/src/state.rs
Normal file
22
crates/deckster_shared/src/state.rs
Normal file
|
@ -0,0 +1,22 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use crate::path::{KeyPath, KnobPath};
|
||||
use crate::style::{KeyStyle, KnobStyle};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Key {
|
||||
pub path: KeyPath,
|
||||
pub base_style: KeyStyle,
|
||||
pub style: Option<KeyStyle>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Knob {
|
||||
pub path: KnobPath,
|
||||
pub base_style: KnobStyle,
|
||||
pub style: Option<KnobStyle>,
|
||||
pub value: Option<f32>,
|
||||
}
|
||||
|
||||
pub type KeyStyleByStateMap<State> = HashMap<State, KeyStyle>;
|
||||
pub type KnobStyleByStateMap<State> = HashMap<State, KnobStyle>;
|
101
crates/deckster_shared/src/style.rs
Normal file
101
crates/deckster_shared/src/style.rs
Normal file
|
@ -0,0 +1,101 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::icon_descriptor::IconDescriptor;
|
||||
use crate::rgb::RGB8WithOptionalA;
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct KeyStyle {
|
||||
pub label: Option<String>,
|
||||
pub icon: Option<IconDescriptor>,
|
||||
pub border: Option<RGB8WithOptionalA>,
|
||||
}
|
||||
|
||||
impl KeyStyle {
|
||||
pub fn merge_over(&self, base: &KeyStyle) -> KeyStyle {
|
||||
KeyStyle {
|
||||
label: self.label.as_ref().or(base.label.as_ref()).cloned(),
|
||||
icon: self.icon.as_ref().or(base.icon.as_ref()).cloned(),
|
||||
border: self.border.or(base.border),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct KnobStyle {
|
||||
pub label: Option<String>,
|
||||
pub icon: Option<IconDescriptor>,
|
||||
pub indicators: Option<KnobIndicators>,
|
||||
}
|
||||
|
||||
impl KnobStyle {
|
||||
pub fn merge_over(&self, base: &Self) -> Self {
|
||||
Self {
|
||||
label: self.label.as_ref().or(base.label.as_ref()).cloned(),
|
||||
icon: self.icon.as_ref().or(base.icon.as_ref()).cloned(),
|
||||
indicators: self
|
||||
.indicators
|
||||
.as_ref()
|
||||
.zip(base.indicators.as_ref())
|
||||
.map(|(a, b)| a.merge_over(b))
|
||||
.or_else(|| self.indicators.clone())
|
||||
.or_else(|| base.indicators.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct KnobIndicators {
|
||||
pub bar: Option<KnobIndicatorBarConfig>,
|
||||
pub circle: Option<KnobIndicatorCircleConfig>,
|
||||
}
|
||||
|
||||
impl KnobIndicators {
|
||||
pub fn merge_over(&self, base: &Self) -> Self {
|
||||
Self {
|
||||
bar: self
|
||||
.bar
|
||||
.as_ref()
|
||||
.zip(base.bar.as_ref())
|
||||
.map(|(a, b)| a.merge_over(b))
|
||||
.or_else(|| self.bar.clone())
|
||||
.or_else(|| base.bar.clone()),
|
||||
circle: self
|
||||
.circle
|
||||
.as_ref()
|
||||
.zip(base.circle.as_ref())
|
||||
.map(|(a, b)| a.merge_over(b))
|
||||
.or_else(|| self.circle.clone())
|
||||
.or_else(|| base.circle.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct KnobIndicatorBarConfig {
|
||||
pub color: Option<RGB8WithOptionalA>,
|
||||
}
|
||||
|
||||
impl KnobIndicatorBarConfig {
|
||||
pub fn merge_over(&self, base: &Self) -> Self {
|
||||
Self {
|
||||
color: self.color.as_ref().or(base.color.as_ref()).cloned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct KnobIndicatorCircleConfig {
|
||||
pub color: Option<RGB8WithOptionalA>,
|
||||
pub width: Option<u8>,
|
||||
pub radius: Option<u8>,
|
||||
}
|
||||
|
||||
impl KnobIndicatorCircleConfig {
|
||||
pub fn merge_over(&self, base: &Self) -> Self {
|
||||
Self {
|
||||
color: self.color.as_ref().or(base.color.as_ref()).cloned(),
|
||||
width: self.width.as_ref().or(base.width.as_ref()).cloned(),
|
||||
radius: self.radius.as_ref().or(base.radius.as_ref()).cloned(),
|
||||
}
|
||||
}
|
||||
}
|
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,
|
||||
}
|
10
crates/pa_volume_interface/Cargo.toml
Normal file
10
crates/pa_volume_interface/Cargo.toml
Normal file
|
@ -0,0 +1,10 @@
|
|||
[package]
|
||||
name = "pa_volume_interface"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
flume = "0.11.0"
|
||||
im = "15.1.0"
|
||||
tokio = { version = "1.35.1", default-features = false, features = ["sync"] }
|
||||
libpulse-binding = "2.28.1"
|
525
crates/pa_volume_interface/src/lib.rs
Normal file
525
crates/pa_volume_interface/src/lib.rs
Normal file
|
@ -0,0 +1,525 @@
|
|||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread;
|
||||
use std::time::Instant;
|
||||
|
||||
use im::HashMap;
|
||||
use libpulse_binding::callbacks::ListResult;
|
||||
use libpulse_binding::context::introspect::{Introspector, SinkInfo, SinkInputInfo, SourceInfo};
|
||||
use libpulse_binding::context::subscribe::{Facility, InterestMaskSet};
|
||||
use libpulse_binding::context::{subscribe, Context, FlagSet};
|
||||
use libpulse_binding::def::Retval;
|
||||
use libpulse_binding::mainloop::standard::{IterateResult, Mainloop};
|
||||
use libpulse_binding::operation::Operation;
|
||||
use libpulse_binding::volume::{ChannelVolumes, Volume};
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::broadcast::Receiver;
|
||||
|
||||
pub type PaEntityId = u32;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Copy, Clone)]
|
||||
pub enum PaEntityKind {
|
||||
Source,
|
||||
Sink,
|
||||
SinkInput,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Clone)]
|
||||
pub enum PaEntityMetadata {
|
||||
Source {
|
||||
name: String,
|
||||
description: String,
|
||||
},
|
||||
Sink {
|
||||
name: String,
|
||||
description: String,
|
||||
},
|
||||
SinkInput {
|
||||
description: String,
|
||||
binary_name: Option<String>,
|
||||
application_name: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PaEntityState {
|
||||
id: PaEntityId,
|
||||
channel_volumes: ChannelVolumes,
|
||||
is_muted: bool,
|
||||
metadata: PaEntityMetadata,
|
||||
}
|
||||
|
||||
impl PaEntityState {
|
||||
pub fn id(&self) -> &PaEntityId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> PaEntityKind {
|
||||
match &self.metadata {
|
||||
PaEntityMetadata::Source { .. } => PaEntityKind::Source,
|
||||
PaEntityMetadata::Sink { .. } => PaEntityKind::Sink,
|
||||
PaEntityMetadata::SinkInput { .. } => PaEntityKind::SinkInput,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn metadata(&self) -> &PaEntityMetadata {
|
||||
&self.metadata
|
||||
}
|
||||
|
||||
pub fn channel_volumes(&self) -> Vec<f32> {
|
||||
self.channel_volumes.get().iter().map(|v| v.0 as f32 / Volume::NORMAL.0 as f32).collect()
|
||||
}
|
||||
|
||||
pub fn is_muted(&self) -> bool {
|
||||
self.is_muted
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&SourceInfo<'_>> for PaEntityState {
|
||||
fn from(value: &SourceInfo) -> Self {
|
||||
PaEntityState {
|
||||
id: value.index as PaEntityId,
|
||||
is_muted: value.mute,
|
||||
channel_volumes: value.volume,
|
||||
metadata: PaEntityMetadata::Source {
|
||||
name: value.name.clone().unwrap_or_default().into_owned(),
|
||||
description: value.description.clone().unwrap_or_default().into_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&SinkInfo<'_>> for PaEntityState {
|
||||
fn from(value: &SinkInfo) -> Self {
|
||||
PaEntityState {
|
||||
id: value.index as PaEntityId,
|
||||
is_muted: value.mute,
|
||||
channel_volumes: value.volume,
|
||||
metadata: PaEntityMetadata::Sink {
|
||||
name: value.name.clone().unwrap_or_default().into_owned(),
|
||||
description: value.description.clone().unwrap_or_default().into_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&SinkInputInfo<'_>> for PaEntityState {
|
||||
fn from(value: &SinkInputInfo) -> Self {
|
||||
PaEntityState {
|
||||
id: value.index as PaEntityId,
|
||||
is_muted: value.mute,
|
||||
channel_volumes: value.volume,
|
||||
metadata: PaEntityMetadata::SinkInput {
|
||||
description: value.name.clone().unwrap_or_default().into_owned(),
|
||||
application_name: value
|
||||
.proplist
|
||||
.get("application.name")
|
||||
.map(|v| String::from_utf8_lossy(v).trim_end_matches(char::from(0)).to_owned()),
|
||||
binary_name: value
|
||||
.proplist
|
||||
.get("application.process.binary")
|
||||
.map(|v| String::from_utf8_lossy(v).trim_end_matches(char::from(0)).to_owned()),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PaVolumeState {
|
||||
timestamp: Instant,
|
||||
entities_by_id: HashMap<PaEntityId, Arc<PaEntityState>>,
|
||||
default_sink_id: PaEntityId,
|
||||
default_source_id: PaEntityId,
|
||||
}
|
||||
|
||||
impl PaVolumeState {
|
||||
pub fn timestamp(&self) -> &Instant {
|
||||
&self.timestamp
|
||||
}
|
||||
|
||||
pub fn entities_by_id(&self) -> &HashMap<PaEntityId, Arc<PaEntityState>> {
|
||||
&self.entities_by_id
|
||||
}
|
||||
|
||||
pub fn default_sink_id(&self) -> &PaEntityId {
|
||||
&self.default_sink_id
|
||||
}
|
||||
|
||||
pub fn default_source_id(&self) -> &PaEntityId {
|
||||
&self.default_source_id
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PaVolumeState {
|
||||
fn default() -> Self {
|
||||
PaVolumeState {
|
||||
timestamp: Instant::now(),
|
||||
entities_by_id: HashMap::new(),
|
||||
default_sink_id: 0,
|
||||
default_source_id: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum PaCommand {
|
||||
SetIsMuted { id: PaEntityId, value: bool },
|
||||
SetChannelVolumes { id: PaEntityId, channel_volumes: Box<[f32]> },
|
||||
Terminate,
|
||||
}
|
||||
|
||||
struct PaThread {
|
||||
mainloop: Rc<RefCell<Mainloop>>,
|
||||
context: Rc<RefCell<Context>>,
|
||||
introspector: Rc<RefCell<Introspector>>,
|
||||
commands_rx: flume::Receiver<PaCommand>,
|
||||
state_tx: broadcast::Sender<Arc<PaVolumeState>>,
|
||||
current_state: Arc<RwLock<Option<Arc<PaVolumeState>>>>,
|
||||
}
|
||||
|
||||
impl PaThread {
|
||||
fn spawn(
|
||||
client_name: String,
|
||||
commands_rx: flume::Receiver<PaCommand>,
|
||||
state_tx: broadcast::Sender<Arc<PaVolumeState>>,
|
||||
current_state: Arc<RwLock<Option<Arc<PaVolumeState>>>>,
|
||||
) {
|
||||
thread::spawn(move || {
|
||||
let mainloop = Mainloop::new().unwrap();
|
||||
let context = Context::new(&mainloop, &client_name).unwrap();
|
||||
let introspector = context.introspect();
|
||||
|
||||
let mut t = PaThread {
|
||||
mainloop: Rc::new(RefCell::new(mainloop)),
|
||||
context: Rc::new(RefCell::new(context)),
|
||||
introspector: Rc::new(RefCell::new(introspector)),
|
||||
commands_rx,
|
||||
state_tx,
|
||||
current_state,
|
||||
};
|
||||
|
||||
t.init();
|
||||
t.run();
|
||||
});
|
||||
}
|
||||
|
||||
fn init(&mut self) {
|
||||
self.context.borrow_mut().connect(None, FlagSet::NOFLAGS, None).unwrap();
|
||||
self.wait_for(|s| s.context.borrow().get_state() == libpulse_binding::context::State::Ready);
|
||||
|
||||
let introspector = Rc::clone(&self.introspector);
|
||||
|
||||
{
|
||||
let entities_by_id = Rc::new(RefCell::new(HashMap::<PaEntityId, Arc<PaEntityState>>::new()));
|
||||
let entities_by_id_c = Rc::clone(&entities_by_id);
|
||||
|
||||
self.wait_for_operation(introspector.borrow().get_sink_info_list(move |list_result| match list_result {
|
||||
ListResult::Error => panic!("Introspector.get_sink_info_list failed"),
|
||||
ListResult::End => {}
|
||||
ListResult::Item(sink) => {
|
||||
entities_by_id_c.borrow_mut().insert(sink.index as PaEntityId, Arc::new(sink.into()));
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let entities_by_id_c = Rc::clone(&entities_by_id);
|
||||
self.wait_for_operation(introspector.borrow().get_source_info_list(move |list_result| match list_result {
|
||||
ListResult::Error => panic!("Introspector.get_source_info_list failed"),
|
||||
ListResult::End => {}
|
||||
ListResult::Item(source) => {
|
||||
entities_by_id_c.borrow_mut().insert(source.index as PaEntityId, Arc::new(source.into()));
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let entities_by_id_c = Rc::clone(&entities_by_id);
|
||||
self.wait_for_operation(introspector.borrow().get_sink_input_info_list(move |list_result| match list_result {
|
||||
ListResult::Error => panic!("Introspector.get_sink_input_info_list failed"),
|
||||
ListResult::End => {}
|
||||
ListResult::Item(sink_input) => {
|
||||
let id = sink_input.index as PaEntityId;
|
||||
entities_by_id_c.borrow_mut().insert(id, Arc::new(sink_input.into()));
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
PaThread::set_state(
|
||||
&self.current_state,
|
||||
&self.state_tx,
|
||||
Arc::new(PaVolumeState {
|
||||
timestamp: Instant::now(),
|
||||
entities_by_id: Rc::into_inner(entities_by_id).unwrap().into_inner(),
|
||||
default_sink_id: 0,
|
||||
default_source_id: 0,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
self.context
|
||||
.borrow_mut()
|
||||
.subscribe(InterestMaskSet::SINK | InterestMaskSet::SOURCE | InterestMaskSet::SINK_INPUT, |success| {
|
||||
if !success {
|
||||
panic!("Context.subscribe failed")
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn run(mut self) {
|
||||
let introspector = Rc::clone(&self.introspector);
|
||||
let current_state = Arc::clone(&self.current_state);
|
||||
let state_tx = self.state_tx.clone();
|
||||
|
||||
self.context
|
||||
.borrow_mut()
|
||||
.set_subscribe_callback(Some(Box::new(move |facility, operation, entity_id| {
|
||||
let entity_id = entity_id as PaEntityId;
|
||||
let facility = facility.unwrap();
|
||||
|
||||
let timestamp = Instant::now();
|
||||
let current_state = Arc::clone(¤t_state);
|
||||
|
||||
match operation.unwrap() {
|
||||
subscribe::Operation::Removed => {
|
||||
let state = PaThread::unwrap_state(¤t_state);
|
||||
|
||||
let entities_by_id = state.entities_by_id.without(&entity_id);
|
||||
let default_source_id = if entity_id == state.default_source_id { 0 } else { state.default_source_id };
|
||||
let default_sink_id = if entity_id == state.default_sink_id { 0 } else { state.default_sink_id };
|
||||
|
||||
PaThread::set_state(
|
||||
¤t_state,
|
||||
&state_tx,
|
||||
Arc::new(PaVolumeState {
|
||||
timestamp,
|
||||
entities_by_id,
|
||||
default_source_id,
|
||||
default_sink_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
subscribe::Operation::New | subscribe::Operation::Changed => {
|
||||
let state_tx = state_tx.clone();
|
||||
|
||||
match facility {
|
||||
Facility::SinkInput => {
|
||||
introspector.borrow().get_sink_input_info(entity_id, move |list_result| match list_result {
|
||||
ListResult::Error => panic!("Introspector.get_sink_input_info failed"),
|
||||
ListResult::End => {}
|
||||
ListResult::Item(sink_input) => {
|
||||
let state = PaThread::unwrap_state(¤t_state);
|
||||
let id = sink_input.index as PaEntityId;
|
||||
|
||||
let entities_by_id = state.entities_by_id.update(id, Arc::new(sink_input.into()));
|
||||
PaThread::set_state(
|
||||
¤t_state,
|
||||
&state_tx,
|
||||
Arc::new(PaVolumeState {
|
||||
timestamp,
|
||||
entities_by_id,
|
||||
..*state
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
Facility::Sink => {
|
||||
introspector.borrow().get_sink_info_by_index(entity_id, move |list_result| match list_result {
|
||||
ListResult::Error => panic!("Introspector.get_sink_info_by_index failed"),
|
||||
ListResult::End => {}
|
||||
ListResult::Item(sink_input) => {
|
||||
let state = PaThread::unwrap_state(¤t_state);
|
||||
let id = sink_input.index as PaEntityId;
|
||||
|
||||
let entities_by_id = state.entities_by_id.update(id, Arc::new(sink_input.into()));
|
||||
PaThread::set_state(
|
||||
¤t_state,
|
||||
&state_tx,
|
||||
Arc::new(PaVolumeState {
|
||||
timestamp,
|
||||
entities_by_id,
|
||||
..*state
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
Facility::Source => {
|
||||
introspector.borrow().get_source_info_by_index(entity_id, move |list_result| match list_result {
|
||||
ListResult::Error => panic!("Introspector.get_source_info_by_index failed"),
|
||||
ListResult::End => {}
|
||||
ListResult::Item(sink_input) => {
|
||||
let state = PaThread::unwrap_state(¤t_state);
|
||||
let id = sink_input.index as PaEntityId;
|
||||
|
||||
let entities_by_id = state.entities_by_id.update(id, Arc::new(sink_input.into()));
|
||||
PaThread::set_state(
|
||||
¤t_state,
|
||||
&state_tx,
|
||||
Arc::new(PaVolumeState {
|
||||
timestamp,
|
||||
entities_by_id,
|
||||
..*state
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
};
|
||||
})));
|
||||
|
||||
let current_state = Arc::clone(&self.current_state);
|
||||
let mainloop = Rc::clone(&self.mainloop);
|
||||
|
||||
loop {
|
||||
self.run_single_mainloop_iteration(false);
|
||||
|
||||
while let Ok(command) = self.commands_rx.try_recv() {
|
||||
match command {
|
||||
PaCommand::SetIsMuted { id, value } => {
|
||||
if let Some(state) = PaThread::unwrap_state(¤t_state).entities_by_id.get(&id) {
|
||||
match state.kind() {
|
||||
PaEntityKind::Sink => self.introspector.borrow_mut().set_sink_mute_by_index(id, value, None),
|
||||
PaEntityKind::Source => self.introspector.borrow_mut().set_source_mute_by_index(id, value, None),
|
||||
PaEntityKind::SinkInput => self.introspector.borrow_mut().set_sink_input_mute(id, value, None),
|
||||
};
|
||||
}
|
||||
}
|
||||
PaCommand::SetChannelVolumes { id, channel_volumes } => {
|
||||
if let Some(state) = PaThread::unwrap_state(¤t_state).entities_by_id.get(&id) {
|
||||
let mut value = state.channel_volumes;
|
||||
for (i, v) in channel_volumes.iter().enumerate() {
|
||||
value.set(i as u8, Volume((Volume::NORMAL.0 as f32 * v).floor() as u32));
|
||||
}
|
||||
|
||||
match state.kind() {
|
||||
PaEntityKind::Sink => self.introspector.borrow_mut().set_sink_volume_by_index(id, &value, None),
|
||||
PaEntityKind::Source => self.introspector.borrow_mut().set_source_volume_by_index(id, &value, None),
|
||||
PaEntityKind::SinkInput => self.introspector.borrow_mut().set_sink_input_volume(id, &value, None),
|
||||
};
|
||||
}
|
||||
}
|
||||
PaCommand::Terminate => {
|
||||
mainloop.borrow_mut().quit(Retval(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_state(state: &Arc<RwLock<Option<Arc<PaVolumeState>>>>) -> Arc<PaVolumeState> {
|
||||
state
|
||||
.read()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.expect("this function is only called after the initial state was set")
|
||||
}
|
||||
|
||||
fn set_state(current_state: &RwLock<Option<Arc<PaVolumeState>>>, state_tx: &broadcast::Sender<Arc<PaVolumeState>>, value: Arc<PaVolumeState>) {
|
||||
let mut s = current_state.write().unwrap();
|
||||
*s = Some(Arc::clone(&value));
|
||||
state_tx.send(value).unwrap();
|
||||
}
|
||||
|
||||
fn run_single_mainloop_iteration(&mut self, block: bool) {
|
||||
match self.mainloop.borrow_mut().iterate(block) {
|
||||
IterateResult::Quit(_) => {
|
||||
panic!("Mainloop quit.")
|
||||
}
|
||||
IterateResult::Err(e) => {
|
||||
panic!("Mainloop error: {}", e)
|
||||
}
|
||||
IterateResult::Success(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for(&mut self, predicate: impl Fn(&mut Self) -> bool) {
|
||||
loop {
|
||||
self.run_single_mainloop_iteration(true);
|
||||
|
||||
if predicate(self) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_operation<T: ?Sized>(&mut self, operation: Operation<T>) -> Result<(), ()> {
|
||||
loop {
|
||||
self.run_single_mainloop_iteration(true);
|
||||
|
||||
match operation.get_state() {
|
||||
libpulse_binding::operation::State::Running => {}
|
||||
libpulse_binding::operation::State::Done => return Ok(()),
|
||||
libpulse_binding::operation::State::Cancelled => return Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PaWorker {
|
||||
commands_tx: flume::Sender<PaCommand>,
|
||||
}
|
||||
|
||||
impl Drop for PaWorker {
|
||||
fn drop(&mut self) {
|
||||
self.commands_tx.send(PaCommand::Terminate).ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PaVolumeInterface {
|
||||
#[allow(unused)]
|
||||
worker: Arc<PaWorker>,
|
||||
current_state: Arc<RwLock<Option<Arc<PaVolumeState>>>>,
|
||||
state_tx: broadcast::Sender<Arc<PaVolumeState>>,
|
||||
commands_tx: flume::Sender<PaCommand>,
|
||||
}
|
||||
|
||||
impl PaVolumeInterface {
|
||||
pub fn spawn_thread(client_name: String) -> PaVolumeInterface {
|
||||
let (commands_tx, commands_rx) = flume::unbounded();
|
||||
let state_tx = broadcast::Sender::new(5);
|
||||
let current_state = Arc::new(RwLock::new(None));
|
||||
|
||||
PaThread::spawn(client_name, commands_rx, state_tx.clone(), Arc::clone(¤t_state));
|
||||
|
||||
let worker = PaWorker {
|
||||
commands_tx: commands_tx.clone(),
|
||||
};
|
||||
|
||||
PaVolumeInterface {
|
||||
worker: Arc::new(worker),
|
||||
current_state,
|
||||
commands_tx,
|
||||
state_tx,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscribe_to_state(&self) -> (Option<Arc<PaVolumeState>>, Receiver<Arc<PaVolumeState>>) {
|
||||
let rx = self.state_tx.subscribe();
|
||||
let state = self.current_state();
|
||||
(state, rx)
|
||||
}
|
||||
|
||||
pub fn current_state(&self) -> Option<Arc<PaVolumeState>> {
|
||||
self.current_state.read().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn set_is_muted(&self, id: PaEntityId, value: bool) {
|
||||
self.commands_tx.send(PaCommand::SetIsMuted { id, value }).unwrap()
|
||||
}
|
||||
|
||||
pub fn set_channel_volumes(&self, id: PaEntityId, channel_volumes: impl Into<Box<[f32]>>) {
|
||||
self.commands_tx
|
||||
.send(PaCommand::SetChannelVolumes {
|
||||
id,
|
||||
channel_volumes: channel_volumes.into(),
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue