diff --git a/README.md b/README.md index 93edb36f..228e5f07 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,122 @@ # Lotus Input Module Firmware -See below sections for LED Matrix, LCD Display and C1 Minimal module details. +This repository contains both the firmware for the Lotus input modules, as well +as the tool to control them. -Rust project setup based off of: https://github.com/rp-rs/rp2040-project-template +Rust firmware project setup based off of: https://github.com/rp-rs/rp2040-project-template -## Features +## Modules -- Reset into bootloader when firmware crashes/panics +See pages of the individual modules for details about how they work and how +they're controlled. + +- [LED Matrix](ledmatrix/README.md) +- [2nd Display](b1display/README.md) +- [Minimal C1 Input Module](c1minimal/README.md) + +## Generic Features + +All modules are built with an RP2040 microcontroller +Features that all modules share + +- Firmware written in bare-metal Rust +- Reset into RP2040 bootloader when firmware crashes/panics +- Sleep Mode to save power - API over USB ACM Serial Port - Requires no Drivers on Windows and Linux - - Display various pre-programmed patterns - - Light up a percentage of the screen - - Change brightness - - Send a black/white image to the display - - Send a greyscale image to the display - Go to sleep - Reset into bootloader - - Scroll and loop the display content vertically - - A commandline script and graphical application to control it -- Sleep Mode - - Transition slowly turns off/on the LEDs - - Current hardware does not have the SLEEP# GPIO connected, can't sleep automatically + - Control and read module state (brightness, displayed image, ...) -Future features: +## Control from the host -- API - - Send a greyscale image to display - - Read current system state (brightness, sleeping, ...) +To build your own application see the: [API command documentation](commands.md) -## Control from the host +Or use our `inputmodule-control` app. Optionally there are is also a +[Python script](python.md). + +For device specific commands, see their individual documentation pages. + +Common commands: + +###### Listing available devices + +```sh +> inputmodule-control --list +/dev/ttyACM0 + VID 0x32AC + PID 0x0020 + SN FRAKDEAM0020110001 + Product Lotus_LED_Matrix +/dev/ttyACM1 + VID 0x32AC + PID 0x0021 + SN FRAKDEAM0000000000 + Product Lotus_B1_Display +``` -Requirements: Python, [PySimpleGUI](https://www.pysimplegui.org) and optionally [pillow](https://pillow.readthedocs.io/en/stable/index.html) +###### Apply command to single device -Use `control.py`. Either the commandline, see `control.py --help` or the graphical version: `control.py --gui` +By default a command will be sent to all devices that can be found, to apply it +to a single device, specify the COM port. +In this example the command is targeted at `b1-display`, so it will only apply +to this module type. ``` -options: - -h, --help show this help message and exit - --bootloader Jump to the bootloader to flash new firmware - --sleep, --no-sleep Simulate the host going to sleep or waking up - --brightness BRIGHTNESS - Adjust the brightness. Value 0-255 - --animate, --no-animate - Start/stop vertical scrolling - --pattern {full,lotus,gradient,double-gradient,zigzag,panic,lotus2} - Display a pattern - --image IMAGE Display a PNG or GIF image in black and white only) - --image-grey IMAGE_GREY - Display a PNG or GIF image in greyscale - --percentage PERCENTAGE - Fill a percentage of the screen - --clock Display the current time - --string STRING Display a string or number, like FPS - --symbols SYMBOLS [SYMBOLS ...] - Show symbols (degF, degC, :), snow, cloud, ...) - --gui Launch the graphical version of the program - --blink Blink the current pattern - --breathing Breathing of the current pattern - --eq EQ [EQ ...] Equalizer - --random-eq Random Equalizer - --wpm WPM Demo - --snake Snake - --all-brightnesses Show every pixel in a different brightness - --set-color {white,black,red,green,blue,cyan,yellow,purple} - Set RGB color (C1 Minimal Input Module) - --get-color Get RGB color (C1 Minimal Input Module) - -v, --version Get device version - --serial-dev SERIAL_DEV - Change the serial dev. Probably /dev/ttyACM0 on Linux, COM0 on Windows +# Example on Linux +> inputmodule-control --serial-dev /dev/ttyACM0 b1-display --pattern black + +# Example on Windows +> inputmodule-control.exe --serial-dev COM5 b1-display --pattern black ``` -Examples +###### Send command when device connects -```sh -# Launch graphical application -./control.py --gui +By default the app tries to connect with the device and aborts if it can't +connect. But you might want to start the app, have it wait until the device is +connected and then send the command. -# Show current time and keep updating it -./control.py --clock +``` +> inputmodule-control b1-display --pattern black +Failed to find serial devivce. Please manually specify with --serial-dev -# Draw PNG or GIF -./control.py --image stripe.gif -./control.py --image stripe.png +# No failure, waits until the device is connected, sends command and exits +> inputmodule-control --wait-for-device b1-display --pattern black -# Change brightness (0-255) -./control.py --brightness 50 +# If the device is already connected, it does nothing, just wait 1s. +# This means you can run this command by a system service and restart it when +# it finishes. Then it will only ever do anything if the device reconnects. +> inputmodule-control --wait-for-device b1-display --pattern black +Device already present. No need to wait. Not executing command. ``` -## Control via Rust binary +## Update the Firmware -Currently have to specify the build target because it's not possible to specify a per package build target. -Tracking issue: https://github.com/rust-lang/cargo/issues/9406 +First, put the module into bootloader mode. + +This can be done either by pressing the bootsel button while plugging it in or +by using one of the following commands: +```sh +inputmodule-control led-matrix --bootloader +inputmodule-control b1-display --bootloader +inputmodule-control c1-minimal --bootloader ``` -> cargo build --target x86_64-unknown-linux-gnu -p inputmodule-control -> cargo run --target x86_64-unknown-linux-gnu -p inputmodule-control + +Then the module will present itself in the same way as a USB thumb drive. +Copy the UF2 firmware file onto it and the device will flash and reset automatically. +Alternatively when building from source, run one of the following commands: + +```sh +cargo run -p ledmatrix +cargo run -p b1display +cargo run -p c1minimal ``` -## Building +## Building the firmware Dependencies: Rust -Prepare Rust toolchain: +Prepare Rust toolchain (once): ```sh rustup target install thumbv6m-none-eabi @@ -116,36 +132,33 @@ cargo build -p b1display cargo build -p c1minimal ``` -Generate UF2 file: +Generate the UF2 update file: ```sh elf2uf2-rs target/thumbv6m-none-eabi/debug/ledmatrix ledmatrix.uf2 elf2uf2-rs target/thumbv6m-none-eabi/debug/b1display b1dipslay.uf2 -elf2uf2-rs target/thumbv6m-none-eabi/debug/b1display c1minimal.uf2 +elf2uf2-rs target/thumbv6m-none-eabi/debug/c1minimal c1minimal.uf2 ``` -## Flashing +## Building the Application -First, put the module into bootloader mode, which will expose a filesystem +Dependencies: Rust, pkg-config, libudev -This can be done by pressing the bootsel button while plugging it in. +Currently have to specify the build target because it's not possible to specify a per package build target. +Tracking issue: https://github.com/rust-lang/cargo/issues/9406 -```sh -cargo run -p ledmatrix -cargo run -p b1display -cargo run -p c1minimal ``` - -Or by copying the above generated UF2 file to the partition mounted when the -module is in the bootloder. +> cargo build --target x86_64-unknown-linux-gnu -p inputmodule-control +> cargo run --target x86_64-unknown-linux-gnu -p inputmodule-control +``` ### Check the firmware version of the device -###### In-band using `control.py` +###### In-band using commandline ```sh -> ./control.py --version -Device version: 0.1.2 +> inputmodule-control b1-display --version +Device Version: 0.1.3 ``` ###### By looking at the USB descriptor @@ -169,30 +182,3 @@ Additionally the panic message is written to flash, which can be read as follows sudo picotool save -r 0x15000000 0x15004000 message.bin strings message.bin | head ``` - -## LED Matrix - -It's a 9x34 (306) LED matrix, controlled by RP2040 MCU and IS31FL3741A LED controller. - -Connection to the host system is via USB 2.0 and currently there is a USB Serial API to control it without reflashing. - -## B1 Display - -## C1 Minimal Input Module - -It's a very minimal input module. Many GPIO pins are exposed so that headers -can be soldered onto them. Additionally there are pads for a WS2812/Neopixel -compatible RGB LED. - -When booting up this LED is lit in green color. -Its color and brightness can be controlled via the commands: - -```sh -> ./control.py --brightness 255 -> ./control.py --get-brightness -Current brightness: 255 - -> ./control.py --set-color yellow -> ./control.py --get-color -Current color: RGB:(255, 255, 0) -``` diff --git a/b1display/README.md b/b1display/README.md new file mode 100644 index 00000000..5249d227 --- /dev/null +++ b/b1display/README.md @@ -0,0 +1 @@ +## B1 Display diff --git a/b1display/src/main.rs b/b1display/src/main.rs index 1086f865..a0343a34 100644 --- a/b1display/src/main.rs +++ b/b1display/src/main.rs @@ -182,6 +182,8 @@ fn main() -> ! { let mut state = B1DIsplayState { sleeping: SimpleSleepState::Awake, + screen_inverted: false, + screen_on: true, }; let mut said_hello = false; @@ -240,21 +242,22 @@ fn main() -> ! { }; } (Some(command), SimpleSleepState::Awake) => { + // While sleeping no command is handled, except waking up + if let Some(response) = + handle_command(&command, &mut state, logo_rect, &mut disp) + { + let _ = serial.write(&response); + }; + // Must write AFTER writing response, otherwise the + // client interprets this debug message as the response let mut text: String<64> = String::new(); write!( &mut text, - "Handling command {}:{}:{}:{}\r\n", + "Handled command {}:{}:{}:{}\r\n", buf[0], buf[1], buf[2], buf[3] ) .unwrap(); let _ = serial.write(text.as_bytes()); - - // While sleeping no command is handled, except waking up - if let Some(response) = - handle_command(&command, &mut state, logo_rect, &mut disp) - { - let _ = serial.write(&response); - }; } _ => {} } diff --git a/c1minimal/README.md b/c1minimal/README.md new file mode 100644 index 00000000..a4d6c92e --- /dev/null +++ b/c1minimal/README.md @@ -0,0 +1,18 @@ +## C1 Minimal Input Module + +It's a very minimal input module. Many GPIO pins are exposed so that headers +can be soldered onto them. Additionally there are pads for a WS2812/Neopixel +compatible RGB LED. + +When booting up this LED is lit in green color. +Its color and brightness can be controlled via the commands: + +```sh +> ./control.py --brightness 255 +> ./control.py --get-brightness +Current brightness: 255 + +> ./control.py --set-color yellow +> ./control.py --get-color +Current color: RGB:(255, 255, 0) +``` diff --git a/commands.md b/commands.md new file mode 100644 index 00000000..00ddfb66 --- /dev/null +++ b/commands.md @@ -0,0 +1,100 @@ +# Commands + +The input modules can be controlled by sending commands via the USB CDC-ACM +serial port. To send a command, write the two magic bytes, command ID and +parameters. Most commands don't return anything. + +Simple example in Python: + +```python +import serial + +def send_command(command_id, parameters, with_response=False) + with serial.Serial(/dev/ttyACM0, 115200) as s: + s.write([0x32, 0xAC, command_id] + parameters) + + if with_response: + res = s.read(32) + return res + +# Go to sleep and check the status +send_command(0x03, [True]) +res = send_command(0x03, [], with_response=True) +print(f"Is currently sleeping: {bool(res[0])}) +``` + +Many commands support setting and writing a value, with the same command ID. +When no parameters are given, the current value is queried and returned. + +###### Modules: + +- L = LED Matrix +- D = B1 Display +- M = C1 Minimal Module + +## Command overview + +| Command | ID | Modules | Response | Parameters | Behavior | +| ------------ | ---- | ------- | -------- | ---------- | ------------------------ | +| Brightness | 0x00 | `L M` | | | Set LED brightness | +| Pattern | 0x01 | `L ` | | | Display a pattern | +| Bootloader | 0x02 | `LDM` | | | Jump to the bootloader | +| Sleep | 0x03 | `LDM` | | bool | Go to sleep or wake up | +| GetSleep | 0x03 | `LDM` | bool | | Check sleep state | +| Animate | 0x04 | `L ` | | bool | Scroll current pattern | +| GetAnimate | 0x04 | `L ` | bool | | Check whether animating | +| Panic | 0x05 | `LDM` | | | Cause a FW panic/crash | +| DrawBW | 0x06 | `L ` | | 39 Bytes | Draw a black/white image | +| StageCol | 0x07 | `L ` | | 1+34 Bytes | Send a greyscale column | +| FlushCols | 0x08 | `L ` | | | Flush/draw all columns | +| SetText | 0x09 | ` D ` | | | TODO: Remove | +| StartGame | 0x10 | `L ` | | 1B Game ID | Start an embeded game | +| GameCtrl | 0x11 | `L ` | | 1B Control | Send a game command | +| GameStatus | 0x12 | `L ` | WIP | | Check the game status | +| SetColor | 0x13 | ` M` | | 3B: RGB | Set the LED's color | +| DisplayOn | 0x14 | ` D ` | | bool | Turn the display on/off | +| InvertScreen | 0x15 | ` D ` | | bool | Invert scren on/off | +| SetPxCol | 0x16 | ` D ` | | 50 Bytes | Send a column of pixels | +| FlushFB | 0x17 | ` D ` | | | Flush all columns | +| Version | 0x20 | ` D ` | 3 Bytes | | Get firmware version | + +#### Pattern (0x01) + +The following patterns are defined + +- 0x00 - Percentage (See below, needs another parameter) +- 0x01 - Gradient (Brightness gradient from top to bottom) +- 0x02 - DoubleGradient (Brightness gradient from the middle to top and bottom) +- 0x03 - DisplayLotusHorizontal (Display "LOTUS" 90 degree rotated) +- 0x04 - ZigZag (Display a zigzag pattern) +- 0x05 - FullBrightness (Turn every LED on and set the brightness to 100%) +- 0x06 - DisplayPanic (Display the string "PANIC") +- 0x07 - DisplayLotusVertical (Display the string "LOTUS") + +Pattern 0x00 is special. It needs another parameter to specify the percentage. +It will fill a percentage of the screen. It can serve as a progress indicator. + +#### DrawBW (0x06) +TODO + +#### StageCol (0x07) +TODO + +#### FlushCols (0x08) +TODO + +#### SetPxCol (0x16) +TODO + +#### FlushFB (0x17) +TODO + +#### Version (0x20) + +Response: + +``` +Byte 0: USB bcdDevice MSB +Byte 1: USB bcdDevice LSB +Byte 2: 1 if pre-release version, 0 otherwise +``` diff --git a/inputmodule-control/src/b1display.rs b/inputmodule-control/src/b1display.rs index 6b641f3c..f5293dea 100644 --- a/inputmodule-control/src/b1display.rs +++ b/inputmodule-control/src/b1display.rs @@ -1,5 +1,11 @@ use clap::Parser; +#[derive(Copy, Clone, Debug, PartialEq, clap::ValueEnum)] +pub enum B1Pattern { + White, + Black, +} + /// B1 Display #[derive(Parser, Debug)] #[command(arg_required_else_help = true)] @@ -16,10 +22,6 @@ pub struct B1DisplaySubcommand { #[arg(long)] pub panic: bool, - /// Serial device, like /dev/ttyACM0 or COM0 - #[arg(long)] - pub serial_dev: Option, - /// Get the device version #[arg(short, long)] pub version: bool, @@ -27,12 +29,16 @@ pub struct B1DisplaySubcommand { /// Turn display on/off // TODO: Allow getting current state #[arg(long)] - pub display_on: Option, + pub display_on: Option>, + + /// Display a simple pattern + #[arg(long)] + #[clap(value_enum)] + pub pattern: Option, /// Invert screen on/off - // TODO: Allow getting current state #[arg(long)] - pub invert_screen: Option, + pub invert_screen: Option>, /// Display black&white image (300x400px) #[arg(long)] diff --git a/inputmodule-control/src/c1minimal.rs b/inputmodule-control/src/c1minimal.rs index b71e29e6..a050bb37 100644 --- a/inputmodule-control/src/c1minimal.rs +++ b/inputmodule-control/src/c1minimal.rs @@ -27,10 +27,6 @@ pub struct C1MinimalSubcommand { #[arg(long)] pub panic: bool, - /// Serial device, like /dev/ttyACM0 or COM0 - #[arg(long)] - pub serial_dev: Option, - /// Get the device version #[arg(short, long)] pub version: bool, diff --git a/inputmodule-control/src/font.rs b/inputmodule-control/src/font.rs index ab910306..fc06f30b 100644 --- a/inputmodule-control/src/font.rs +++ b/inputmodule-control/src/font.rs @@ -4,20 +4,20 @@ pub fn convert_symbol(symbol: &str) -> Vec { match symbol { "degC" => vec![ - 0, 0, 0, 1, 1, - 0, 0, 0, 1, 1, - 1, 1, 1, 0, 0, - 1, 0, 0, 0, 0, - 1, 0, 0, 0, 0, - 1, 1, 1, 0, 0, + 1, 1, 0, 0, 0, + 1, 1, 0, 0, 0, + 0, 0, 1, 1, 1, + 0, 0, 1, 0, 0, + 0, 0, 1, 0, 0, + 0, 0, 1, 1, 1, ], "degF" => vec![ - 0, 0, 0, 1, 1, - 0, 0, 0, 1, 1, - 1, 1, 1, 0, 0, - 1, 0, 0, 0, 0, - 1, 1, 1, 0, 0, - 1, 0, 0, 0, 0, + 1, 1, 0, 0, 0, + 1, 1, 0, 0, 0, + 0, 0, 1, 1, 1, + 0, 0, 1, 0, 0, + 0, 0, 1, 1, 1, + 0, 0, 1, 0, 0, ], "snow" => vec![ 0, 0, 0, 0, 0, diff --git a/inputmodule-control/src/inputmodule.rs b/inputmodule-control/src/inputmodule.rs index 595b78fa..723d1967 100644 --- a/inputmodule-control/src/inputmodule.rs +++ b/inputmodule-control/src/inputmodule.rs @@ -4,16 +4,17 @@ use std::time::Duration; use chrono::Local; use image::{io::Reader as ImageReader, Luma}; use rand::prelude::*; -use serialport::{SerialPort, SerialPortInfo}; +use serialport::{SerialPort, SerialPortInfo, SerialPortType}; +use crate::b1display::B1Pattern; use crate::c1minimal::Color; use crate::font::{convert_font, convert_symbol}; use crate::ledmatrix::{Game, GameOfLifeStartParam, Pattern}; const FWK_MAGIC: &[u8] = &[0x32, 0xAC]; -const FRAMEWORK_VID: u16 = 0x32AC; -const LED_MATRIX_PID: u16 = 0x0020; -const B1_LCD_PID: u16 = 0x0021; +pub const FRAMEWORK_VID: u16 = 0x32AC; +pub const LED_MATRIX_PID: u16 = 0x0020; +pub const B1_LCD_PID: u16 = 0x0021; // TODO: Use a shared enum with the firmware code #[derive(Clone, Copy)] @@ -55,7 +56,11 @@ const HEIGHT: usize = 34; const SERIAL_TIMEOUT: Duration = Duration::from_millis(20); -fn find_serialdevs(ports: &[SerialPortInfo], requested: &Option) -> Vec { +fn match_serialdevs( + ports: &[SerialPortInfo], + requested: &Option, + pid: Option, +) -> Vec { if let Some(requested) = requested { for p in ports { if requested == &p.port_name { @@ -65,12 +70,16 @@ fn find_serialdevs(ports: &[SerialPortInfo], requested: &Option) -> Vec< vec![] } else { let mut compatible_devs = vec![]; - // If nothing requested, fall back to a generic one or the first supported Framework USB device + let pids = if let Some(pid) = pid { + vec![pid] + } else { + // By default accept any type + vec![LED_MATRIX_PID, B1_LCD_PID, 0x22, 0xFF] + }; + // Find all supported Framework devices for p in ports { - if let serialport::SerialPortType::UsbPort(usbinfo) = &p.port_type { - if usbinfo.vid == FRAMEWORK_VID - && [LED_MATRIX_PID, B1_LCD_PID].contains(&usbinfo.pid) - { + if let SerialPortType::UsbPort(usbinfo) = &p.port_type { + if usbinfo.vid == FRAMEWORK_VID && pids.contains(&usbinfo.pid) { compatible_devs.push(p.port_name.clone()); } } @@ -79,31 +88,68 @@ fn find_serialdevs(ports: &[SerialPortInfo], requested: &Option) -> Vec< } } -/// Commands that interact with serial devices -pub fn serial_commands(args: &crate::ClapCli) { - let ports = serialport::available_ports().expect("No ports found!"); - if args.list || args.verbose { - for p in &ports { - //println!("{}", p.port_name); - println!("{p:?}"); - } - } - let serialdevs = match &args.command { - Some(crate::Commands::LedMatrix(ledmatrix_args)) => { - find_serialdevs(&ports, &ledmatrix_args.serial_dev) - } - Some(crate::Commands::B1Display(ledmatrix_args)) => { - find_serialdevs(&ports, &ledmatrix_args.serial_dev) +pub fn find_serialdevs(args: &crate::ClapCli, wait_for_device: bool) -> (Vec, bool) { + let mut serialdevs: Vec; + let mut waited = false; + loop { + let ports = serialport::available_ports().expect("No ports found!"); + if args.list || args.verbose { + for p in &ports { + match &p.port_type { + SerialPortType::UsbPort(usbinfo) => { + println!("{}", p.port_name); + println!(" VID {:#06X}", usbinfo.vid); + println!(" PID {:#06X}", usbinfo.pid); + if let Some(sn) = &usbinfo.serial_number { + println!(" SN {}", sn); + } + if let Some(product) = &usbinfo.product { + // TODO: Seems to replace the spaces with underscore, not sure why + println!(" Product {}", product); + } + } + _ => { + //println!("{}", p.port_name); + //println!(" Unknown (PCI Port)"); + } + } + } } - Some(crate::Commands::C1Minimal(c1minimal_args)) => { - find_serialdevs(&ports, &c1minimal_args.serial_dev) + serialdevs = match_serialdevs( + &ports, + &args.serial_dev, + args.command.as_ref().map(|x| x.to_pid()), + ); + if serialdevs.is_empty() { + if wait_for_device { + // Waited at least once, that means the device was not present + // when the program started + waited = true; + + // Try again after short wait + thread::sleep(Duration::from_millis(100)); + continue; + } else { + return (vec![], waited); + } + } else { + break; } - None => vec![], - }; + } + (serialdevs, waited) +} + +/// Commands that interact with serial devices +pub fn serial_commands(args: &crate::ClapCli) { + let (serialdevs, waited): (Vec, bool) = find_serialdevs(args, args.wait_for_device); if serialdevs.is_empty() { - println!("Failed to find serial device. Please manually specify with --serial-dev"); + println!("Failed to find serial devivce. Please manually specify with --serial-dev"); return; - }; + } else if args.wait_for_device && !waited { + println!("Device already present. No need to wait. Not executing command. Sleep 1s"); + thread::sleep(Duration::from_millis(1000)); + return; + } match &args.command { // TODO: Handle generic commands without code deduplication @@ -216,6 +262,9 @@ pub fn serial_commands(args: &crate::ClapCli) { if let Some(image_path) = &b1display_args.image_bw { b1display_bw_image_cmd(serialdev, image_path); } + if let Some(pattern) = b1display_args.pattern { + b1_display_pattern(serialdev, pattern); + } } } Some(crate::Commands::C1Minimal(c1minimal_args)) => { @@ -311,6 +360,17 @@ fn simple_cmd(serialdev: &str, command: Command, args: &[u8]) { simple_cmd_port(&mut port, command, args); } +fn open_serialport(serialdev: &str) -> Box { + serialport::new(serialdev, 115_200) + .timeout(SERIAL_TIMEOUT) + .open() + .expect("Failed to open port") +} + +fn simple_open_cmd(serialport: &mut Box, command: Command, args: &[u8]) { + simple_cmd_port(serialport, command, args); +} + fn simple_cmd_port(port: &mut Box, command: Command, args: &[u8]) { let mut buffer: [u8; 64] = [0; 64]; buffer[..2].copy_from_slice(FWK_MAGIC); @@ -635,12 +695,44 @@ fn show_symbols(serialdev: &str, symbols: &Vec) { show_font(serialdev, &font_items); } -fn display_on_cmd(serialdev: &str, display_on: bool) { - simple_cmd(serialdev, Command::DisplayOn, &[display_on as u8]); +fn display_on_cmd(serialdev: &str, arg: Option) { + let mut port = serialport::new(serialdev, 115_200) + .timeout(SERIAL_TIMEOUT) + .open() + .expect("Failed to open port"); + + if let Some(display_on) = arg { + simple_cmd_port(&mut port, Command::DisplayOn, &[display_on as u8]); + } else { + simple_cmd_port(&mut port, Command::DisplayOn, &[]); + + let mut response: Vec = vec![0; 32]; + port.read_exact(response.as_mut_slice()) + .expect("Found no data!"); + + let on = response[0] == 1; + println!("Currently on: {on}"); + } } -fn invert_screen_cmd(serialdev: &str, invert_on: bool) { - simple_cmd(serialdev, Command::InvertScreen, &[invert_on as u8]); +fn invert_screen_cmd(serialdev: &str, arg: Option) { + let mut port = serialport::new(serialdev, 115_200) + .timeout(SERIAL_TIMEOUT) + .open() + .expect("Failed to open port"); + + if let Some(invert_on) = arg { + simple_cmd_port(&mut port, Command::InvertScreen, &[invert_on as u8]); + } else { + simple_cmd_port(&mut port, Command::InvertScreen, &[]); + + let mut response: Vec = vec![0; 32]; + port.read_exact(response.as_mut_slice()) + .expect("Found no data!"); + + let inverted = response[0] == 1; + println!("Currently inverted: {inverted}"); + } } fn set_color_cmd(serialdev: &str, color: Color) { @@ -662,6 +754,7 @@ fn set_color_cmd(serialdev: &str, color: Color) { /// Must be 300x400 in size. /// Sends one 400px column in a single commands and a flush at the end fn b1display_bw_image_cmd(serialdev: &str, image_path: &str) { + let mut serialport = open_serialport(serialdev); let img = ImageReader::open(image_path) .unwrap() .decode() @@ -696,8 +789,28 @@ fn b1display_bw_image_cmd(serialdev: &str, image_path: &str) { } } - simple_cmd(serialdev, Command::SetPixelColumn, &vals); + simple_open_cmd(&mut serialport, Command::SetPixelColumn, &vals); } - simple_cmd(serialdev, Command::FlushFramebuffer, &[]); + simple_open_cmd(&mut serialport, Command::FlushFramebuffer, &[]); +} + +fn b1_display_color(serialdev: &str, black: bool) { + let mut serialport = open_serialport(serialdev); + for x in 0..300 { + let byte = if black { 0xFF } else { 0x00 }; + let mut vals: [u8; 2 + 50] = [byte; 2 + 50]; + let column = (x as u16).to_le_bytes(); + vals[0] = column[0]; + vals[1] = column[1]; + simple_open_cmd(&mut serialport, Command::SetPixelColumn, &vals); + } + simple_open_cmd(&mut serialport, Command::FlushFramebuffer, &[]); +} + +fn b1_display_pattern(serialdev: &str, pattern: B1Pattern) { + match pattern { + B1Pattern::Black => b1_display_color(serialdev, true), + B1Pattern::White => b1_display_color(serialdev, false), + } } diff --git a/inputmodule-control/src/ledmatrix.rs b/inputmodule-control/src/ledmatrix.rs index 08f08b82..54b6c54d 100644 --- a/inputmodule-control/src/ledmatrix.rs +++ b/inputmodule-control/src/ledmatrix.rs @@ -121,10 +121,6 @@ pub struct LedMatrixSubcommand { #[arg(long)] pub panic: bool, - /// Serial device, like /dev/ttyACM0 or COM0 - #[arg(long)] - pub serial_dev: Option, - /// Get the device version #[arg(short, long)] pub version: bool, diff --git a/inputmodule-control/src/main.rs b/inputmodule-control/src/main.rs index cabbd0de..8348ad74 100644 --- a/inputmodule-control/src/main.rs +++ b/inputmodule-control/src/main.rs @@ -1,4 +1,5 @@ #![allow(clippy::needless_range_loop)] +#![allow(clippy::single_match)] mod b1display; mod c1minimal; mod font; @@ -6,10 +7,11 @@ mod inputmodule; mod ledmatrix; use clap::{Parser, Subcommand}; +use inputmodule::find_serialdevs; use crate::b1display::B1DisplaySubcommand; use crate::c1minimal::C1MinimalSubcommand; -use crate::inputmodule::serial_commands; +use crate::inputmodule::{serial_commands, B1_LCD_PID, LED_MATRIX_PID}; use crate::ledmatrix::LedMatrixSubcommand; #[derive(Subcommand, Debug)] @@ -19,6 +21,16 @@ enum Commands { C1Minimal(C1MinimalSubcommand), } +impl Commands { + pub fn to_pid(&self) -> u16 { + match self { + Self::LedMatrix(_) => LED_MATRIX_PID, + Self::B1Display(_) => B1_LCD_PID, + Self::C1Minimal(_) => 0x22, + } + } +} + /// RAW HID and VIA commandline for QMK devices #[derive(Parser, Debug)] #[command(version, arg_required_else_help = true)] @@ -34,13 +46,13 @@ pub struct ClapCli { #[arg(short, long)] verbose: bool, - /// VID (Vendor ID) in hex digits + /// Serial device, like /dev/ttyACM0 or COM0 #[arg(long)] - vid: Option, + pub serial_dev: Option, - /// PID (Product ID) in hex digits + /// Retry connecting to the device until it works #[arg(long)] - pid: Option, + wait_for_device: bool, } fn main() { @@ -48,9 +60,11 @@ fn main() { let args = ClapCli::parse_from(args); match args.command { - Some(Commands::B1Display(_)) => serial_commands(&args), - Some(Commands::LedMatrix(_)) => serial_commands(&args), - Some(Commands::C1Minimal(_)) => serial_commands(&args), - None => panic!("Not allowed"), + Some(_) => serial_commands(&args), + None => { + if args.list { + find_serialdevs(&args, false); + } + } } } diff --git a/ledmatrix/README.md b/ledmatrix/README.md new file mode 100644 index 00000000..5d56293b --- /dev/null +++ b/ledmatrix/README.md @@ -0,0 +1,171 @@ +# LED Matrix + +It's a 9x34 (306) LED matrix, controlled by RP2040 MCU and IS31FL3741A LED controller. + +Connection to the host system is via USB 2.0 and currently there is a USB Serial API to control it without reflashing. + +- Commands + - Display various pre-programmed patterns + - Light up a percentage of the screen + - Change brightness + - Send a black/white image to the display + - Send a greyscale image to the display + - Scroll and loop the display content vertically + - A commandline script and graphical application to control it +- Sleep Mode + - Transition slowly turns off/on the LEDs + +## Controlling + +### Commandline + +``` +> inputmodule-control led-matrix +LED Matrix + +Usage: ipc led-matrix [OPTIONS] + +Options: + --brightness [] + Set LED max brightness percentage or get, if no value provided + --sleeping [] + Set sleep status or get, if no value provided [possible values: true, false] + --bootloader + Jump to the bootloader + --percentage + Display a percentage (0-100) + --animate [] + Start/stop animation [possible values: true, false] + --pattern + Display a pattern [possible values: percentage, gradient, double-gradient, lotus-sideways, zigzag, all-on, panic, lotus-top-down] + --all-brightnesses + Show every brightness, one per pixel + --blinking + Blink the current pattern once a second + --breathing + Breathing brightness of the current pattern + --image-bw + Display black&white image (9x34px) + --image-gray + Display grayscale image + --random-eq + Random EQ + --eq + EQ with custom values + --clock + Show the current time + --string + Display a string (max 5 chars) + --symbols [...] + Display a string (max 5 symbols) + --start-game + Start a game [possible values: snake, pong, tetris, game-of-life] + --game-param + Paramater for starting the game. Required for some games [possible values: current-matrix, pattern1, blinker, toad, beacon, glider] + --stop-game + Stop the currently running game + --panic + Crash the firmware (TESTING ONLY!) + -v, --version + Get the device version + -h, --help + Print help +``` + +### Non-trivial Examples + +Most commandline arguments should be self-explanatory. +If not, please open an issue. +Those that require an argument or setup have examples here: + +###### Percentage + +Light up a percentage of the module. From bottom to top. +This could be used to show volume level, progress of something, or similar. + +```sh +inputmodule-control led-matrix --percentage 30 +``` + +###### Display an Image + +Display an image (tested with PNG and GIF). It must be 9x34 pixels in size. It +doesn't have to be black/white or grayscale. The program will calculate the +brightness of each pixel. But if the brightness doesn't vary enough, it won't +look good. +Two example images are included in the repository. + +```sh +# Convert image to black/white and display +inputmodule-control led-matrix --image-bw stripe.gif + +# Convert image to grayscale and display +inputmodule-control led-matrix --image-gray grayscale.gif +``` + +###### Random equalizer +To show off the equalizer use-case, this command generates a +random but authentic looking equalizer pattern until the command is terminated. + +Alternatively you can provide 9 EQ values yourself. A script might capture +audio input and feed it into this command. + +```sh +inputmodule-control led-matrix --random-eq +inputmodule-control led-matrix --eq 1 2 3 4 5 4 3 2 1 +``` + +###### Custom string + +Display a custom string of up to 5 characters. +Currently only uppercase A-Z, 0-9 and some punctuation is implemented. + +```sh +inputmodule-control led-matrix --string "LOTUS" +``` + +The symbols parameter is much more powerful, it can also show extra symbols. +The full list of symbols is defined [here](https://github.com/FrameworkComputer/led_matrix_fw/blob/main/inputmodule-control/src/font.rs). + +```sh +# Show 0 °C, a snow icon and a smiley +inputmodule-control led-matrix --symbols 0 degC ' ' snow ':)' +``` + +###### Games + +While the game commands are implemented, the controls don't take easy keyboard +input. +Instead try out the [Python script](../python.md): + +```sh +# Snake +./control.py --snake + +# Pong (Seems broken at the moment) +./control.py --pong-embedded +``` + +###### Game of Life + +[Conway's Game of Life](https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life) +needs a parameter to start. Choose either one of the preprogrammed starting patterns. +Or display whatever you like using the other commands and have the game start based on that. +Font patterns generally look pretty good and survive for a while or even stay alive forever. + +The game board wraps around the edges to make gliders possible that move continuously. + +```sh +# Start from the currently displayed pattern +inputmodule-control led-matrix --start-game game-of-life --game-param current-matrix + +# Show two gliders that move forever +inputmodule-control led-matrix --start-game game-of-life --game-param glider +``` + +If you want to display something else, either reset the module (unplugging) or +run the stop command. + +```sh +inputmodule-control led-amtrix --stop-game +``` diff --git a/lotus-inputmodules/src/control.rs b/lotus-inputmodules/src/control.rs index 560cd8f1..55887dda 100644 --- a/lotus-inputmodules/src/control.rs +++ b/lotus-inputmodules/src/control.rs @@ -144,7 +144,9 @@ pub enum Command { #[cfg(feature = "c1minimal")] SetColor(RGB8), DisplayOn(bool), + GetDisplayOn, InvertScreen(bool), + GetInvertScreen, SetPixelColumn(usize, [u8; 50]), FlushFramebuffer, _Unknown, @@ -167,6 +169,8 @@ pub struct C1MinimalState { #[cfg(feature = "b1display")] pub struct B1DIsplayState { pub sleeping: SimpleSleepState, + pub screen_inverted: bool, + pub screen_on: bool, } pub fn parse_command(count: usize, buf: &[u8]) -> Option { @@ -324,8 +328,16 @@ pub fn parse_module_command(count: usize, buf: &[u8]) -> Option { None } } - Some(CommandVals::DisplayOn) => Some(Command::DisplayOn(arg == Some(1))), - Some(CommandVals::InvertScreen) => Some(Command::InvertScreen(arg == Some(1))), + Some(CommandVals::DisplayOn) => Some(if let Some(on) = arg { + Command::DisplayOn(on == 1) + } else { + Command::GetDisplayOn + }), + Some(CommandVals::InvertScreen) => Some(if let Some(invert) = arg { + Command::InvertScreen(invert == 1) + } else { + Command::GetInvertScreen + }), Some(CommandVals::SetPixelColumn) => { // 3B for magic and command // 2B for column (u16) @@ -519,10 +531,17 @@ where None } Command::DisplayOn(on) => { + state.screen_on = *on; disp.on_off(*on).unwrap(); None } + Command::GetDisplayOn => { + let mut response: [u8; 32] = [0; 32]; + response[0] = state.screen_on as u8; + Some(response) + } Command::InvertScreen(invert) => { + state.screen_inverted = *invert; if *invert { disp.write_command(Instruction::INVON, &[]).unwrap(); } else { @@ -530,6 +549,11 @@ where } None } + Command::GetInvertScreen => { + let mut response: [u8; 32] = [0; 32]; + response[0] = state.screen_inverted as u8; + Some(response) + } Command::SetPixelColumn(column, pixel_bytes) => { let mut pixels: [bool; 400] = [false; 400]; for (i, byte) in pixel_bytes.iter().enumerate() { diff --git a/python.md b/python.md new file mode 100644 index 00000000..b720d3b9 --- /dev/null +++ b/python.md @@ -0,0 +1,58 @@ +# Python script to control Lotus input modules + +Requirements: Python, [PySimpleGUI](https://www.pysimplegui.org) and optionally [pillow](https://pillow.readthedocs.io/en/stable/index.html) + +Use `control.py`. Either the commandline, see `control.py --help` or the graphical version: `control.py --gui` + +``` +options: + -h, --help show this help message and exit + --bootloader Jump to the bootloader to flash new firmware + --sleep, --no-sleep Simulate the host going to sleep or waking up + --brightness BRIGHTNESS + Adjust the brightness. Value 0-255 + --animate, --no-animate + Start/stop vertical scrolling + --pattern {full,lotus,gradient,double-gradient,zigzag,panic,lotus2} + Display a pattern + --image IMAGE Display a PNG or GIF image in black and white only) + --image-grey IMAGE_GREY + Display a PNG or GIF image in greyscale + --percentage PERCENTAGE + Fill a percentage of the screen + --clock Display the current time + --string STRING Display a string or number, like FPS + --symbols SYMBOLS [SYMBOLS ...] + Show symbols (degF, degC, :), snow, cloud, ...) + --gui Launch the graphical version of the program + --blink Blink the current pattern + --breathing Breathing of the current pattern + --eq EQ [EQ ...] Equalizer + --random-eq Random Equalizer + --wpm WPM Demo + --snake Snake + --all-brightnesses Show every pixel in a different brightness + --set-color {white,black,red,green,blue,cyan,yellow,purple} + Set RGB color (C1 Minimal Input Module) + --get-color Get RGB color (C1 Minimal Input Module) + -v, --version Get device version + --serial-dev SERIAL_DEV + Change the serial dev. Probably /dev/ttyACM0 on Linux, COM0 on Windows +``` + +Examples + +```sh +# Launch graphical application +./control.py --gui + +# Show current time and keep updating it +./control.py --clock + +# Draw PNG or GIF +./control.py --image stripe.gif +./control.py --image stripe.png + +# Change brightness (0-255) +./control.py --brightness 50 +```