From 2782d406dddd7193f1bf0f489418aa94dbf63d98 Mon Sep 17 00:00:00 2001 From: 4yn Date: Thu, 17 Feb 2022 01:40:48 +0800 Subject: [PATCH] wip serial --- src-slider_io/src/bin/test_diva.rs | 26 ++- src-slider_io/src/bin/test_diy_serial.rs | 47 +++++ src-slider_io/src/bin/test_raw_serial.rs | 102 ++++++++++ src-slider_io/src/context.rs | 4 +- src-slider_io/src/device/config.rs | 2 + src-slider_io/src/device/diva.rs | 248 ++++++++++++++--------- src-slider_io/src/shared/utils.rs | 2 +- src-tauri/src/main.rs | 2 +- src-tauri/tauri.conf.json | 2 +- src/App.svelte | 31 ++- 10 files changed, 358 insertions(+), 108 deletions(-) create mode 100644 src-slider_io/src/bin/test_diy_serial.rs create mode 100644 src-slider_io/src/bin/test_raw_serial.rs diff --git a/src-slider_io/src/bin/test_diva.rs b/src-slider_io/src/bin/test_diva.rs index 77285d3..e1adf36 100644 --- a/src-slider_io/src/bin/test_diva.rs +++ b/src-slider_io/src/bin/test_diva.rs @@ -4,7 +4,10 @@ use std::io; use slider_io::{ device::diva, - shared::{utils::LoopTimer, worker::ThreadWorker}, + shared::{ + utils::LoopTimer, + worker::{ThreadJob, ThreadWorker}, + }, state::SliderState, }; @@ -14,14 +17,23 @@ fn main() { .init(); let state = SliderState::new(); + let mut job = diva::DivaSliderJob::new(&state, &"COM4".to_string(), 0x3f); - let timer = LoopTimer::new(); - let _worker = ThreadWorker::new( - "d", - diva::DivaSliderJob::new(&state, &"COM5".to_string()), - timer, - ); + let ok = job.setup(); + while ok { + job.tick(); + } + // let state = SliderState::new(); + + // let timer = LoopTimer::new(); + // let _worker = ThreadWorker::new( + // "d", + // diva::DivaSliderJob::new(&state, &"COM4".to_string(), 0x3f), + // timer, + // ); + + println!("Press enter to quit"); let mut input = String::new(); io::stdin().read_line(&mut input).unwrap(); } diff --git a/src-slider_io/src/bin/test_diy_serial.rs b/src-slider_io/src/bin/test_diy_serial.rs new file mode 100644 index 0000000..f30ae5e --- /dev/null +++ b/src-slider_io/src/bin/test_diy_serial.rs @@ -0,0 +1,47 @@ +use std::{mem, ptr}; +use winapi::um::{commapi::*, fileapi::*, minwinbase::*, synchapi::*, winbase::*, winnt::*}; + +fn main() { + unsafe { + let mut port: Vec = vec![]; + port.extend("COM4".encode_utf16()); + port.push(0); + + let handle = CreateFileW( + port.as_ptr(), + GENERIC_READ | GENERIC_WRITE, + 0, + ptr::null_mut(), + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, + 0 as HANDLE, + ); + + let mut overlapped_read: OVERLAPPED = mem::zeroed(); + overlapped_read.hEvent = CreateEventW(ptr::null_mut(), 1, 0, ptr::null_mut()); + let mut overlapped_write: OVERLAPPED = mem::zeroed(); + overlapped_write.hEvent = CreateEventW(ptr::null_mut(), 0, 0, ptr::null_mut()); + + SetupComm(handle, 4096, 4096); + + let mut timeouts: COMMTIMEOUTS = mem::zeroed(); + GetCommTimeouts(handle, &mut timeouts); + + SetCommMask(handle, 0x80); // EV_ERR + + let mut dcb: DCB = mem::zeroed(); + GetCommState(handle, &mut dcb); + + dcb.BaudRate = 115200; + dcb.ByteSize = 8; + dcb.Parity = NOPARITY; + dcb.set_fParity(0); + dcb.StopBits = ONESTOPBIT; + dcb.set_fBinary(1); + + PurgeComm( + handle, + PURGE_TXCLEAR | PURGE_TXABORT | PURGE_RXCLEAR | PURGE_RXABORT, + ); + } +} diff --git a/src-slider_io/src/bin/test_raw_serial.rs b/src-slider_io/src/bin/test_raw_serial.rs new file mode 100644 index 0000000..6b3ad8b --- /dev/null +++ b/src-slider_io/src/bin/test_raw_serial.rs @@ -0,0 +1,102 @@ +extern crate slider_io; + +use std::{thread::sleep, time::Duration}; + +fn main() { + let mut sp = serialport::new("COM4", 115200).open().unwrap(); + sp.write_request_to_send(true).unwrap(); + sp.write_data_terminal_ready(true).unwrap(); + + sleep(Duration::from_millis(100)); + println!("tx"); + let res = sp.write(&[0xff, 0x01, 0x00, 0xf1]).unwrap(); + println!("tx {}", res); + + loop { + sleep(Duration::from_millis(100)); + println!("rx"); + let mut buf = [0u8; 4]; + let res = sp.read(&mut buf).unwrap(); + println!("rx {} {:?}", res, buf); + } +} + +// use serial2::SerialPort; + +// fn main() { +// let sp = SerialPort::open("COM4", 115200).unwrap(); +// println!("Tx"); +// let res = sp.write("data".as_bytes()).unwrap(); +// println!("Tx {}", res); +// loop { +// println!("Rx"); +// let mut buf = [0u8; 5]; +// let res = sp.read(&mut buf).unwrap(); +// // let res = sp.read(&mut buf).unwrap_or_else(|e| { +// // println!("Err {}", e); +// // 0 +// // }); +// println!("Rx {} {:?}", res, buf); +// } + +// let mut sp = serialport::new("COM4", 115200).open().unwrap(); +// println!("Tx"); +// let res = sp.write("data".as_bytes()).unwrap(); +// println!("Tx {}", res); +// loop { +// println!("Rx"); +// let mut buf = [0u8; 5]; +// let res = sp.read(&mut buf).unwrap(); +// println!("Rx {} {:?}", res, buf); +// } +// } + +// use serialport::SerialPort; +// use std::time::Duration; +// use tokio::{ +// io::{self, AsyncReadExt, AsyncWriteExt}, +// time::sleep, +// }; +// use tokio_serial::SerialPortBuilderExt; + +// #[tokio::main] +// async fn main() -> io::Result<()> { +// let mut sp = tokio_serial::new("COM4", 115200) +// .open_native_async() +// .unwrap(); + +// sp.write_request_to_send(true).unwrap(); +// sp.write_data_terminal_ready(true).unwrap(); +// // sp.set_timeout(Duration::from_millis(1000))?; +// let (mut rx_port, mut tx_port) = tokio::io::split(sp); + +// // tx_port.write(&[41, 42, 43, 44]).await?; +// // let mut buf = [0u8; 10]; +// // let res = rx_port.read(&mut buf).await?; +// // println!("{}, {:?}", res, buf); + +// loop { +// let mut buf = [0u8; 10]; +// let res = rx_port.read(&mut buf).await?; +// println!("{}, {:?}", res, buf); +// } + +// // let mut serial_reader = +// // tokio_util::codec::FramedRead::new(rx_port, +// // tokio_util::codec::BytesCodec::new()); let serial_sink = +// // tokio_util::codec::FramedWrite::new(tx_port, +// // tokio_util::codec::BytesCodec::new()); + +// // println!("Tx"); +// // let res = tx.write("data".as_bytes()).await?; +// // println!("Sent {}", res); +// // sleep(Duration::from_millis(1000)).await; +// // loop { +// // let mut buffer = [0u8; 1]; +// // println!("Rx"); +// // let res = rx.read(&mut buffer).await; +// // println!("{:?} {:?}", res, buffer); +// // } + +// Ok(()) +// } diff --git a/src-slider_io/src/context.rs b/src-slider_io/src/context.rs index 2a4559a..186c9e7 100644 --- a/src-slider_io/src/context.rs +++ b/src-slider_io/src/context.rs @@ -63,13 +63,13 @@ impl Context { None, None, ), - DeviceMode::DivaSlider { port } => ( + DeviceMode::DivaSlider { port, brightness } => ( { let timer = LoopTimer::new(); timers.push(("d", timer.fork())); Some(ThreadWorker::new( "diva", - DivaSliderJob::new(&state, port), + DivaSliderJob::new(&state, port, *brightness), timer, )) }, diff --git a/src-slider_io/src/device/config.rs b/src-slider_io/src/device/config.rs index 91974a2..7a1324f 100644 --- a/src-slider_io/src/device/config.rs +++ b/src-slider_io/src/device/config.rs @@ -19,6 +19,7 @@ pub enum DeviceMode { }, DivaSlider { port: String, + brightness: u8, }, } @@ -37,6 +38,7 @@ impl DeviceMode { }, "diva" => DeviceMode::DivaSlider { port: v["divaSerialPort"].as_str()?.to_string(), + brightness: u8::try_from(v["divaBrightness"].as_i64()?).ok()?, }, "brokenithm" => DeviceMode::Brokenithm { ground_only: false, diff --git a/src-slider_io/src/device/diva.rs b/src-slider_io/src/device/diva.rs index a449195..606d4fb 100644 --- a/src-slider_io/src/device/diva.rs +++ b/src-slider_io/src/device/diva.rs @@ -1,9 +1,8 @@ -use log::{error, info}; +use log::{debug, error, info, warn}; use serialport::{COMPort, SerialPort}; use std::{ collections::VecDeque, io::{Read, Write}, - num::Wrapping, thread::sleep, time::Duration, }; @@ -13,11 +12,23 @@ use crate::{ state::SliderState, }; +/* +Init packet +0xff 0x10 0x00 0xf1 + +Report of all touch sliders at 16 pressure +0xff 0x01 0x20 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0x10 0xe0 + +Report of all touch sliders at 0-31 pressure +0xff 0x01 0x20 0x00 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0x09 0x0a 0x0b 0x0c 0x0d 0x0e 0x0f 0x10 0x11 0x12 0x13 0x14 0x15 0x16 0x17 0x18 0x19 0x1a 0x1b 0x1c 0x1d 0x1e 0x1f 0x0f +*/ + +#[derive(Debug)] struct DivaPacket { command: u8, len: u8, data: Vec, - checksum: Wrapping, + checksum: u8, raw: Option>, } @@ -27,17 +38,23 @@ impl DivaPacket { command: 0, len: 0, data: Vec::with_capacity(256), - checksum: Wrapping(0), + checksum: 0, raw: None, } } fn from_bytes(command: u8, data: &[u8]) -> Self { + let checksum = 0xffu64 + + (command as u64) + + (data.len() as u64) + + data.iter().map(|x| (*x) as u64).sum::(); + let checksum = ((0x100 - (checksum & 0xff)) & 0xff) as u8; + Self { command, len: data.len() as u8, data: data.iter().copied().collect(), - checksum: Wrapping(0), + checksum: checksum, raw: None, } } @@ -60,23 +77,21 @@ impl DivaPacket { fn serialize(&mut self) -> &[u8] { let mut raw: Vec = Vec::with_capacity(512); - let mut checksum = Wrapping(0); raw.push(0xff); - checksum += Wrapping(0xffu8); Self::push_raw_escaped(self.command, &mut raw); - checksum += Wrapping(self.command); Self::push_raw_escaped(self.len, &mut raw); - checksum += Wrapping(self.len); for i in &self.data { Self::push_raw_escaped(*i, &mut raw); - checksum += Wrapping(*i); } + Self::push_raw_escaped(self.checksum, &mut raw); - checksum = -checksum; - Self::push_raw_escaped(checksum.0, &mut raw); + // null pad? + // raw.push(0); + debug!("Diva serializing {}", raw.len()); self.raw = Some(raw); + // debug!("Diva serializing {:?}", &self.raw); return self.raw.as_ref().unwrap(); } } @@ -91,6 +106,7 @@ enum DivaDeserializerState { struct DivaDeserializer { state: DivaDeserializerState, + checksum: u64, escape: u8, len: u8, packet: DivaPacket, @@ -100,7 +116,8 @@ impl DivaDeserializer { fn new() -> Self { Self { state: DivaDeserializerState::Done, - escape: 1, + checksum: 0, + escape: 0, len: 0, packet: DivaPacket::new(), } @@ -108,11 +125,13 @@ impl DivaDeserializer { fn deserialize(&mut self, data: &[u8], out: &mut VecDeque) { // println!("Found data"); + // debug!("Diva deserializing {:?}", data); + debug!("Diva deserializing {}", data.len()); for c in data { match c { 0xff => { self.packet = DivaPacket::new(); - self.packet.checksum = Wrapping(0xff); + self.checksum = 0xff; self.state = DivaDeserializerState::ExpectCommand; self.escape = 0; @@ -126,7 +145,7 @@ impl DivaDeserializer { let c = c + self.escape; self.escape = 0; - self.packet.checksum += Wrapping(c); + self.checksum += c as u64; match self.state { DivaDeserializerState::ExpectCommand => { self.packet.command = c; @@ -154,9 +173,11 @@ impl DivaDeserializer { // println!("data {}", c); } DivaDeserializerState::ExpectChecksum => { - // println!("checksum {} {:?}", c, self.packet.checksum); - debug_assert!(self.packet.checksum == Wrapping(0)); - if self.packet.checksum == Wrapping(0) { + self.packet.checksum = c; + debug_assert!(self.checksum & 0xff == 0); + // println!("Packet complete {} {}", self.checksum, c); + if self.checksum & 0xff == 0 { + // println!("Feeding packet"); out.push_back(DivaPacket::new()); std::mem::swap(&mut self.packet, out.back_mut().unwrap()); } @@ -174,25 +195,31 @@ enum DivaSliderBootstrap { Init, AwaitReset, AwaitInfo, - AwaitStart, ReadLoop, + Halt, } pub struct DivaSliderJob { state: SliderState, port: String, - packets: VecDeque, + brightness: u8, + // read_buf: Vec, + in_packets: VecDeque, + out_packets: VecDeque, deserializer: DivaDeserializer, serial_port: Option, bootstrap: DivaSliderBootstrap, } impl DivaSliderJob { - pub fn new(state: &SliderState, port: &String) -> Self { + pub fn new(state: &SliderState, port: &String, brightness: u8) -> Self { Self { state: state.clone(), port: port.clone(), - packets: VecDeque::with_capacity(100), + brightness, + // read_buf: vec![0u8; 1024], + in_packets: VecDeque::with_capacity(100), + out_packets: VecDeque::with_capacity(100), deserializer: DivaDeserializer::new(), serial_port: None, bootstrap: DivaSliderBootstrap::Init, @@ -207,11 +234,24 @@ impl ThreadJob for DivaSliderJob { self.port.as_str(), 115200 ); - match serialport::new(&self.port, 152000).open_native() { + match serialport::new(&self.port, 152000) + .flow_control(serialport::FlowControl::Hardware) + .open_native() + { Ok(serial_port) => { info!("Serial port opened"); + // serial_port.write_request_to_send(true).unwrap_or_else(|e| { + // error!("Serial request to send failed {}", e); + // }); + // serial_port + // .write_data_terminal_ready(true) + // .unwrap_or_else(|e| { + // error!("Serial data terminal ready failed {}", e); + // }); + // serial_port.set_timeout(Duration::from_millis(10)).ok(); + serial_port.clear(serialport::ClearBuffer::All).ok(); serial_port - .set_read_write_timeout(Duration::from_millis(3)) + .set_read_write_timeout(Duration::from_millis(100)) .ok(); self.serial_port = Some(serial_port); true @@ -228,93 +268,74 @@ impl ThreadJob for DivaSliderJob { let serial_port = self.serial_port.as_mut().unwrap(); - let bytes_avail = serial_port.bytes_to_read().unwrap_or(0); + let bytes_avail = serial_port.bytes_to_read().unwrap_or_else(|e| { + error!("Diva serial read error {}", e); + 0 + }); + // debug!("Serial read {} bytes", bytes_avail); if bytes_avail > 0 { let mut read_buf = vec![0 as u8; bytes_avail as usize]; - serial_port.read(&mut read_buf).ok(); - self.deserializer.deserialize(&read_buf, &mut self.packets); + serial_port.read_exact(&mut read_buf).unwrap(); + self + .deserializer + .deserialize(&read_buf, &mut self.in_packets); work = true; } + // let read_amount = serial_port.read(&mut self.read_buf).unwrap_or_else(|e| { + // error!("Read error {}", e); + // 0 + // }); + // debug!("Serial read {} bytes", read_amount); + // if read_amount > 0 { + // self + // .deserializer + // .deserialize(&self.read_buf[0..read_amount], &mut self.packets); + // } + match self.bootstrap { DivaSliderBootstrap::Init => { info!("Diva sending init"); - let mut reset_packet = DivaPacket::from_bytes(0x10, &[]); - match serial_port.write(reset_packet.serialize()) { - Ok(_) => { - info!("Diva sent init"); - - self.bootstrap = DivaSliderBootstrap::AwaitReset; - work = true; - } - Err(e) => { - error!("Diva send init error {}", e); - } - } - - // Wait for flush - sleep(Duration::from_millis(100)); + let reset_packet = DivaPacket::from_bytes(0x10, &[]); + self.out_packets.push_back(reset_packet); + self.bootstrap = DivaSliderBootstrap::AwaitReset; } DivaSliderBootstrap::AwaitReset => { - while self.packets.len() > 1 { - self.packets.pop_front(); - } - if let Some(ack_packet) = self.packets.pop_front() { - info!( - "Diva ack reset {:?} {:?}", - ack_packet.command, ack_packet.data - ); + while let Some(ack_packet) = self.in_packets.pop_front() { + if ack_packet.command == 0x10 && ack_packet.len == 0x00 && ack_packet.checksum == 0xf1 { + info!( + "Diva ack reset {:#4x} {:?}", + ack_packet.command, ack_packet.data + ); - let mut info_packet = DivaPacket::from_bytes(0xf0, &[]); - - match serial_port.write(info_packet.serialize()) { - Ok(_) => { - info!("Diva sent info"); - - self.bootstrap = DivaSliderBootstrap::AwaitInfo; - work = true; - } - Err(e) => { - error!("Diva send info error {}", e); - } + info!("Diva sending info"); + let info_packet = DivaPacket::from_bytes(0xf0, &[]); + self.out_packets.push_back(info_packet); + self.bootstrap = DivaSliderBootstrap::AwaitInfo; + break; + } else { + warn!( + "Unexpected packet {:#4x} {:?}", + ack_packet.command, ack_packet.data + ); } } } DivaSliderBootstrap::AwaitInfo => { - if let Some(ack_packet) = self.packets.pop_front() { + if let Some(ack_packet) = self.in_packets.pop_front() { info!( - "Diva ack info {:?} {:?}", - ack_packet.command, ack_packet.data - ); - - let mut start_packet = DivaPacket::from_bytes(0x03, &[]); - - match serial_port.write(start_packet.serialize()) { - Ok(_) => { - info!("Diva sent start"); - - self.bootstrap = DivaSliderBootstrap::AwaitStart; - work = true; - } - Err(e) => { - error!("Diva send start error {}", e); - } - } - } - } - DivaSliderBootstrap::AwaitStart => { - if let Some(ack_packet) = self.packets.pop_front() { - info!( - "Diva ack start {:?} {:?}", + "Diva ack info {:#4x} {:?}", ack_packet.command, ack_packet.data ); + info!("Diva sending start"); + let start_packet = DivaPacket::from_bytes(0x03, &[]); + self.out_packets.push_back(start_packet); self.bootstrap = DivaSliderBootstrap::ReadLoop; - work = true; } } DivaSliderBootstrap::ReadLoop => { - while let Some(data_packet) = self.packets.pop_front() { + while let Some(data_packet) = self.in_packets.pop_front() { if data_packet.command == 0x01 && data_packet.len == 32 { let mut input_handle = self.state.input.lock(); input_handle @@ -325,24 +346,62 @@ impl ThreadJob for DivaSliderJob { } let mut send_lights = false; - let mut lights_buf = [0; 97]; + let mut lights_buf = [0; 94]; { let mut lights_handle = self.state.lights.lock(); if lights_handle.dirty { send_lights = true; - lights_buf[0] = 0x3f; - lights_buf[1..97].copy_from_slice(&lights_handle.ground[0..96]); + lights_buf[0] = self.brightness; + lights_buf[1..94].copy_from_slice(&lights_handle.ground[0..93]); lights_handle.dirty = false; } } if send_lights { - let mut lights_packet = DivaPacket::from_bytes(0x02, &lights_buf); - serial_port.write(lights_packet.serialize()).ok(); + let lights_packet = DivaPacket::from_bytes(0x02, &lights_buf); + self.out_packets.push_back(lights_packet); } } + DivaSliderBootstrap::Halt => {} }; + let mut sent = false; + while let Some(mut packet) = self.out_packets.pop_front() { + println!("Sending packet {:?}", packet); + sent = true; + serial_port + .write(packet.serialize()) + .map_err(|e| { + error!("Send packet err {}", e); + e + }) + .ok(); + } + if sent { + println!("Flushing"); + serial_port + .flush() + .map_err(|e| { + error!("Flush err {}", e); + e + }) + .ok(); + serial_port + .write(&[0]) + .map_err(|e| { + error!("Send null packet err {}", e); + e + }) + .ok(); + serial_port + .flush() + .map_err(|e| { + error!("Flush null packet err {}", e); + e + }) + .ok(); + } + // TODO: async worker? sleep(Duration::from_millis(10)); @@ -353,7 +412,7 @@ impl ThreadJob for DivaSliderJob { impl Drop for DivaSliderJob { fn drop(&mut self) { match self.bootstrap { - DivaSliderBootstrap::AwaitStart | DivaSliderBootstrap::ReadLoop => { + DivaSliderBootstrap::ReadLoop => { let serial_port = self.serial_port.as_mut().unwrap(); let mut stop_packet = DivaPacket::from_bytes(0x04, &[]); serial_port.write(stop_packet.serialize()).ok(); @@ -361,6 +420,5 @@ impl Drop for DivaSliderJob { _ => {} }; info!("Diva serial port closed"); - // println!("Diva slider dropped"); } } diff --git a/src-slider_io/src/shared/utils.rs b/src-slider_io/src/shared/utils.rs index 6862602..3985e9a 100644 --- a/src-slider_io/src/shared/utils.rs +++ b/src-slider_io/src/shared/utils.rs @@ -52,7 +52,7 @@ impl LoopTimer { Self { cap: 100, cur: 0, - buf: vec![Instant::now() - Duration::from_secs(10); 100], + buf: vec![Instant::now() - Duration::from_secs(10_000); 100], freq: Arc::new(AtomicF64::new(0.0)), } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index e673e8c..12fb11d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -40,7 +40,7 @@ fn main() { #[cfg(not(debug_assertions))] { - let log_file_path = slider_io::Config::get_log_file_path().unwrap(); + let log_file_path = slider_io::system::get_log_file_path().unwrap(); simple_logging::log_to_file(log_file_path.as_path(), log::LevelFilter::Debug).unwrap(); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 94d9b22..5b8b80d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "package": { "productName": "slidershim", - "version": "0.1.4" + "version": "0.2.0" }, "build": { "distDir": "../public", diff --git a/src/App.svelte b/src/App.svelte index 88d99dd..7a72670 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -11,6 +11,7 @@ let ledMode = "none"; let divaSerialPort = "COM1"; + let divaBrightness = 63; let keyboardSensitivity = 20; let outputPolling = "100"; let outputWebsocketUrl = "http://localhost:3000"; @@ -58,6 +59,7 @@ ledMode = payload.ledMode || "none"; divaSerialPort = payload.divaSerialPort || "COM1"; + divaBrightness = payload.divaBrightness || 63; keyboardSensitivity = payload.keyboardSensitivity || 20; outputPolling = payload.outputPolling || "100"; outputWebsocketUrl = @@ -106,6 +108,7 @@ outputMode, ledMode, divaSerialPort, + divaBrightness, keyboardSensitivity, outputPolling, outputWebsocketUrl, @@ -137,7 +140,7 @@
- + logo
 slidershim{versionString} @@ -200,6 +203,32 @@
+
+
Brightness
+
+ +
+
+
+
+
+ +
+
{/if}