diff --git a/CLAUDE.md b/CLAUDE.md index faa6ec0..063fa27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with th ## Project Overview -ProductionDeck is an open-source RP2040-based StreamDeck Mini alternative implemented in Rust using the Embassy async framework. It provides full compatibility with official StreamDeck software by implementing the exact USB HID protocol. The device features 6 programmable keys with a shared 80x80 pixel TFT display. +ProductionDeck is an open-source RP2040-based Stream Deck–compatible firmware in Rust (Embassy). Multiple **device profiles** (Mini, Classic/Mk.2, XL, Neo, +, modules, etc.) share the same codebase; each profile selects USB PID, layout, and **Legacy (Mini)** vs **Main/Expanded** HID handling. Physical hardware is often a small key matrix + one ST7735 region unless you build a larger layout. **Current Status**: Alpha - Firmware compiles successfully, ready for hardware testing. @@ -50,11 +50,14 @@ cargo doc --open ## Project Structure ### Core Source Files -- `src/main.rs` - Application entry point and task coordination +- `src/bin/*.rs` - One binary per target device (`mini`, `xl`, `mk2`, `neo`, `plus-xl`, …) +- `src/lib.rs` - Library root (`productiondeck` crate) - `src/config.rs` - Hardware configuration constants and pin assignments -- `src/usb.rs` - USB HID implementation and StreamDeck protocol handling +- `src/device/mod.rs` - USB PID, layout, and protocol family per `Device` +- `src/protocol/v1.rs` / `v2.rs` / `module_6.rs` - HID protocol handlers +- `src/usb.rs` - USB HID class and routing to protocol + channels - `src/display.rs` - Display handling and graphics rendering -- `src/buttons.rs` - Button scanning and debouncing logic +- `src/buttons.rs` - Button matrix / direct scanning ### Configuration Files - `Cargo.toml` - Rust project manifest and dependencies @@ -65,22 +68,21 @@ cargo doc --open ### Documentation - `README.md` - Main project documentation - `LICENSE` - MIT License -- `StreamDeck_Protocol_Reference.md` - Protocol documentation -- `StreamDeck_USB_Implementation.md` - USB implementation details +- `StreamDeck_Protocol_Reference.md` - Protocol documentation (Elgato HID API alignment) ## Architecture Overview ### Hardware Configuration - **Target**: Raspberry Pi Pico (RP2040 dual-core ARM Cortex-M0+) -- **USB Identity**: VID 0x0fd9 (Elgato), PID 0x0063 (StreamDeck Mini) -- **Display**: 1x ST7735 TFT (80x80 pixels) shared by all buttons via SPI -- **Buttons**: 3x2 matrix scan (6 buttons total) -- **Protocol**: USB HID compatible with StreamDeck Mini +- **USB Identity**: VID `0x0FD9` (Elgato); PID depends on selected `Device` (see `src/device/mod.rs`) +- **Display**: Often 1× ST7735; layout/size depend on profile (`display_config`) +- **Buttons**: Matrix or direct wiring per `hardware.rs` / `buttons.rs` +- **Protocol**: Legacy Mini family (`v1`) or Main/Expanded family (`v2`) per Elgato HID docs ### Core Architecture - **Async Embassy framework**: Modern Rust async/await for embedded - **Dual-core design**: Core 0 handles USB/protocol, Core 1 handles displays/buttons -- **USB HID interface**: Exact StreamDeck Mini protocol implementation +- **USB HID interface**: Stream Deck Mini (legacy) or Main protocol (JPEG + feature reports) per build - **Channel communication**: Embassy channels for inter-task communication - **Hardware abstraction**: Configurable pin assignments via `config.rs` diff --git a/Cargo.toml b/Cargo.toml index 163d98d..2b8947e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,6 +102,24 @@ path = "src/bin/plus.rs" test = false bench = false +[[bin]] +name = "mk2" +path = "src/bin/mk2.rs" +test = false +bench = false + +[[bin]] +name = "neo" +path = "src/bin/neo.rs" +test = false +bench = false + +[[bin]] +name = "plus-xl" +path = "src/bin/plus_xl.rs" +test = false +bench = false + [[bin]] name = "module6" path = "src/bin/module6.rs" diff --git a/StreamDeck_Mini_Protocol_Analysis.md b/StreamDeck_Mini_Protocol_Analysis.md deleted file mode 100644 index 777ef9c..0000000 --- a/StreamDeck_Mini_Protocol_Analysis.md +++ /dev/null @@ -1,226 +0,0 @@ -# StreamDeck Mini USB HID Protocol Analysis - -## Overview - -This document summarizes the complete USB HID protocol analysis for StreamDeck Mini compatibility, based on extensive debugging and Wireshark packet analysis. The findings were used to successfully implement a working StreamDeck Mini clone using RP2040 (Raspberry Pi Pico) with Embassy USB stack. - -## Key Findings - -### 1. USB Device Configuration - -**Required USB Descriptors:** -``` -Vendor ID: 0x0fd9 (Elgato Systems) -Product ID: 0x0063 (StreamDeck Mini) -Manufacturer: "Elgato" -Product: "StreamDeck Mini" -Serial Number: "PRODUCTIONDK" (or any 12-char alphanumeric) -Device Release: 0x0200 (USB 2.0) -``` - -**Configuration Descriptor:** -- `bcdUSB`: 0x0200 (USB 2.0) -- `bmAttributes`: 0xA0 (Self-powered, Remote Wakeup) -- `bMaxPower`: 100mA -- `wTotalLength`: Must include HID Report Descriptor length (173 bytes) - -### 2. HID Report Descriptor - -**Critical: Exact 173-byte descriptor required** -```c -const HID_REPORT_DESCRIPTOR: &[u8] = &[ - // Usage Page (Consumer) - 0x05, 0x0c - 0x05, 0x0c, - // Usage (Consumer Control) - 0x09, 0x01 - 0x09, 0x01, - // Collection (Application) - 0xa1, 0x01 - 0xa1, 0x01, - // Usage (Consumer Control) - 0x09, 0x01 - 0x09, 0x01, - // Usage Page (Button) - 0x05, 0x09 - 0x05, 0x09, - // Usage Minimum (0x01) - 0x19, 0x01 - 0x19, 0x01, - // Usage Maximum (0x10) - 0x29, 0x10 - 0x29, 0x10, - // Logical Minimum (0) - 0x15, 0x00 - 0x15, 0x00, - // Logical Maximum (255) - 0x26, 0xff, 0x00 - 0x26, 0xff, 0x00, - // Report Size (8) - 0x75, 0x08 - 0x75, 0x08, - // Report Count (16) - 0x95, 0x10 - 0x95, 0x10, - // Report ID (0x01) - 0x85, 0x01 - 0x85, 0x01, - // Input (Data,Var,Abs) - 0x81, 0x02 - 0x81, 0x02, - // ... (continues for all 10 report IDs) - // Report IDs: 0x01, 0x02, 0x03, 0x04, 0x05, 0x07, 0x0b, 0xa0, 0xa1, 0xa2 - // End Collection - 0xc0 - 0xc0 -]; -``` - -**Total length: 173 bytes (0xAD) - MUST match exactly** - -### 3. Feature Report Commands (GET_REPORT) - -#### Firmware Version Requests - -**Report ID 0xA1 (Primary):** -``` -Host → Device: GET_REPORT Feature 0xA1, wLength=32 -Device → Host: 32 bytes -Format: [0xa1, 0x0c, 0x31, 0x33, 0x00, "3.00.000", ...] -``` - -**Report ID 0x05 (Compatibility):** -``` -Host → Device: GET_REPORT Feature 0x05, wLength=32 -Device → Host: 32 bytes (same format as 0xA1) -Format: [0x05, 0x0c, 0x31, 0x33, 0x00, "3.00.000", ...] -``` - -**Report ID 0x04 (Legacy V1):** -``` -Host → Device: GET_REPORT Feature 0x04, wLength=17 -Device → Host: 17 bytes -Format: [0x04, 0x00, 0x00, 0x00, 0x00, "3.00.000", ...] -``` - -#### Serial Number Request - -**Report ID 0x03:** -``` -Host → Device: GET_REPORT Feature 0x03, wLength=32 -Device → Host: 32 bytes -Format: [0x03, 0x0c, 0x31, 0x33, 0x00, "PRODUCTIONDK", ...] -``` - -### 4. Feature Report Commands (SET_REPORT) - -#### Reset Commands - -**Report ID 0x0B (V1 Legacy):** -``` -Host → Device: SET_REPORT Feature 0x0B, 17 bytes -Data: [0x0b, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] -``` - -**Report ID 0x05 (V1 Reset):** -``` -Host → Device: SET_REPORT Feature 0x05, 17 bytes -Data: [0x05, 0x55, 0xAA, 0xD1, 0x01, 0x3e, ...] -``` - -**Report ID 0x03 (V2 Reset):** -``` -Host → Device: SET_REPORT Feature 0x03, 17 bytes -Data: [0x03, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] -``` - -#### Brightness Commands - -**Report ID 0x05 (V1 Brightness):** -``` -Host → Device: SET_REPORT Feature 0x05, 17 bytes -Data: [0x05, 0x55, 0xAA, 0xD1, 0x01, brightness, ...] -``` - -**Report ID 0x03 (V2 Brightness):** -``` -Host → Device: SET_REPORT Feature 0x03, 17 bytes -Data: [0x03, 0x08, brightness, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] -``` - -### 5. Input Reports (Button States) - -**Report ID 0x01:** -``` -Device → Host: 16 bytes -Format: [0x01, button1, button2, button3, button4, button5, button6, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] -``` - -### 6. Output Reports (Image Data) - -**V2 Protocol (Recommended):** -``` -Host → Device: SET_REPORT Output 0x02, 1024 bytes -Format: [0x02, 0x07, key_id, is_last, payload_len_low, payload_len_high, sequence_low, sequence_high, image_data...] -``` - -## Critical Implementation Details - -### 1. Response Length Requirements - -**32-byte responses are mandatory for:** -- GET_REPORT Feature 0x03 (Serial Number) -- GET_REPORT Feature 0xA1 (Firmware Version) -- GET_REPORT Feature 0x05 (Firmware Version - compatibility) - -**12-byte or 16-byte responses will cause "Read FW version: FAILED"** - -### 2. Report ID Conflicts - -**Important:** Report ID 0x05 serves dual purposes: -- **GET_REPORT**: Firmware Version (32 bytes) -- **SET_REPORT**: Reset/Brightness commands - -Ensure proper handling in both directions. - -### 3. Embassy USB Implementation - -**Key configuration:** -```rust -let hid_config = HidConfig { - report_descriptor: HID_REPORT_DESCRIPTOR, - request_handler: Some(handler), - poll_ms: 1, - max_packet_size: 64, // RP2040 hardware limitation -}; -``` - -**Critical:** Use `HidReaderWriter::<_, 64, 64>::new()` for RP2040 compatibility. - -### 4. Error Patterns - -**Common failure modes:** -1. **"Read FW version: FAILED"**: Wrong response length or missing Report ID handlers -2. **"Problem with connecting HID device: -4"**: USB descriptor mismatch -3. **"Upload Image: FAILED"**: Output report handling not implemented - -## Success Indicators - -**When properly implemented, StreamDeck software logs show:** -``` -Device connected, id: @(1)[4057/99/PRODUCTIONDK], serial number: PRODUCTIONDK, firmware version: 3.00.000, bcdDevice: 2.00 -``` - -**Pico logs show:** -``` -HID Get Report 0xA1: returning 32 bytes, version='3.00.000' -HID Get Report 0x03: returning 32 bytes, serial='PRODUCTIONDK' -``` - -## Implementation Checklist - -- [ ] USB Device Descriptor matches real StreamDeck Mini -- [ ] HID Report Descriptor is exactly 173 bytes -- [ ] GET_REPORT Feature 0x03 returns 32 bytes with serial number -- [ ] GET_REPORT Feature 0xA1 returns 32 bytes with firmware version -- [ ] GET_REPORT Feature 0x05 returns 32 bytes with firmware version (compatibility) -- [ ] SET_REPORT Feature 0x05 handles both Reset and Brightness commands -- [ ] Input Report 0x01 sends button states -- [ ] Output Report 0x02 handles image data (for full functionality) - -## References - -- Real StreamDeck Mini USB traffic analysis via Wireshark -- Embassy USB documentation -- RP2040 USB hardware limitations -- StreamDeck software logs analysis - -## Notes - -This protocol analysis was validated through extensive debugging with actual StreamDeck software, ensuring 100% compatibility for device recognition and basic functionality. Image upload functionality requires additional Output Report implementation. diff --git a/StreamDeck_Protocol_Reference.md b/StreamDeck_Protocol_Reference.md index 2ac0e94..2dbe9b5 100644 --- a/StreamDeck_Protocol_Reference.md +++ b/StreamDeck_Protocol_Reference.md @@ -1,226 +1,57 @@ -# StreamDeck USB HID Protocol Reference - -## Critical Protocol Details Extracted from rust-streamdeck - -This document provides exact packet structures and protocol details needed for USB device implementation. - -## ProductionDeck Implementation Notes - -**ProductionDeck** implements the StreamDeck Mini protocol (PID: 0x0063) using: -- **Language**: Rust with Embassy async framework -- **Target**: RP2040 (Raspberry Pi Pico) -- **USB Stack**: Embassy USB with usbd-hid -- **Protocol Version**: V1 (BMP format, BGR color order) -- **Display**: Single shared 80x80 ST7735 TFT (all 6 buttons display on same screen) - -The protocol implementation in `src/usb.rs` follows these exact specifications. - -## USB Device Configuration - -### Required Identifiers -``` -Vendor ID: 0x0fd9 (Elgato Systems) -Product IDs: - 0x0060 - Original (15 keys, 72x72, BMP, V1 protocol) - 0x0063 - Mini (6 keys, 80x80, BMP, V1 protocol) - 0x006d - Original V2 (15 keys, 72x72, JPEG, V2 protocol) - 0x006c - XL (32 keys, 96x96, JPEG, V2 protocol) - 0x0080 - MK2 (15 keys, 72x72, JPEG, V2 protocol) - 0x0090 - Revised Mini (6 keys, 80x80, BMP, V1 protocol) - 0x0084 - Plus (8 keys, 120x120, JPEG, V2 protocol) -``` - -## Feature Report Commands - -### Version Request -**Host → Device** -``` -V1: GET_REPORT Feature 0x04, wLength=17 -V2: GET_REPORT Feature 0x05, wLength=32 (compatibility) -Primary: GET_REPORT Feature 0xA1, wLength=32 -``` - -**Device → Host Response** -``` -V1: [0x04, 0x00, 0x00, 0x00, 0x00, "3.00.000", ...] (17 bytes, offset 5) -V2: [0x05, 0x0c, 0x31, 0x33, 0x00, "3.00.000", ...] (32 bytes, offset 5) -Primary: [0xa1, 0x0c, 0x31, 0x33, 0x00, "3.00.000", ...] (32 bytes, offset 5) -``` - -**Critical:** 32-byte responses are mandatory for 0x05 and 0xA1. 12-byte responses cause "Read FW version: FAILED". - -### Reset Command -**Host → Device** -``` -V1 Legacy: SET_REPORT Feature 0x0B, 17 bytes -Data: [0x0b, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] - -V1 Reset: SET_REPORT Feature 0x05, 17 bytes -Data: [0x05, 0x55, 0xAA, 0xD1, 0x01, 0x3e, ...] - -V2: SET_REPORT Feature 0x03, 17 bytes -Data: [0x03, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] -``` - -### Brightness Control -**Host → Device** -``` -V1: SET_REPORT Feature 0x05, 17 bytes -Data: [0x05, 0x55, 0xAA, 0xD1, 0x01, brightness, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] - -V2: SET_REPORT Feature 0x03, 17 bytes -Data: [0x03, 0x08, brightness, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] -``` -Where `brightness` = 0-100 (percentage) - -**Note:** Report ID 0x05 serves dual purposes - GET_REPORT for firmware version and SET_REPORT for brightness/reset commands. - -### Serial Number Request -**Host → Device** -``` -GET_REPORT Feature 0x03, wLength=32 -``` - -**Device → Host Response** -``` -[0x03, 0x0c, 0x31, 0x33, 0x00, "PRODUCTIONDK", ...] (32 bytes) -``` - -**Critical:** 32-byte response required. 12-byte responses cause connection failures. - -## Output Report - Image Data - -### V2 Protocol (Recommended) -**Report Structure** (1024 bytes total) -``` -[0x02, 0x07, key_id, is_last, payload_len_low, payload_len_high, sequence_low, sequence_high, image_data...] -``` - -**Field Details:** -- `0x02` - Report ID (constant) -- `0x07` - Image command (constant) -- `key_id` - Button index (0-based) -- `is_last` - 1 if final packet, 0 otherwise -- `payload_len` - Little-endian length of image data in this packet -- `sequence` - Little-endian packet sequence number (starts at 0) -- `image_data` - JPEG image data (up to 1016 bytes per packet) - -### V1 Protocol (Original) -**Report Structure** (8191 bytes total) -``` -Packet 1: [0x02, 0x01, 0x01, 0x00, 0x00, key_id, 0x00...0x00, BMP_header, image_data_7749_bytes] -Packet 2: [0x02, 0x01, 0x02, 0x00, 0x01, key_id, 0x00...0x00, remaining_image_data_7803_bytes] -``` - -**BMP Header for V1 Devices** (54 bytes) -```cpp -const uint8_t BMP_HEADER_ORIGINAL[54] = { - 0x42, 0x4d, 0xf6, 0x3c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x00, 0x00, 0x00, 0x28, 0x00, - 0x00, 0x00, 0x48, 0x00, 0x00, 0x00, 0x48, 0x00, 0x00, 0x00, 0x01, 0x00, 0x18, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xc0, 0x3c, 0x00, 0x00, 0xc4, 0x0e, 0x00, 0x00, 0xc4, 0x0e, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 -}; - -const uint8_t BMP_HEADER_MINI[54] = { - 0x42, 0x4d, 0xf6, 0x3c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x00, 0x00, 0x00, 0x28, 0x00, - 0x00, 0x00, 0x50, 0x00, 0x00, 0x00, 0x50, 0x00, 0x00, 0x00, 0x01, 0x00, 0x18, 0x00, 0x00, 0x00, - 0x00, 0x00, 0xc0, 0x3c, 0x00, 0x00, 0xc4, 0x0e, 0x00, 0x00, 0xc4, 0x0e, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 -}; -``` - -## Input Report - Button States - -### Report Format -``` -Device → Host (continuous polling) -``` - -**V1 Devices (Original, Mini)** -``` -[button_0, button_1, button_2, ..., button_n] -``` - -**V2 Devices (All others)** -``` -[0x??, 0x??, 0x??, button_0, button_1, button_2, ..., button_n] -``` -3-byte header followed by button states - -### Button State Values -- `0x00` - Button released -- `0x01` - Button pressed - -### Button Mapping - -**Original StreamDeck** (Right-to-Left mapping) -``` -Physical Layout: Data Array Index: -[01][02][03][04][05] [05][04][03][02][01] -[06][07][08][09][10] -> [10][09][08][07][06] -[11][12][13][14][15] [15][14][13][12][11] -``` - -**All Other Devices** (Left-to-Right mapping) -``` -Physical Layout: Data Array Index: -[01][02][03] [00][01][02] -[04][05][06] -> [03][04][05] -``` - -### Device Specifications - -| Device | Keys | Image Size | Format | Color Order | Key Layout | Protocol | -|--------|------|------------|--------|-------------|------------|----------| -| Original | 15 | 72x72 | BMP | BGR | 5x3 (R→L) | V1 | -| Mini | 6 | 80x80 | BMP | BGR | 3x2 (L→R) | V1 | -| Original V2 | 15 | 72x72 | JPEG | RGB | 5x3 (L→R) | V2 | -| XL | 32 | 96x96 | JPEG | RGB | 8x4 (L→R) | V2 | -| MK2 | 15 | 72x72 | JPEG | RGB | 5x3 (L→R) | V2 | -| Revised Mini | 6 | 80x80 | BMP | BGR | 3x2 (L→R) | V1 | -| Plus | 8 | 120x120 | JPEG | RGB | 4x2 (L→R) | V2 | - -## Image Processing Requirements - -### V1 Devices (BMP Format) -1. Expect BMP header (54 bytes) + RGB pixel data -2. Color order: BGR (swap R and B channels) -3. Image dimensions must match device specifications -4. Total data: header + (width × height × 3) bytes - -### V2 Devices (JPEG Format) -1. Receive JPEG data directly -2. Color order: RGB (no conversion needed) -3. Decode JPEG to get pixel data for display -4. Support progressive JPEG if needed - -### Image Transformations -- **Mini devices**: Rotate image 270° clockwise -- **Original**: Mirror horizontally (flip Y-axis) -- **Original V2/XL/MK2**: Mirror both axes -- **Plus**: No transformation needed - -## Implementation Checklist - -### Essential USB Functionality -- [x] Implement correct VID/PID for target device -- [x] Handle version feature report (0x04/0x05) -- [x] Handle reset feature report (0x0b/0x03) -- [x] Handle brightness feature report (0x05/0x03) -- [x] Process image output reports (0x02) -- [x] Send button input reports continuously - -### Image Processing -- [x] Parse image headers correctly -- [x] Reassemble multi-packet images -- [x] Handle BMP headers for V1 devices -- [x] Apply device-specific transformations -- [x] Convert color order (BGR ↔ RGB) - -### Hardware Integration -- [x] Read physical button matrix -- [x] Display images on individual key LCDs -- [x] Implement brightness control -- [x] Handle USB connection/disconnection - -This protocol reference provides everything needed to implement a fully compatible StreamDeck USB device that works with official software. \ No newline at end of file +# Stream Deck USB HID — ProductionDeck reference + +This project follows the **Elgato Stream Deck HID API** documentation: + +- [General Reference (Main / Expanded protocol)](https://docs.elgato.com/streamdeck/hid/general) +- [Stream Deck Mini (Legacy protocol)](https://docs.elgato.com/streamdeck/hid/mini) + +There are **two protocol families**: + +| Family | Devices (examples) | Notes | +|--------|-------------------|--------| +| **Legacy / Mini** | Mini `0x0063`, Mini 2022 `0x0090`, Mini Discord `0x00B3`, 6-key module `0x00B8` | Different report IDs and image path (BMP); see Mini HID page | +| **Main / Expanded** | Classic `0x006D`, Mk.2 `0x0080`, XL `0x006C` / `0x008F`, Neo `0x009A`, + `0x0084`, 15/32-key modules `0x00B9`/`0x00BA`, … | JPEG chunks, input report header `RID + cmd + UINT16 len + payload`; General Reference | + +**PID 0x0060** (first-gen Stream Deck): not documented under the Main protocol; firmware may still expose it as legacy V1-style for compatibility. + +## ProductionDeck `Device` → USB PID (VID `0x0FD9`) + +| Variant | PID | Protocol module | +|---------|-----|-------------------| +| Mini | `0x0063` | `src/protocol/v1.rs` | +| RevisedMini (Mini 2022) | `0x0090` | V1 | +| MiniDiscord | `0x00B3` | V1 | +| Original | `0x0060` | V1 (non-doc) | +| OriginalV2 (Classic 2019) | `0x006D` | `src/protocol/v2.rs` | +| Mk2 | `0x0080` | V2 | +| Mk2ScissorKeys | `0x00A5` | V2 | +| Xl | `0x006C` | V2 | +| Xl2022 | `0x008F` | V2 | +| Plus | `0x0084` | V2 | +| PlusXl | `0x0084` | V2 | +| Neo | `0x009A` | V2 | +| Module6Keys | `0x00B8` | `src/protocol/module_6.rs` | +| Module15Keys | `0x00B9` | V2 (same as Classic) | +| Module32Keys | `0x00BA` | V2 (same as XL) | + +Plus and + XL share PID `0x0084` in Elgato’s HID summary table; this firmware distinguishes them by build target (`plus` vs `plus-xl`) and [`config::init_runtime_device`](src/config.rs). + +## Main protocol — implemented highlights (`v2.rs`) + +- **Input (buttons)**: `[0x01, cmd=0x00, len_lo, len_hi, …key bytes]` (`format_button_report`). +- **Output `0x02`**: `0x07` key JPEG, `0x08` full LCD, `0x0B` / `0x0C` window (when device supports), `0x0D` background (Classic/XL family). +- **Feature setter `0x03`**: `0x02` reset, `0x05` fill LCD RGB, `0x06` fill key RGB, `0x08` brightness, `0x0D` sleep seconds, `0x13` show background (XL). +- **Feature getters**: `0x04`/`0x05`/`0x07` firmware, `0x06` serial, `0x08` unit info (from `Device::unit_information_tail()`), `0x0A` sleep duration. +- **Helpers** (for future touch/encoder wiring): `V2Handler::format_input_touch_tap`, `V2Handler::format_input_encoder_rotate`. + +## Firmware binaries (`src/bin/`) + +| Binary | `Device` | +|--------|----------| +| `mini`, `revised-mini` | Mini / Mini 2022 | +| `original`, `original-v2` | Original / Classic 2019 | +| `mk2` | Mk.2 | +| `xl`, `plus`, `neo`, `plus-xl` | XL / + / Neo / + XL | +| `module6`, `module15`, `module32` | Modules | + +Use `ProtocolHandler::create_for_device(device)` so V2 unit information and image rules match the selected `Device` (`src/usb.rs`). diff --git a/StreamDeck_USB_Implementation.md b/StreamDeck_USB_Implementation.md deleted file mode 100644 index 6b57b8d..0000000 --- a/StreamDeck_USB_Implementation.md +++ /dev/null @@ -1,488 +0,0 @@ -# StreamDeck USB HID Device Implementation - -## Complete Implementation Guide for RP2040 with Rust/Embassy - -This document provides implementation guidance for creating a StreamDeck-compatible USB HID device that works with official StreamDeck software on Windows. - -## ProductionDeck Current Implementation - -**ProductionDeck** implements this protocol using: -- **Language**: Rust 2021 Edition -- **Framework**: Embassy async framework -- **Target**: RP2040 (thumbv6m-none-eabi) -- **USB Stack**: Embassy USB with usbd-hid -- **Implementation Files**: - - `src/usb.rs` - USB HID device and protocol handling - - `src/config.rs` - USB descriptors and constants - - `src/main.rs` - Device initialization and task coordination - -The current implementation follows the patterns described below but uses Rust/Embassy instead of C++. - -## Hardware Requirements - -### Supported Platforms -- **RP2040** (Raspberry Pi Pico) - Recommended for USB HID -- **STM32** with USB capability (STM32F4, STM32H7) -- **ESP32-S2/S3** with native USB -- **Arduino Leonardo/Micro** (limited capabilities) - -### Display Requirements -- **ProductionDeck**: Single ST7735 TFT display (80x80px) shared by all 6 buttons -- **Traditional StreamDeck**: Individual TFT LCD per button (e.g., 15x 72x72px displays) -- Button matrix for input detection -- Optional: Status LEDs for connection/error indication - -## USB HID Descriptor Implementation - -### HID Report Descriptor (Rust) - -```rust -// StreamDeck HID Report Descriptor - from src/usb.rs -const HID_REPORT_DESCRIPTOR: &[u8] = &[ - // Usage Page (Generic Desktop) - 0x05, 0x01, - // Usage (Undefined) - 0x09, 0x00, - // Collection (Application) - 0xa1, 0x01, - - // =============================================== - // Input Report (Button States: Device → Host) - // =============================================== - 0x09, 0x00, // Usage (Undefined) - 0x15, 0x00, // Logical Minimum (0) - 0x25, 0x01, // Logical Maximum (1) - 0x75, 0x08, // Report Size (8 bits) - 0x95, STREAMDECK_KEYS as u8, // Report Count (6 buttons) - 0x81, 0x02, // Input (Data, Variable, Absolute) - - // =============================================== - // Output Report (Image Data: Host → Device) - // =============================================== - 0x09, 0x00, // Usage (Undefined) - 0x15, 0x00, // Logical Minimum (0) - 0x26, 0xFF, 0x00, // Logical Maximum (255) - 0x75, 0x08, // Report Size (8 bits) - 0x96, 0x00, 0x04, // Report Count (1024 bytes) - 0x91, 0x02, // Output (Data, Variable, Absolute) - - // =============================================== - // Feature Report (Commands: Bidirectional) - // =============================================== - 0x09, 0x00, // Usage (Undefined) - 0x15, 0x00, // Logical Minimum (0) - 0x26, 0xFF, 0x00, // Logical Maximum (255) - 0x75, 0x08, // Report Size (8 bits) - 0x95, HID_REPORT_SIZE_FEATURE as u8, // Report Count (32 bytes) - 0xb1, 0x02, // Feature (Data, Variable, Absolute) - - // End Collection - 0xc0 -]; -``` - -### USB Device Configuration (Rust) - -```rust -// StreamDeck Mini configuration - from src/config.rs and src/usb.rs -use embassy_usb::{Builder, Config}; -use embassy_usb::class::hid::{HidReaderWriter, RequestHandler, State, Config as HidConfig}; - -// USB identifiers (from config.rs) -pub const USB_VID: u16 = 0x0fd9; // Elgato -pub const USB_PID: u16 = 0x0063; // StreamDeck Mini -pub const USB_MANUFACTURER: &str = "Elgato Systems"; -pub const USB_PRODUCT: &str = "Stream Deck Mini"; -pub const USB_SERIAL: &str = "ProductionDeck001"; - -// Device specifications -pub const STREAMDECK_KEYS: usize = 6; -pub const STREAMDECK_KEY_SIZE: usize = 80; -pub const HID_REPORT_SIZE_FEATURE: usize = 32; -pub const USB_POLL_RATE_MS: u16 = 1; - -// USB configuration function -fn create_usb_config() -> Config<'static> { - let mut config = Config::new(USB_VID, USB_PID); - config.manufacturer = Some(USB_MANUFACTURER); - config.product = Some(USB_PRODUCT); - config.serial_number = Some(USB_SERIAL); - config.max_power = 100; // 200mA - config.max_packet_size_0 = 64; - config.device_class = 0x00; // Interface-defined - config.device_sub_class = 0x00; - config.device_protocol = 0x00; - config.composite_with_iads = false; - config -} - -// HID Request Handler -struct StreamDeckHidHandler { - usb_command_sender: embassy_sync::channel::Sender<'static, - embassy_sync::blocking_mutex::raw::ThreadModeRawMutex, UsbCommand, 4>, -} - -impl StreamDeckHidHandler { - fn new() -> Self { - Self { - usb_command_sender: USB_COMMAND_CHANNEL.sender(), - } - } -} -``` - -## Protocol Implementation - -### Feature Report Handling (Rust) - -```rust -// HID Request Handler implementation - from src/usb.rs -impl RequestHandler for StreamDeckHidHandler { - fn get_report(&mut self, id: ReportId, _buf: &mut [u8]) -> Option { - info!("HID Get Report: ID={:?}", id); - - match id { - ReportId::In(_) => { - // Button state will be sent via separate input reports - None - } - ReportId::Feature(report_id) => { - // Handle feature report requests (version, etc.) - if report_id == FEATURE_REPORT_VERSION_V1 || report_id == FEATURE_REPORT_VERSION_V2 { - // Version request - return firmware version - _buf[0] = report_id; - let offset = if report_id == FEATURE_REPORT_VERSION_V2 { 6 } else { 5 }; - let version = b"1.0.0"; - - if _buf.len() > offset + version.len() { - _buf[offset..offset + version.len()].copy_from_slice(version); - return Some(HID_REPORT_SIZE_FEATURE); - } - } - None - } - _ => None, - } - } - - fn set_report(&mut self, id: ReportId, data: &[u8]) -> OutResponse { - info!("HID Set Report: ID={:?}, len={}", id, data.len()); - - match id { - ReportId::Feature(report_id) => { - self.handle_feature_report(report_id, data); - } - ReportId::Out(_) => { - self.handle_output_report(data); - } - _ => {} - } - - None - } -} - -// Feature report command handling -impl StreamDeckHidHandler { - fn handle_feature_report(&mut self, report_id: u8, data: &[u8]) { - match report_id { - FEATURE_REPORT_RESET_V1 => { - // V1 Reset: [0x0B, 0x63, ...] - if data.len() >= 2 && data[1] == 0x63 { - info!("USB: Reset command (V1)"); - let _ = self.usb_command_sender.try_send(UsbCommand::Reset); - } - } - 0x03 => { - // V2 commands: [0x03, command_byte, ...] - if data.len() >= 2 { - match data[1] { - 0x02 => { - // V2 Reset: [0x03, 0x02, ...] - info!("USB: Reset command (V2)"); - let _ = self.usb_command_sender.try_send(UsbCommand::Reset); - } - 0x08 => { - // V2 Brightness: [0x03, 0x08, brightness, ...] - if data.len() >= 3 { - let brightness = data[2]; - info!("USB: Set brightness {}% (V2)", brightness); - let _ = self.usb_command_sender.try_send(UsbCommand::SetBrightness(brightness)); - } - } - _ => { - warn!("Unknown V2 command: 0x{:02X}", data[1]); - } - } - } - } - FEATURE_REPORT_BRIGHTNESS_V1 => { - // V1 Brightness: [0x05, 0x55, 0xAA, 0xD1, 0x01, brightness, ...] - if data.len() >= 6 && data[1] == 0x55 && data[2] == 0xAA && - data[3] == 0xD1 && data[4] == 0x01 { - let brightness = data[5]; - info!("USB: Set brightness {}% (V1)", brightness); - let _ = self.usb_command_sender.try_send(UsbCommand::SetBrightness(brightness)); - } - } - _ => { - warn!("Unknown feature report ID: 0x{:02X}", report_id); - } - } - } -} -``` - -### Image Data Processing (Rust) - -```rust -// Output report (image data) handling - from src/usb.rs -impl StreamDeckHidHandler { - fn handle_output_report(&mut self, data: &[u8]) { - if data.len() < 8 { - warn!("Invalid output report length: {}", data.len()); - return; - } - - debug!("USB Output Report: {} bytes received", data.len()); - debug!("Header: [{:02X}, {:02X}, {:02X}, {:02X}, {:02X}, {:02X}, {:02X}, {:02X}]", - data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7]); - - // Parse output report header (StreamDeck Mini V2 protocol) - if data[0] == OUTPUT_REPORT_IMAGE && data[1] == IMAGE_COMMAND_V2 { - // V2 Image protocol: [0x02, 0x07, key_id, is_last, len_low, len_high, seq_low, seq_high, data...] - let key_id = data[2]; - let _is_last = data[3]; - let payload_len = u16::from_le_bytes([data[4], data[5]]); - let _sequence = u16::from_le_bytes([data[6], data[7]]); - - debug!("Image packet: key={} seq={} len={} last={}", - key_id, _sequence, payload_len, _is_last); - - if key_id < STREAMDECK_KEYS as u8 { - // Convert slice to heapless Vec for channel communication - let mut image_data = Vec::new(); - if image_data.extend_from_slice(data).is_ok() { - let _ = self.usb_command_sender.try_send(UsbCommand::ImageData { - key_id, - data: image_data - }); - } else { - error!("Failed to copy image data to buffer"); - } - } else { - error!("Invalid key_id {} (max {})", key_id, STREAMDECK_KEYS - 1); - } - } else { - debug!("Unknown output report format: [0x{:02X}, 0x{:02X}]", data[0], data[1]); - } - } -} - -// USB Command processing task -let command_fut = async { - let receiver = USB_COMMAND_CHANNEL.receiver(); - loop { - match receiver.receive().await { - UsbCommand::Reset => { - info!("Processing reset command"); - let _ = DISPLAY_CHANNEL.sender().send(DisplayCommand::ClearAll).await; - } - UsbCommand::SetBrightness(brightness) => { - info!("Processing brightness command: {}%", brightness); - let _ = DISPLAY_CHANNEL.sender().send(DisplayCommand::SetBrightness(brightness)).await; - } - UsbCommand::ImageData { key_id, data } => { - debug!("Processing image data for key {}", key_id); - let _ = DISPLAY_CHANNEL.sender().send(DisplayCommand::DisplayImage { key_id, data }).await; - } - } - } -}; -``` - -### Button State Reporting (Rust) - -```rust -// Button report handling - from src/usb.rs -let button_fut = async { - let receiver = BUTTON_CHANNEL.receiver(); - loop { - let button_state = receiver.receive().await; - if button_state.changed { - // Convert button state to HID report format - let mut report = [0u8; STREAMDECK_KEYS]; - for (i, &pressed) in button_state.buttons.iter().enumerate() { - report[i] = if pressed { 1 } else { 0 }; - } - - // Send button report - match writer.write(&report).await { - Ok(()) => { - debug!("Button report sent: {:?}", report); - } - Err(e) => { - warn!("Failed to send button report: {:?}", e); - } - } - } - } -}; - -// Button state structure (from main.rs) -#[derive(Clone, Debug, Format)] -pub struct ButtonState { - pub buttons: [bool; STREAMDECK_KEYS], - pub changed: bool, -} - -// Channel communication for button events -use embassy_sync::channel::Channel; -use embassy_sync::blocking_mutex::raw::ThreadModeRawMutex; - -pub static BUTTON_CHANNEL: Channel = Channel::new(); -``` - -## Hardware Integration Examples - -### RP2040 Implementation (Rust) - -```rust -// Main application - from src/main.rs -use embassy_executor::Spawner; -use embassy_rp::bind_interrupts; -use embassy_rp::peripherals::USB; -use embassy_rp::usb::{Driver, InterruptHandler}; - -bind_interrupts!(struct Irqs { - USBCTRL_IRQ => InterruptHandler; -}); - -#[embassy_executor::main] -async fn main(spawner: Spawner) { - let p = embassy_rp::init(Default::default()); - - // Create USB driver - let driver = Driver::new(p.USB, Irqs); - - // Spawn USB task - spawner.spawn(usb_task(driver, p.PIN_20)).unwrap(); - - // Spawn display task on core 1 - spawner.spawn(display_task( - p.SPI0, p.PIN_19, p.PIN_18, p.PIN_14, p.PIN_15, p.PIN_8, p.PIN_17 - )).unwrap(); - - // Spawn button scanning task - spawner.spawn(button_task( - p.PIN_2, p.PIN_3, p.PIN_4, p.PIN_5, p.PIN_6 - )).unwrap(); - - // Main loop - Embassy handles everything asynchronously - loop { - Timer::after(Duration::from_secs(1)).await; - } -} - -// USB task spawned on core 0 -#[embassy_executor::task] -pub async fn usb_task( - driver: Driver<'static, peripherals::USB>, - usb_led_pin: peripherals::PIN_20, -) { - // USB implementation as shown above... -} -``` - -### Embassy Async Architecture (Rust) - -```rust -// Embassy provides async/await for embedded systems -// Multiple concurrent tasks instead of traditional main loop - -// Task coordination using channels -use embassy_sync::channel::Channel; -use embassy_sync::blocking_mutex::raw::ThreadModeRawMutex; - -// Communication channels between tasks -pub static BUTTON_CHANNEL: Channel = Channel::new(); -pub static USB_COMMAND_CHANNEL: Channel = Channel::new(); -pub static DISPLAY_CHANNEL: Channel = Channel::new(); - -// USB commands enum -#[derive(Clone, Debug, Format)] -pub enum UsbCommand { - Reset, - SetBrightness(u8), - ImageData { - key_id: u8, - data: heapless::Vec, - }, -} - -// Display commands enum -#[derive(Clone, Debug, Format)] -pub enum DisplayCommand { - ClearAll, - SetBrightness(u8), - DisplayImage { - key_id: u8, - data: heapless::Vec, - }, -} - -// All tasks run concurrently using Embassy's async executor -// - USB task handles HID protocol -// - Button task scans physical buttons -// - Display task manages TFT screen -// - All communicate via channels (lock-free, async) -``` - -## Testing and Validation - -### Windows Testing -1. Install official Stream Deck software -2. Connect your device -3. Verify recognition in Device Manager (should show as "Stream Deck Mini") -4. Test button presses and image updates in Stream Deck software - -### Protocol Validation (Rust) -```rust -// Debug logging using defmt (Real-Time Transfer) -use defmt::{debug, info, warn, error}; - -// Protocol debugging in handle_output_report -debug!("USB Output Report: {} bytes received", data.len()); -debug!("Header: [{:02X}, {:02X}, {:02X}, {:02X}, {:02X}, {:02X}, {:02X}, {:02X}]", - data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7]); - -// Feature report debugging -info!("HID Get Report: ID={:?}", id); -info!("HID Set Report: ID={:?}, len={}", id, data.len()); - -// Command processing logs -info!("USB: Reset command (V1)"); -info!("USB: Set brightness {}% (V2)", brightness); -debug!("Processing image data for key {}", key_id); - -// Use RTT (Real-Time Transfer) for zero-overhead logging -// Set DEFMT_LOG environment variable to control log levels: -// DEFMT_LOG=debug cargo build -``` - -## Key Implementation Notes - -1. **USB Timing**: Maintain consistent 1ms USB polling for responsive button detection -2. **Image Processing**: V1 devices use BMP with BGR color order, V2 use JPEG with RGB -3. **Memory Management**: Image buffers require significant RAM (6 * 80 * 80 * 3 = 115KB for Mini) -4. **Display Updates**: Rotate images 270° for Mini devices to match orientation -5. **Error Handling**: Implement proper USB error recovery and reconnection logic - -## References - -- **Protocol Analysis**: https://gist.github.com/cliffrowley/d18a9c4569537b195f2b1eb6c68469e0 -- **Rust Implementation**: https://github.com/ryankurte/rust-streamdeck -- **Elgato HID Docs**: https://docs.elgato.com/streamdeck/hid/ -- **USB HID Specification**: https://www.usb.org/hid - -This implementation provides a complete foundation for creating StreamDeck-compatible devices that work seamlessly with official software. \ No newline at end of file diff --git a/src/bin/mini.rs b/src/bin/mini.rs index dcff354..ff187a7 100644 --- a/src/bin/mini.rs +++ b/src/bin/mini.rs @@ -1,188 +1,22 @@ #![allow(unreachable_code)] -//! ProductionDeck - StreamDeck Mini Compatible Firmware -//! -//! This binary builds firmware specifically for StreamDeck Mini compatibility: -//! - 6 keys in 3x2 layout -//! - 80x80 pixel images per key -//! - USB VID:PID 0x0fd9:0x0063 -//! - V1 BMP protocol +//! ProductionDeck — StreamDeck Mini (PID `0x0063`), V1 BMP, 3×2 keys. #![no_std] #![no_main] -use defmt::*; +use cortex_m_rt::entry; use defmt_rtt as _; -use embassy_executor::Executor; -use embassy_rp::multicore::{spawn_core1, Stack}; -use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; -use embassy_sync::channel::Channel; use panic_halt as _; -use static_cell::StaticCell; +use productiondeck::device::Device; +use productiondeck::entry::{run_multicore, MulticoreCore0Layout, MulticoreCore1Buffer}; -// Set compile-time device selection -const DEVICE: productiondeck::device::Device = productiondeck::device::Device::Mini; +const DEVICE: Device = Device::Mini; -// Import all modules from library -extern crate productiondeck; -use productiondeck::*; - -// Multicore setup -static mut CORE1_STACK: Stack<4096> = Stack::new(); -static EXECUTOR0: StaticCell = StaticCell::new(); -static EXECUTOR1: StaticCell = StaticCell::new(); - -// Inter-core communication channel for image processing -static IMAGE_CHANNEL: Channel = - Channel::new(); - -/// Main application entry point for StreamDeck Mini with multicore support -#[cortex_m_rt::entry] +#[entry] fn main() -> ! { - // Initialize hardware - let p = embassy_rp::init(Default::default()); - - // Create application supervisor for Mini - let supervisor = supervisor::AppSupervisor::new_for_device(DEVICE); - - // Print startup information - supervisor.print_startup_banner(); - - // Spawn core 1 for image processing and display tasks - spawn_core1( - p.CORE1, - unsafe { &mut *core::ptr::addr_of_mut!(CORE1_STACK) }, - move || { - let executor1 = EXECUTOR1.init(Executor::new()); - executor1.run(|spawner| { - unwrap!(spawner.spawn(core1_image_processing_task())); - }); - }, - ); - - // Run core 0 for USB, buttons, and supervision - let executor0 = EXECUTOR0.init(Executor::new()); - executor0.run(|spawner| { - unwrap!(spawner.spawn(core0_main_task(supervisor))); - // Also spawn the USB task directly - unwrap!(spawner.spawn(usb::usb_task_for_device( - embassy_rp::usb::Driver::new(p.USB, crate::Irqs), - embassy_rp::gpio::Output::new(p.PIN_20, embassy_rp::gpio::Level::Low), - DEVICE - ))); - // Spawn button task for Mini (Direct mode) - unwrap!(spawner.spawn(buttons::button_task_direct({ - let mut inputs = heapless::Vec::new(); - let _ = inputs.push(embassy_rp::gpio::Input::new( - p.PIN_4, - embassy_rp::gpio::Pull::Up, - )); - let _ = inputs.push(embassy_rp::gpio::Input::new( - p.PIN_5, - embassy_rp::gpio::Pull::Up, - )); - let _ = inputs.push(embassy_rp::gpio::Input::new( - p.PIN_6, - embassy_rp::gpio::Pull::Up, - )); - let _ = inputs.push(embassy_rp::gpio::Input::new( - p.PIN_10, - embassy_rp::gpio::Pull::Up, - )); - let _ = inputs.push(embassy_rp::gpio::Input::new( - p.PIN_11, - embassy_rp::gpio::Pull::Up, - )); - let _ = inputs.push(embassy_rp::gpio::Input::new( - p.PIN_12, - embassy_rp::gpio::Pull::Up, - )); - inputs - }))); - // Spawn status LED task - unwrap!(spawner.spawn(hardware::status_task( - embassy_rp::gpio::Output::new(p.PIN_25, embassy_rp::gpio::Level::Low), - embassy_rp::gpio::Output::new(p.PIN_21, embassy_rp::gpio::Level::Low) - ))); - }); - - // This should never be reached - loop { - cortex_m::asm::wfe(); - } -} - -/// Core 0 main task: USB, buttons, and supervision -#[embassy_executor::task] -async fn core0_main_task(mut supervisor: supervisor::AppSupervisor) { - info!("Core 0: Starting USB and button tasks"); - - // Initialize and spawn core 0 tasks (USB, buttons) - // Note: spawner is not available in this context, we'll use the existing channel system - info!("Core 0: StreamDeck Mini firmware initialized successfully"); - supervisor.print_init_success(); - - // Run the main supervisor loop - supervisor.run().await; -} - -/// Core 1 task: Image processing and display -#[embassy_executor::task] -async fn core1_image_processing_task() { - info!("Core 1: Starting image processing and display tasks"); - - // Initialize and spawn core 1 tasks (display, image processing) - match hardware::init_hardware_tasks_core1(DEVICE).await { - Ok(()) => { - info!("Core 1: Image processing tasks initialized successfully"); - } - Err(e) => { - error!("Core 1: Failed to spawn image processing tasks: {:?}", e); - core::panic!("Image processing initialization failed"); - } - } - - // Optimized image processing buffer - let mut image_processing_buffer = [0u8; 8192]; // 8KB buffer for image processing - - // Process display commands from core 0 - let receiver = IMAGE_CHANNEL.receiver(); - loop { - match receiver.receive().await { - productiondeck::types::DisplayCommand::DisplayImage { key_id, data } => { - info!( - "Core 1: Processing image for key {} ({} bytes)", - key_id, - data.len() - ); - - // Optimized image processing with larger buffer - if data.len() <= image_processing_buffer.len() { - // Copy data to processing buffer for faster access - let copy_len = data.len().min(image_processing_buffer.len()); - image_processing_buffer[..copy_len].copy_from_slice(&data[..copy_len]); - - // TODO: Implement actual image processing and display - // Process image from buffer for better performance - } else { - warn!( - "Core 1: Image too large for buffer ({} > {} bytes)", - data.len(), - image_processing_buffer.len() - ); - } - } - productiondeck::types::DisplayCommand::SetBrightness(brightness) => { - info!("Core 1: Setting brightness to {}%", brightness); - // TODO: Implement brightness control - } - productiondeck::types::DisplayCommand::ClearAll => { - info!("Core 1: Clearing all displays"); - // TODO: Implement display clear - } - productiondeck::types::DisplayCommand::Clear(key_id) => { - info!("Core 1: Clearing display for key {}", key_id); - // TODO: Implement single key clear - } - } - } + run_multicore( + DEVICE, + MulticoreCore0Layout::MiniOrModule6Direct, + MulticoreCore1Buffer::B8192, + ) } diff --git a/src/bin/mk2.rs b/src/bin/mk2.rs new file mode 100644 index 0000000..488bb1e --- /dev/null +++ b/src/bin/mk2.rs @@ -0,0 +1,17 @@ +//! ProductionDeck — Stream Deck Mk.2 (PID `0x0080`). + +#![no_std] +#![no_main] + +use defmt_rtt as _; +use embassy_executor::Spawner; +use panic_halt as _; +use productiondeck::device::Device; +use productiondeck::entry::run_single_core_quiet; + +const DEVICE: Device = Device::Mk2; + +#[embassy_executor::main] +async fn main(spawner: Spawner) { + run_single_core_quiet(spawner, DEVICE).await; +} diff --git a/src/bin/module15.rs b/src/bin/module15.rs index 9b3250b..860f484 100644 --- a/src/bin/module15.rs +++ b/src/bin/module15.rs @@ -1,173 +1,22 @@ #![allow(unreachable_code)] -//! ProductionDeck - Stream Deck Module 15 Compatible Firmware -//! -//! This binary builds firmware specifically for Stream Deck Module 15 compatibility: -//! - 15 keys in 5x3 layout -//! - 72x72 pixel images per key (rotate 180° per spec) -//! - USB VID:PID 0x0FD9:0x00B9 -//! - Module HID protocol (Input 512B, Output 1024B, Feature 32B) +//! ProductionDeck — Stream Deck Module 15 (PID `0x00B9`), 5×3 keys. #![no_std] #![no_main] -use defmt::*; +use cortex_m_rt::entry; use defmt_rtt as _; -use embassy_executor::Executor; -use embassy_rp::multicore::{spawn_core1, Stack}; -use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; -use embassy_sync::channel::Channel; use panic_halt as _; -use static_cell::StaticCell; +use productiondeck::device::Device; +use productiondeck::entry::{run_multicore, MulticoreCore0Layout, MulticoreCore1Buffer}; -// Set compile-time device selection -const DEVICE: productiondeck::device::Device = productiondeck::device::Device::Module15Keys; +const DEVICE: Device = Device::Module15Keys; -// Import all modules from library -extern crate productiondeck; -use productiondeck::*; - -// Multicore setup -static mut CORE1_STACK: Stack<4096> = Stack::new(); -static EXECUTOR0: StaticCell = StaticCell::new(); -static EXECUTOR1: StaticCell = StaticCell::new(); - -// Inter-core communication channel for image processing -static IMAGE_CHANNEL: Channel = - Channel::new(); - -/// Main application entry point for Stream Deck Module 15 with multicore support -#[cortex_m_rt::entry] +#[entry] fn main() -> ! { - // Initialize hardware - let p = embassy_rp::init(Default::default()); - - // Create application supervisor for Module 15 - let supervisor = supervisor::AppSupervisor::new_for_device(DEVICE); - - // Print startup information - supervisor.print_startup_banner(); - - // Spawn core 1 for image processing and display tasks - spawn_core1( - p.CORE1, - unsafe { &mut *core::ptr::addr_of_mut!(CORE1_STACK) }, - move || { - let executor1 = EXECUTOR1.init(Executor::new()); - executor1.run(|spawner| { - unwrap!(spawner.spawn(core1_image_processing_task())); - }); - }, - ); - - // Run core 0 for USB, buttons, and supervision - let executor0 = EXECUTOR0.init(Executor::new()); - executor0.run(|spawner| { - unwrap!(spawner.spawn(core0_main_task(supervisor))); - // Also spawn the USB task directly - unwrap!(spawner.spawn(usb::usb_task_for_device( - embassy_rp::usb::Driver::new(p.USB, crate::Irqs), - embassy_rp::gpio::Output::new(p.PIN_20, embassy_rp::gpio::Level::Low), - DEVICE - ))); - // Spawn button task for Module 15 (matrix 5x3 = 15 buttons) - unwrap!(spawner.spawn(buttons::button_task_matrix_5x3( - // rows: 3 outputs (per hardware config: 2, 3, 7) - embassy_rp::gpio::Output::new(p.PIN_2, embassy_rp::gpio::Level::High), - embassy_rp::gpio::Output::new(p.PIN_3, embassy_rp::gpio::Level::High), - embassy_rp::gpio::Output::new(p.PIN_7, embassy_rp::gpio::Level::High), - // cols: 5 inputs with pull-ups (per hardware config: 4, 5, 6, 10, 11) - embassy_rp::gpio::Input::new(p.PIN_4, embassy_rp::gpio::Pull::Up), - embassy_rp::gpio::Input::new(p.PIN_5, embassy_rp::gpio::Pull::Up), - embassy_rp::gpio::Input::new(p.PIN_6, embassy_rp::gpio::Pull::Up), - embassy_rp::gpio::Input::new(p.PIN_10, embassy_rp::gpio::Pull::Up), - embassy_rp::gpio::Input::new(p.PIN_11, embassy_rp::gpio::Pull::Up), - ))); - // Spawn status LED task - unwrap!(spawner.spawn(hardware::status_task( - embassy_rp::gpio::Output::new(p.PIN_25, embassy_rp::gpio::Level::Low), - embassy_rp::gpio::Output::new(p.PIN_21, embassy_rp::gpio::Level::Low) - ))); - }); - - // This should never be reached - loop { - cortex_m::asm::wfe(); - } -} - -/// Core 0 main task: USB, buttons, and supervision -#[embassy_executor::task] -async fn core0_main_task(mut supervisor: supervisor::AppSupervisor) { - info!("Core 0: Starting USB and button tasks"); - - // Initialize and spawn core 0 tasks (USB, buttons) - // Note: spawner is not available in this context, we'll use the existing channel system - info!("Core 0: Stream Deck Module 15 firmware initialized successfully"); - supervisor.print_init_success(); - - // Run the main supervisor loop - supervisor.run().await; -} - -/// Core 1 task: Image processing and display -#[embassy_executor::task] -async fn core1_image_processing_task() { - info!("Core 1: Starting image processing and display tasks"); - - // Initialize and spawn core 1 tasks (display, image processing) - match hardware::init_hardware_tasks_core1(DEVICE).await { - Ok(()) => { - info!("Core 1: Image processing tasks initialized successfully"); - } - Err(e) => { - error!("Core 1: Failed to spawn image processing tasks: {:?}", e); - core::panic!("Image processing initialization failed"); - } - } - - // Optimized image processing buffer for Module 15 (72x72 JPEG) - let mut image_processing_buffer = [0u8; 8192]; // 8KB buffer for image processing - - // Process display commands from core 0 - let receiver = IMAGE_CHANNEL.receiver(); - loop { - match receiver.receive().await { - productiondeck::types::DisplayCommand::DisplayImage { key_id, data } => { - info!( - "Core 1: Processing image for key {} ({} bytes)", - key_id, - data.len() - ); - - // Optimized image processing with larger buffer - if data.len() <= image_processing_buffer.len() { - // Copy data to processing buffer for faster access - let copy_len = data.len().min(image_processing_buffer.len()); - image_processing_buffer[..copy_len].copy_from_slice(&data[..copy_len]); - - // TODO: Implement actual image processing and display for Module 15 - // Process image from buffer for better performance - // Note: Module 15 uses 72x72 JPEG images that need 180° rotation - } else { - warn!( - "Core 1: Image too large for buffer ({} > {} bytes)", - data.len(), - image_processing_buffer.len() - ); - } - } - productiondeck::types::DisplayCommand::SetBrightness(brightness) => { - info!("Core 1: Setting brightness to {}%", brightness); - // TODO: Implement brightness control - } - productiondeck::types::DisplayCommand::ClearAll => { - info!("Core 1: Clearing all displays"); - // TODO: Implement display clear - } - productiondeck::types::DisplayCommand::Clear(key_id) => { - info!("Core 1: Clearing display for key {}", key_id); - // TODO: Implement single key clear - } - } - } + run_multicore( + DEVICE, + MulticoreCore0Layout::Module15Matrix, + MulticoreCore1Buffer::B8192, + ) } diff --git a/src/bin/module32.rs b/src/bin/module32.rs index d641ade..401f106 100644 --- a/src/bin/module32.rs +++ b/src/bin/module32.rs @@ -1,177 +1,22 @@ #![allow(unreachable_code)] -//! ProductionDeck - Stream Deck Module 32 Compatible Firmware -//! -//! This binary builds firmware specifically for Stream Deck Module 32 compatibility: -//! - 32 keys in 8x4 layout -//! - 96x96 pixel images per key (rotate 180° per spec) -//! - USB VID:PID 0x0FD9:0x00BA -//! - Module HID protocol (Input 512B, Output 1024B, Feature 32B) +//! ProductionDeck — Stream Deck Module 32 (PID `0x00BA`), 8×4 keys. #![no_std] #![no_main] -use defmt::*; +use cortex_m_rt::entry; use defmt_rtt as _; -use embassy_executor::Executor; -use embassy_rp::multicore::{spawn_core1, Stack}; -use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; -use embassy_sync::channel::Channel; use panic_halt as _; -use static_cell::StaticCell; +use productiondeck::device::Device; +use productiondeck::entry::{run_multicore, MulticoreCore0Layout, MulticoreCore1Buffer}; -// Set compile-time device selection -const DEVICE: productiondeck::device::Device = productiondeck::device::Device::Module32Keys; +const DEVICE: Device = Device::Module32Keys; -// Import all modules from library -extern crate productiondeck; -use productiondeck::*; - -// Multicore setup -static mut CORE1_STACK: Stack<4096> = Stack::new(); -static EXECUTOR0: StaticCell = StaticCell::new(); -static EXECUTOR1: StaticCell = StaticCell::new(); - -// Inter-core communication channel for image processing -static IMAGE_CHANNEL: Channel = - Channel::new(); - -/// Main application entry point for Stream Deck Module 32 with multicore support -#[cortex_m_rt::entry] +#[entry] fn main() -> ! { - // Initialize hardware - let p = embassy_rp::init(Default::default()); - - // Create application supervisor for Module 32 - let supervisor = supervisor::AppSupervisor::new_for_device(DEVICE); - - // Print startup information - supervisor.print_startup_banner(); - - // Spawn core 1 for image processing and display tasks - spawn_core1( - p.CORE1, - unsafe { &mut *core::ptr::addr_of_mut!(CORE1_STACK) }, - move || { - let executor1 = EXECUTOR1.init(Executor::new()); - executor1.run(|spawner| { - unwrap!(spawner.spawn(core1_image_processing_task())); - }); - }, - ); - - // Run core 0 for USB, buttons, and supervision - let executor0 = EXECUTOR0.init(Executor::new()); - executor0.run(|spawner| { - unwrap!(spawner.spawn(core0_main_task(supervisor))); - // Also spawn the USB task directly - unwrap!(spawner.spawn(usb::usb_task_for_device( - embassy_rp::usb::Driver::new(p.USB, crate::Irqs), - embassy_rp::gpio::Output::new(p.PIN_25, embassy_rp::gpio::Level::Low), - DEVICE - ))); - // Spawn button task for Module 32 (matrix 8x4 = 32 buttons) - unwrap!(spawner.spawn(buttons::button_task_matrix_8x4( - // rows: 4 outputs - embassy_rp::gpio::Output::new(p.PIN_2, embassy_rp::gpio::Level::High), - embassy_rp::gpio::Output::new(p.PIN_3, embassy_rp::gpio::Level::High), - embassy_rp::gpio::Output::new(p.PIN_7, embassy_rp::gpio::Level::High), - embassy_rp::gpio::Output::new(p.PIN_9, embassy_rp::gpio::Level::High), - // cols: 8 inputs with pull-ups - embassy_rp::gpio::Input::new(p.PIN_4, embassy_rp::gpio::Pull::Up), - embassy_rp::gpio::Input::new(p.PIN_5, embassy_rp::gpio::Pull::Up), - embassy_rp::gpio::Input::new(p.PIN_6, embassy_rp::gpio::Pull::Up), - embassy_rp::gpio::Input::new(p.PIN_10, embassy_rp::gpio::Pull::Up), - embassy_rp::gpio::Input::new(p.PIN_11, embassy_rp::gpio::Pull::Up), - embassy_rp::gpio::Input::new(p.PIN_12, embassy_rp::gpio::Pull::Up), - embassy_rp::gpio::Input::new(p.PIN_13, embassy_rp::gpio::Pull::Up), - embassy_rp::gpio::Input::new(p.PIN_16, embassy_rp::gpio::Pull::Up), - ))); - // Spawn status LED task (using different pins to avoid conflicts) - unwrap!(spawner.spawn(hardware::status_task( - embassy_rp::gpio::Output::new(p.PIN_20, embassy_rp::gpio::Level::Low), - embassy_rp::gpio::Output::new(p.PIN_21, embassy_rp::gpio::Level::Low) - ))); - }); - - // This should never be reached - loop { - cortex_m::asm::wfe(); - } -} - -/// Core 0 main task: USB, buttons, and supervision -#[embassy_executor::task] -async fn core0_main_task(mut supervisor: supervisor::AppSupervisor) { - info!("Core 0: Starting USB and button tasks"); - - // Initialize and spawn core 0 tasks (USB, buttons) - // Note: spawner is not available in this context, we'll use the existing channel system - info!("Core 0: Stream Deck Module 32 firmware initialized successfully"); - supervisor.print_init_success(); - - // Run the main supervisor loop - supervisor.run().await; -} - -/// Core 1 task: Image processing and display -#[embassy_executor::task] -async fn core1_image_processing_task() { - info!("Core 1: Starting image processing and display tasks"); - - // Initialize and spawn core 1 tasks (display, image processing) - match hardware::init_hardware_tasks_core1(DEVICE).await { - Ok(()) => { - info!("Core 1: Image processing tasks initialized successfully"); - } - Err(e) => { - error!("Core 1: Failed to spawn image processing tasks: {:?}", e); - core::panic!("Image processing initialization failed"); - } - } - - // Optimized image processing buffer for Module 32 (96x96 JPEG) - let mut image_processing_buffer = [0u8; 16384]; // 16KB buffer for larger images - - // Process display commands from core 0 - let receiver = IMAGE_CHANNEL.receiver(); - loop { - match receiver.receive().await { - productiondeck::types::DisplayCommand::DisplayImage { key_id, data } => { - info!( - "Core 1: Processing image for key {} ({} bytes)", - key_id, - data.len() - ); - - // Optimized image processing with larger buffer - if data.len() <= image_processing_buffer.len() { - // Copy data to processing buffer for faster access - let copy_len = data.len().min(image_processing_buffer.len()); - image_processing_buffer[..copy_len].copy_from_slice(&data[..copy_len]); - - // TODO: Implement actual image processing and display for Module 32 - // Process image from buffer for better performance - // Note: Module 32 uses 96x96 JPEG images that need 180° rotation - } else { - warn!( - "Core 1: Image too large for buffer ({} > {} bytes)", - data.len(), - image_processing_buffer.len() - ); - } - } - productiondeck::types::DisplayCommand::SetBrightness(brightness) => { - info!("Core 1: Setting brightness to {}%", brightness); - // TODO: Implement brightness control - } - productiondeck::types::DisplayCommand::ClearAll => { - info!("Core 1: Clearing all displays"); - // TODO: Implement display clear - } - productiondeck::types::DisplayCommand::Clear(key_id) => { - info!("Core 1: Clearing display for key {}", key_id); - // TODO: Implement single key clear - } - } - } + run_multicore( + DEVICE, + MulticoreCore0Layout::Module32Matrix, + MulticoreCore1Buffer::B16384, + ) } diff --git a/src/bin/module6.rs b/src/bin/module6.rs index 55c97fc..29a4184 100644 --- a/src/bin/module6.rs +++ b/src/bin/module6.rs @@ -1,188 +1,22 @@ #![allow(unreachable_code)] -//! ProductionDeck - Stream Deck Module 6 Compatible Firmware -//! -//! This binary builds firmware specifically for Stream Deck Module 6 compatibility: -//! - 6 keys in 3x2 layout -//! - 80x80 pixel images per key (rotate 90° clockwise per spec) -//! - USB VID:PID 0x0FD9:0x00B8 -//! - Module HID protocol (Input 65B, Output 1024B, Feature 32B) +//! ProductionDeck — Stream Deck Module 6 (PID `0x00B8`), 6 keys. #![no_std] #![no_main] -use defmt::*; +use cortex_m_rt::entry; use defmt_rtt as _; -use embassy_executor::Executor; -use embassy_rp::multicore::{spawn_core1, Stack}; -use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; -use embassy_sync::channel::Channel; use panic_halt as _; -use static_cell::StaticCell; +use productiondeck::device::Device; +use productiondeck::entry::{run_multicore, MulticoreCore0Layout, MulticoreCore1Buffer}; -// Set compile-time device selection -const DEVICE: productiondeck::device::Device = productiondeck::device::Device::Module6Keys; +const DEVICE: Device = Device::Module6Keys; -// Import all modules from library -extern crate productiondeck; -use productiondeck::*; - -// Multicore setup -static mut CORE1_STACK: Stack<4096> = Stack::new(); -static EXECUTOR0: StaticCell = StaticCell::new(); -static EXECUTOR1: StaticCell = StaticCell::new(); - -// Inter-core communication channel for image processing -static IMAGE_CHANNEL: Channel = - Channel::new(); - -/// Main application entry point for Stream Deck Module 6 with multicore support -#[cortex_m_rt::entry] +#[entry] fn main() -> ! { - // Initialize hardware - let p = embassy_rp::init(Default::default()); - - // Create application supervisor for Module 6 - let supervisor = supervisor::AppSupervisor::new_for_device(DEVICE); - - // Print startup information - supervisor.print_startup_banner(); - - // Spawn core 1 for image processing and display tasks - spawn_core1( - p.CORE1, - unsafe { &mut *core::ptr::addr_of_mut!(CORE1_STACK) }, - move || { - let executor1 = EXECUTOR1.init(Executor::new()); - executor1.run(|spawner| { - unwrap!(spawner.spawn(core1_image_processing_task())); - }); - }, - ); - - // Run core 0 for USB, buttons, and supervision - let executor0 = EXECUTOR0.init(Executor::new()); - executor0.run(|spawner| { - unwrap!(spawner.spawn(core0_main_task(supervisor))); - // Also spawn the USB task directly - unwrap!(spawner.spawn(usb::usb_task_for_device( - embassy_rp::usb::Driver::new(p.USB, crate::Irqs), - embassy_rp::gpio::Output::new(p.PIN_20, embassy_rp::gpio::Level::Low), - DEVICE - ))); - // Spawn button task for Module 6 (Direct mode, 3x2 keys) - unwrap!(spawner.spawn(buttons::button_task_direct({ - let mut inputs = heapless::Vec::new(); - let _ = inputs.push(embassy_rp::gpio::Input::new( - p.PIN_4, - embassy_rp::gpio::Pull::Up, - )); - let _ = inputs.push(embassy_rp::gpio::Input::new( - p.PIN_5, - embassy_rp::gpio::Pull::Up, - )); - let _ = inputs.push(embassy_rp::gpio::Input::new( - p.PIN_6, - embassy_rp::gpio::Pull::Up, - )); - let _ = inputs.push(embassy_rp::gpio::Input::new( - p.PIN_10, - embassy_rp::gpio::Pull::Up, - )); - let _ = inputs.push(embassy_rp::gpio::Input::new( - p.PIN_11, - embassy_rp::gpio::Pull::Up, - )); - let _ = inputs.push(embassy_rp::gpio::Input::new( - p.PIN_12, - embassy_rp::gpio::Pull::Up, - )); - inputs - }))); - // Spawn status LED task - unwrap!(spawner.spawn(hardware::status_task( - embassy_rp::gpio::Output::new(p.PIN_25, embassy_rp::gpio::Level::Low), - embassy_rp::gpio::Output::new(p.PIN_21, embassy_rp::gpio::Level::Low) - ))); - }); - - // This should never be reached - loop { - cortex_m::asm::wfe(); - } -} - -/// Core 0 main task: USB, buttons, and supervision -#[embassy_executor::task] -async fn core0_main_task(mut supervisor: supervisor::AppSupervisor) { - info!("Core 0: Starting USB and button tasks"); - - // Initialize and spawn core 0 tasks (USB, buttons) - // Note: spawner is not available in this context, we'll use the existing channel system - info!("Core 0: Stream Deck Module 6 firmware initialized successfully"); - supervisor.print_init_success(); - - // Run the main supervisor loop - supervisor.run().await; -} - -/// Core 1 task: Image processing and display -#[embassy_executor::task] -async fn core1_image_processing_task() { - info!("Core 1: Starting image processing and display tasks"); - - // Initialize and spawn core 1 tasks (display, image processing) - match hardware::init_hardware_tasks_core1(DEVICE).await { - Ok(()) => { - info!("Core 1: Image processing tasks initialized successfully"); - } - Err(e) => { - error!("Core 1: Failed to spawn image processing tasks: {:?}", e); - core::panic!("Image processing initialization failed"); - } - } - - // Optimized image processing buffer - let mut image_processing_buffer = [0u8; 8192]; // 8KB buffer for image processing - - // Process display commands from core 0 - let receiver = IMAGE_CHANNEL.receiver(); - loop { - match receiver.receive().await { - productiondeck::types::DisplayCommand::DisplayImage { key_id, data } => { - info!( - "Core 1: Processing image for key {} ({} bytes)", - key_id, - data.len() - ); - - // Optimized image processing with larger buffer - if data.len() <= image_processing_buffer.len() { - // Copy data to processing buffer for faster access - let copy_len = data.len().min(image_processing_buffer.len()); - image_processing_buffer[..copy_len].copy_from_slice(&data[..copy_len]); - - // TODO: Implement actual image processing and display - // Process image from buffer for better performance - } else { - warn!( - "Core 1: Image too large for buffer ({} > {} bytes)", - data.len(), - image_processing_buffer.len() - ); - } - } - productiondeck::types::DisplayCommand::SetBrightness(brightness) => { - info!("Core 1: Setting brightness to {}%", brightness); - // TODO: Implement brightness control - } - productiondeck::types::DisplayCommand::ClearAll => { - info!("Core 1: Clearing all displays"); - // TODO: Implement display clear - } - productiondeck::types::DisplayCommand::Clear(key_id) => { - info!("Core 1: Clearing display for key {}", key_id); - // TODO: Implement single key clear - } - } - } + run_multicore( + DEVICE, + MulticoreCore0Layout::MiniOrModule6Direct, + MulticoreCore1Buffer::B8192, + ) } diff --git a/src/bin/neo.rs b/src/bin/neo.rs new file mode 100644 index 0000000..a9d2e8d --- /dev/null +++ b/src/bin/neo.rs @@ -0,0 +1,17 @@ +//! ProductionDeck — Stream Deck Neo (PID `0x009A`), Main Protocol. + +#![no_std] +#![no_main] + +use defmt_rtt as _; +use embassy_executor::Spawner; +use panic_halt as _; +use productiondeck::device::Device; +use productiondeck::entry::run_single_core_quiet; + +const DEVICE: Device = Device::Neo; + +#[embassy_executor::main] +async fn main(spawner: Spawner) { + run_single_core_quiet(spawner, DEVICE).await; +} diff --git a/src/bin/original.rs b/src/bin/original.rs index a71031d..95d81dc 100644 --- a/src/bin/original.rs +++ b/src/bin/original.rs @@ -1,53 +1,17 @@ -//! ProductionDeck - StreamDeck Original Compatible Firmware -//! -//! This binary builds firmware specifically for StreamDeck Original compatibility: -//! - 15 keys in 5x3 layout -//! - 72x72 pixel images per key -//! - USB VID:PID 0x0fd9:0x0060 -//! - V1 BMP protocol +//! ProductionDeck — Stream Deck first generation (PID `0x0060`). #![no_std] #![no_main] -use defmt::*; use defmt_rtt as _; use embassy_executor::Spawner; use panic_halt as _; +use productiondeck::device::Device; +use productiondeck::entry::run_single_core; -// Set compile-time device selection -const DEVICE: productiondeck::device::Device = productiondeck::device::Device::Original; +const DEVICE: Device = Device::Original; -// Import all modules from library -extern crate productiondeck; -use productiondeck::*; - -// USB interrupt binding -// Use Irqs from the library to avoid duplicate definitions - -/// Main application entry point for StreamDeck Original #[embassy_executor::main] async fn main(spawner: Spawner) { - // Initialize hardware - let p = embassy_rp::init(Default::default()); - - // Create application supervisor for Original - let mut supervisor = supervisor::AppSupervisor::new_for_device(DEVICE); - - // Print startup information - supervisor.print_startup_banner(); - - // Initialize and spawn all hardware tasks for Original - match hardware::init_hardware_tasks_for_device(&spawner, p, DEVICE).await { - Ok(()) => { - info!("StreamDeck Original firmware initialized successfully"); - supervisor.print_init_success(); - } - Err(e) => { - error!("Failed to spawn hardware tasks: {:?}", e); - core::panic!("Hardware initialization failed"); - } - } - - // Run the main supervisor loop - supervisor.run().await; + run_single_core(spawner, DEVICE).await; } diff --git a/src/bin/original_v2.rs b/src/bin/original_v2.rs index 2b2e22b..9512d0d 100644 --- a/src/bin/original_v2.rs +++ b/src/bin/original_v2.rs @@ -1,53 +1,17 @@ -//! ProductionDeck - StreamDeck Original V2 Compatible Firmware -//! -//! This binary builds firmware specifically for StreamDeck Original V2 compatibility: -//! - 15 keys in 5x3 layout -//! - 72x72 pixel images per key -//! - USB VID:PID 0x0fd9:0x006d -//! - V2 JPEG protocol +//! ProductionDeck — Stream Deck Classic 2019 / Original V2 (PID `0x006d`). #![no_std] #![no_main] -use defmt::*; use defmt_rtt as _; use embassy_executor::Spawner; use panic_halt as _; +use productiondeck::device::Device; +use productiondeck::entry::run_single_core; -// Set compile-time device selection -const DEVICE: productiondeck::device::Device = productiondeck::device::Device::OriginalV2; +const DEVICE: Device = Device::OriginalV2; -// Import all modules from library -extern crate productiondeck; -use productiondeck::*; - -// USB interrupt binding -// Use Irqs from the library to avoid duplicate definitions - -/// Main application entry point for StreamDeck Original V2 #[embassy_executor::main] async fn main(spawner: Spawner) { - // Initialize hardware - let p = embassy_rp::init(Default::default()); - - // Create application supervisor for Original V2 - let mut supervisor = supervisor::AppSupervisor::new_for_device(DEVICE); - - // Print startup information - supervisor.print_startup_banner(); - - // Initialize and spawn all hardware tasks for Original V2 - match hardware::init_hardware_tasks_for_device(&spawner, p, DEVICE).await { - Ok(()) => { - info!("StreamDeck Original V2 firmware initialized successfully"); - supervisor.print_init_success(); - } - Err(e) => { - error!("Failed to spawn hardware tasks: {:?}", e); - core::panic!("Hardware initialization failed"); - } - } - - // Run the main supervisor loop - supervisor.run().await; + run_single_core(spawner, DEVICE).await; } diff --git a/src/bin/plus.rs b/src/bin/plus.rs index 6df1bea..bc490e7 100644 --- a/src/bin/plus.rs +++ b/src/bin/plus.rs @@ -1,53 +1,17 @@ -//! ProductionDeck - StreamDeck Plus Compatible Firmware -//! -//! This binary builds firmware specifically for StreamDeck Plus compatibility: -//! - 8 keys in 4x2 layout -//! - 120x120 pixel images per key -//! - USB VID:PID 0x0fd9:0x0084 -//! - V2 JPEG protocol +//! ProductionDeck — Stream Deck + (PID `0x0084`). #![no_std] #![no_main] -use defmt::*; use defmt_rtt as _; use embassy_executor::Spawner; use panic_halt as _; +use productiondeck::device::Device; +use productiondeck::entry::run_single_core; -// Set compile-time device selection -const DEVICE: productiondeck::device::Device = productiondeck::device::Device::Plus; +const DEVICE: Device = Device::Plus; -// Import all modules from library -extern crate productiondeck; -use productiondeck::*; - -// USB interrupt binding -// Use Irqs from the library to avoid duplicate definitions - -/// Main application entry point for StreamDeck Plus #[embassy_executor::main] async fn main(spawner: Spawner) { - // Initialize hardware - let p = embassy_rp::init(Default::default()); - - // Create application supervisor for Plus - let mut supervisor = supervisor::AppSupervisor::new_for_device(DEVICE); - - // Print startup information - supervisor.print_startup_banner(); - - // Initialize and spawn all hardware tasks for Plus - match hardware::init_hardware_tasks_for_device(&spawner, p, DEVICE).await { - Ok(()) => { - info!("StreamDeck Plus firmware initialized successfully"); - supervisor.print_init_success(); - } - Err(e) => { - error!("Failed to spawn hardware tasks: {:?}", e); - core::panic!("Hardware initialization failed"); - } - } - - // Run the main supervisor loop - supervisor.run().await; + run_single_core(spawner, DEVICE).await; } diff --git a/src/bin/plus_xl.rs b/src/bin/plus_xl.rs new file mode 100644 index 0000000..317d52d --- /dev/null +++ b/src/bin/plus_xl.rs @@ -0,0 +1,17 @@ +//! ProductionDeck — Stream Deck + XL (PID `0x0084`), 9×4 layout. + +#![no_std] +#![no_main] + +use defmt_rtt as _; +use embassy_executor::Spawner; +use panic_halt as _; +use productiondeck::device::Device; +use productiondeck::entry::run_single_core_quiet; + +const DEVICE: Device = Device::PlusXl; + +#[embassy_executor::main] +async fn main(spawner: Spawner) { + run_single_core_quiet(spawner, DEVICE).await; +} diff --git a/src/bin/revised_mini.rs b/src/bin/revised_mini.rs index bcadcff..518a55e 100644 --- a/src/bin/revised_mini.rs +++ b/src/bin/revised_mini.rs @@ -1,52 +1,17 @@ -//! ProductionDeck - StreamDeck Revised Mini Compatible Firmware -//! -//! This binary builds firmware specifically for StreamDeck Revised Mini compatibility: -//! - 6 keys in 3x2 layout -//! - 80x80 pixel images per key -//! - USB VID:PID 0x0fd9:0x0080 -//! - V1 BMP protocol +//! ProductionDeck — Stream Deck Mini 2022 (PID `0x0090`). #![no_std] #![no_main] -use defmt::*; use defmt_rtt as _; use embassy_executor::Spawner; use panic_halt as _; +use productiondeck::device::Device; +use productiondeck::entry::run_single_core; -// Set compile-time device selection -const DEVICE: productiondeck::device::Device = productiondeck::device::Device::RevisedMini; +const DEVICE: Device = Device::RevisedMini; -// Import all modules from library -extern crate productiondeck; -use productiondeck::*; - -// Use Irqs from the library to avoid duplicate definitions - -/// Main application entry point for StreamDeck Revised Mini #[embassy_executor::main] async fn main(spawner: Spawner) { - // Initialize hardware - let p = embassy_rp::init(Default::default()); - - // Create application supervisor for Revised Mini - let mut supervisor = supervisor::AppSupervisor::new_for_device(DEVICE); - - // Print startup information - supervisor.print_startup_banner(); - - // Initialize and spawn all hardware tasks for Revised Mini - match hardware::init_hardware_tasks_for_device(&spawner, p, DEVICE).await { - Ok(()) => { - info!("StreamDeck Revised Mini firmware initialized successfully"); - supervisor.print_init_success(); - } - Err(e) => { - error!("Failed to spawn hardware tasks: {:?}", e); - core::panic!("Hardware initialization failed"); - } - } - - // Run the main supervisor loop - supervisor.run().await; + run_single_core(spawner, DEVICE).await; } diff --git a/src/bin/xl.rs b/src/bin/xl.rs index 0b225ce..db53d8a 100644 --- a/src/bin/xl.rs +++ b/src/bin/xl.rs @@ -1,53 +1,17 @@ -//! ProductionDeck - StreamDeck XL Compatible Firmware -//! -//! This binary builds firmware specifically for StreamDeck XL compatibility: -//! - 32 keys in 8x4 layout -//! - 96x96 pixel images per key -//! - USB VID:PID 0x0fd9:0x006c -//! - V2 JPEG protocol +//! ProductionDeck — Stream Deck XL (PID `0x006c`). #![no_std] #![no_main] -use defmt::*; use defmt_rtt as _; use embassy_executor::Spawner; use panic_halt as _; +use productiondeck::device::Device; +use productiondeck::entry::run_single_core; -// Set compile-time device selection -const DEVICE: productiondeck::device::Device = productiondeck::device::Device::Xl; +const DEVICE: Device = Device::Xl; -// Import all modules from library -extern crate productiondeck; -use productiondeck::*; - -// USB interrupt binding -// Use Irqs from the library to avoid duplicate definitions - -/// Main application entry point for StreamDeck XL #[embassy_executor::main] async fn main(spawner: Spawner) { - // Initialize hardware - let p = embassy_rp::init(Default::default()); - - // Create application supervisor for XL - let mut supervisor = supervisor::AppSupervisor::new_for_device(DEVICE); - - // Print startup information - supervisor.print_startup_banner(); - - // Initialize and spawn all hardware tasks for XL - match hardware::init_hardware_tasks_for_device(&spawner, p, DEVICE).await { - Ok(()) => { - info!("StreamDeck XL firmware initialized successfully"); - supervisor.print_init_success(); - } - Err(e) => { - error!("Failed to spawn hardware tasks: {:?}", e); - core::panic!("Hardware initialization failed"); - } - } - - // Run the main supervisor loop - supervisor.run().await; + run_single_core(spawner, DEVICE).await; } diff --git a/src/buttons.rs b/src/buttons.rs index e7a825e..305caa7 100644 --- a/src/buttons.rs +++ b/src/buttons.rs @@ -10,14 +10,14 @@ use embassy_time::{Duration, Instant, Timer}; use crate::channels::BUTTON_CHANNEL; use crate::config::*; -use crate::types::ButtonState; +use crate::types::{ButtonState, MAX_BUTTON_SLOTS}; // =================================================================== // Button Debouncing State // =================================================================== struct ButtonDebouncer { - buttons: [ButtonDebounceState; 32], // Max keys for any device + buttons: [ButtonDebounceState; MAX_BUTTON_SLOTS], } #[derive(Clone, Copy)] @@ -34,7 +34,7 @@ impl ButtonDebouncer { current: false, raw: false, last_change: Instant::now(), - }; 32], // Max keys for any device + }; MAX_BUTTON_SLOTS], } } @@ -75,8 +75,8 @@ impl ButtonMatrix { Self { rows, cols } } - async fn scan(&mut self) -> [bool; 32] { - let mut button_states = [false; 32]; // Max keys for any device + async fn scan(&mut self) -> [bool; MAX_BUTTON_SLOTS] { + let mut button_states = [false; MAX_BUTTON_SLOTS]; for row_idx in 0..ROWS { // Pull current row low @@ -106,7 +106,7 @@ async fn run_matrix_task( ) { let mut debouncer = ButtonDebouncer::new(); let mut _last_button_state = ButtonState { - buttons: [false; 32], + buttons: [false; MAX_BUTTON_SLOTS], changed: false, active_count: active_keys, }; @@ -151,6 +151,21 @@ async fn run_matrix_task( // Button Task Implementation // =================================================================== +#[embassy_executor::task] +#[allow(clippy::too_many_arguments)] +pub async fn button_task_matrix_4x2( + row0: Output<'static>, + row1: Output<'static>, + col0: Input<'static>, + col1: Input<'static>, + col2: Input<'static>, + col3: Input<'static>, +) { + info!("Button task (matrix 4x2) started"); + let matrix = ButtonMatrix::<2, 4>::new([row0, row1], [col0, col1, col2, col3]); + run_matrix_task::<2, 4>(matrix, 8).await; +} + #[embassy_executor::task] pub async fn button_task_matrix_3x2( row0: Output<'static>, @@ -181,6 +196,31 @@ pub async fn button_task_matrix_5x3( run_matrix_task::<3, 5>(matrix, 15).await; } +#[embassy_executor::task] +#[allow(clippy::too_many_arguments)] +pub async fn button_task_matrix_9x4( + row0: Output<'static>, + row1: Output<'static>, + row2: Output<'static>, + row3: Output<'static>, + col0: Input<'static>, + col1: Input<'static>, + col2: Input<'static>, + col3: Input<'static>, + col4: Input<'static>, + col5: Input<'static>, + col6: Input<'static>, + col7: Input<'static>, + col8: Input<'static>, +) { + info!("Button task (matrix 9x4) started"); + let matrix = ButtonMatrix::<4, 9>::new( + [row0, row1, row2, row3], + [col0, col1, col2, col3, col4, col5, col6, col7, col8], + ); + run_matrix_task::<4, 9>(matrix, 36).await; +} + #[embassy_executor::task] #[allow(clippy::too_many_arguments)] pub async fn button_task_matrix_8x4( @@ -215,7 +255,7 @@ pub async fn button_task_direct(inputs: heapless::Vec, 32>) { let mut debouncer = ButtonDebouncer::new(); let mut _last_button_state = ButtonState { - buttons: [false; 32], + buttons: [false; MAX_BUTTON_SLOTS], changed: false, active_count: inputs.len(), }; @@ -225,7 +265,7 @@ pub async fn button_task_direct(inputs: heapless::Vec, 32>) { loop { // Read all inputs directly (active-low with pull-ups) - let mut raw_states = [false; 32]; + let mut raw_states = [false; MAX_BUTTON_SLOTS]; for (i, pin) in inputs.iter().enumerate() { raw_states[i] = !pin.is_high(); } diff --git a/src/config.rs b/src/config.rs index 7f3b0bc..07bfc58 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,40 +1,24 @@ //! Hardware configuration for ProductionDeck //! RP2040-based StreamDeck compatible device with multi-device support -use crate::device::{Device, DeviceConfig}; -use core::sync::atomic::{AtomicI32, AtomicU16, AtomicU8, Ordering}; +use crate::device::{Device, DeviceConfig, RUNTIME_DEVICE_TAG_UNINIT}; +use core::sync::atomic::{AtomicI32, AtomicU8, Ordering}; // =================================================================== // Device Selection Configuration // =================================================================== -/// Current device PID (can be changed at runtime via device selection) -/// Default to StreamDeck Mini (0x0063) for backward compatibility -static CURRENT_DEVICE_PID: AtomicU16 = AtomicU16::new(0x0063); - -/// Set the current device type by PID -pub fn set_device_pid(pid: u16) -> Result<(), &'static str> { - if Device::from_pid(pid).is_some() { - CURRENT_DEVICE_PID.store(pid, Ordering::Relaxed); - Ok(()) - } else { - Err("Unsupported device PID") - } -} +static RUNTIME_DEVICE_TAG: AtomicU8 = AtomicU8::new(RUNTIME_DEVICE_TAG_UNINIT); -/// Get the current device PID -pub fn get_device_pid() -> u16 { - CURRENT_DEVICE_PID.load(Ordering::Relaxed) +/// Store the firmware build device (call once from each binary `main` before other tasks). +pub fn init_runtime_device(device: Device) { + RUNTIME_DEVICE_TAG.store(device as u8, Ordering::Relaxed); } -/// Get the current device configuration +/// Active device for [`streamdeck_keys`], USB strings, and display sizing. pub fn get_current_device() -> Device { - let pid = get_device_pid(); - Device::from_pid(pid).unwrap_or_else(|| { - // Fallback to Mini if current PID is invalid - CURRENT_DEVICE_PID.store(0x0063, Ordering::Relaxed); - Device::Mini - }) + let tag = RUNTIME_DEVICE_TAG.load(Ordering::Relaxed); + Device::from_runtime_tag(tag).expect("init_runtime_device must run before get_current_device") } // =================================================================== @@ -70,22 +54,6 @@ pub fn button_input_mode() -> ButtonInputMode { // USB Configuration - Dynamic based on current device // =================================================================== -pub fn usb_vid() -> u16 { - get_current_device().usb_config().vid -} - -pub fn usb_pid() -> u16 { - get_current_device().usb_config().pid -} - -pub fn usb_manufacturer() -> &'static str { - get_current_device().usb_config().manufacturer -} - -pub fn usb_product() -> &'static str { - get_current_device().usb_config().product_name -} - /// Serial number (static for all devices) pub const USB_SERIAL: &str = "PRODUCTIONDK"; // 12 chars @@ -113,64 +81,12 @@ pub fn key_image_size() -> usize { display.image_width // Assume square images } -pub fn key_image_bytes() -> usize { - let display = get_current_device().display_config(); - display.image_width * display.image_height * 3 // RGB -} - -// =================================================================== -// USB HID Configuration - Dynamic based on current device -// =================================================================== - -pub fn hid_report_size_input() -> usize { - get_current_device().input_report_size() -} - -pub fn hid_report_size_feature() -> usize { - get_current_device().feature_report_size() -} - -pub fn hid_report_size_output() -> usize { - get_current_device().output_report_size() -} - // =================================================================== // GPIO Pin Assignments - Raspberry Pi Pico // =================================================================== - -// Button Matrix - Dynamic sizing based on device -pub fn btn_row_pins() -> &'static [u8] { - let rows = streamdeck_rows(); - match rows { - 2 => &[2, 3], // Mini: 2 rows - 3 => &[2, 3, 7], // Original: 3 rows - 4 => &[2, 3, 7, 9], // XL: 4 rows - _ => &[2, 3], // Fallback to 2 rows - } -} - -pub fn btn_col_pins() -> &'static [u8] { - let cols = streamdeck_cols(); - match cols { - 3 => &[4, 5, 6], // Mini: 3 cols - 4 => &[4, 5, 6, 10], // Plus: 4 cols - 5 => &[4, 5, 6, 10, 11], // Original: 5 cols - 8 => &[4, 5, 6, 10, 11, 12, 13, 16], // XL: 8 cols - _ => &[4, 5, 6], // Fallback to 3 cols - } -} - -/// Direct input pin assignments (one GPIO per button) -/// For Mini (6 keys), use six dedicated pins. -pub fn btn_direct_pins() -> &'static [u8] { - let keys = streamdeck_keys(); - match keys { - // StreamDeck Mini and Revised Mini (6 keys) - 6 => &[4, 5, 6, 10, 11, 12], - // Fallback: re-use column pins (may not cover all keys) - _ => btn_col_pins(), - } -} +// +// Button matrix row/column BCM numbers live in [`crate::hardware::HardwareConfig::for_device`] +// together with [`crate::hardware::create_all_pins_for_device`] (single source of truth). // SPI Display Interface pub const SPI_MOSI_PIN: u8 = 19; // Data to display @@ -288,23 +204,3 @@ pub const ST7735_COLOR_MODE_16BIT: u8 = 0x05; // RGB565 format pub const RGB565_RED_MASK: u16 = 0xF8; pub const RGB565_GREEN_MASK: u16 = 0xFC; pub const RGB565_BLUE_SHIFT: u8 = 3; - -// =================================================================== -// Backward Compatibility Constants -// =================================================================== - -/// Backward compatibility - use dynamic functions instead -#[deprecated(note = "Use streamdeck_keys() function instead")] -pub const STREAMDECK_KEYS: usize = 6; - -#[deprecated(note = "Use streamdeck_cols() function instead")] -pub const STREAMDECK_COLS: usize = 3; - -#[deprecated(note = "Use streamdeck_rows() function instead")] -pub const STREAMDECK_ROWS: usize = 2; - -#[deprecated(note = "Use key_image_size() function instead")] -pub const KEY_IMAGE_SIZE: usize = 80; - -#[deprecated(note = "Use key_image_bytes() function instead")] -pub const KEY_IMAGE_BYTES: usize = 80 * 80 * 3; diff --git a/src/device/mini.rs b/src/device/mini.rs deleted file mode 100644 index 9194feb..0000000 --- a/src/device/mini.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! StreamDeck Mini device configurations -//! -//! Supports both the original Mini (PID 0x0063) and Revised Mini (PID 0x0090) - -use super::{ButtonLayout, DeviceConfig, DisplayConfig, ImageFormat, ProtocolVersion, UsbConfig}; - -/// StreamDeck Mini configuration (PID: 0x0063) -pub struct MiniConfig; - -impl DeviceConfig for MiniConfig { - fn device_name(&self) -> &'static str { - "StreamDeck Mini" - } - - fn button_layout(&self) -> ButtonLayout { - ButtonLayout::new(3, 2, true) // 3x2 layout, left-to-right - } - - fn display_config(&self) -> DisplayConfig { - DisplayConfig { - image_width: 80, - image_height: 80, - format: ImageFormat::Bmp, - needs_rotation: true, // Mini needs 270° rotation - flip_horizontal: false, - flip_vertical: false, - } - } - - fn usb_config(&self) -> UsbConfig { - UsbConfig { - vid: 0x0fd9, - pid: 0x0063, - product_name: "Stream Deck Mini", - manufacturer: "Elgato Systems", - protocol: ProtocolVersion::V1, - } - } -} - -/// StreamDeck Revised Mini configuration (PID: 0x0090) -pub struct RevisedMiniConfig; - -impl DeviceConfig for RevisedMiniConfig { - fn device_name(&self) -> &'static str { - "StreamDeck Revised Mini" - } - - fn button_layout(&self) -> ButtonLayout { - ButtonLayout::new(3, 2, true) // 3x2 layout, left-to-right - } - - fn display_config(&self) -> DisplayConfig { - DisplayConfig { - image_width: 80, - image_height: 80, - format: ImageFormat::Bmp, - needs_rotation: true, // Mini needs 270° rotation - flip_horizontal: false, - flip_vertical: false, - } - } - - fn usb_config(&self) -> UsbConfig { - UsbConfig { - vid: 0x0fd9, - pid: 0x0090, - product_name: "Stream Deck Mini", - manufacturer: "Elgato Systems", - protocol: ProtocolVersion::V1, - } - } -} diff --git a/src/device/mod.rs b/src/device/mod.rs index b0920d4..db52da0 100644 --- a/src/device/mod.rs +++ b/src/device/mod.rs @@ -2,12 +2,13 @@ //! //! This module provides a unified interface for different StreamDeck models, //! abstracting away device-specific configurations, protocols, and capabilities. +//! +//! Protocol families (Elgato HID API): +//! - **Legacy / Mini family**: Mini, Mini 2022, Mini Discord, 6-key Module — distinct report layout. +//! - **Main / Expanded family**: Classic, XL, Neo, Plus, Plus XL, 15/32-key Modules — see General Reference. -pub mod mini; -pub mod original; -pub mod original_v2; -pub mod plus; -pub mod xl; +pub mod neo; +pub mod plus_xl; /// Image format supported by StreamDeck devices #[derive(Debug, Clone, Copy, PartialEq, defmt::Format)] @@ -21,14 +22,12 @@ pub enum ImageFormat { /// Protocol version used by StreamDeck devices #[derive(Debug, Clone, Copy, PartialEq, defmt::Format)] pub enum ProtocolVersion { - /// V1 protocol (Original, Mini, Revised Mini) + /// Legacy Mini-family protocol (BMP, distinct feature IDs) V1, - /// V2 protocol (Original V2, XL, MK2, Plus) + /// Main / Expanded family protocol (JPEG chunks, feature report ID 0x03, …) V2, - /// Module HID protocol(6Keys) + /// 6-key Module uses Mini-family legacy command set per Elgato Mini module page Module6Keys, - /// Module HID protocol (15/32 Keys) - Module15_32Keys, } /// Button layout configuration @@ -36,9 +35,9 @@ pub enum ProtocolVersion { pub struct ButtonLayout { /// Number of button columns pub cols: usize, - /// Number of button rows + /// Number of button rows pub rows: usize, - /// Total number of buttons (cols * rows) + /// Total number of physical keys (cols * rows) pub total_keys: usize, /// Button mapping order (true = left-to-right, false = right-to-left) pub left_to_right: bool, @@ -64,7 +63,7 @@ pub struct DisplayConfig { pub image_height: usize, /// Image format (BMP or JPEG) pub format: ImageFormat, - /// Whether image needs rotation (Mini devices need 270° rotation) + /// Whether image needs rotation (Mini: host rotates 90° CW; device applies inverse) pub needs_rotation: bool, /// Whether image needs horizontal flip pub flip_horizontal: bool, @@ -89,168 +88,250 @@ pub struct UsbConfig { /// Complete device configuration trait pub trait DeviceConfig { - /// Get device name for identification fn device_name(&self) -> &'static str; - - /// Get button layout configuration fn button_layout(&self) -> ButtonLayout; - - /// Get display configuration fn display_config(&self) -> DisplayConfig; - - /// Get USB configuration fn usb_config(&self) -> UsbConfig; - /// Get maximum image data size in bytes + /// Logical keys reported in Main Protocol input (includes Neo sensor slots) + fn protocol_input_key_count(&self) -> usize { + self.button_layout().total_keys + } + fn max_image_size(&self) -> usize { let display = self.display_config(); match display.format { - ImageFormat::Bmp => { - // BMP: header (54 bytes) + RGB data (width * height * 3) - 54 + (display.image_width * display.image_height * 3) - } - ImageFormat::Jpeg => { - // JPEG: Variable size, use conservative estimate - display.image_width * display.image_height / 2 - } + ImageFormat::Bmp => 54 + (display.image_width * display.image_height * 3), + ImageFormat::Jpeg => display.image_width * display.image_height / 2, } } - /// Get HID report descriptor size fn hid_descriptor_size(&self) -> usize { - 173 // Standard StreamDeck HID descriptor size + 173 } - /// Get input report size (button states) fn input_report_size(&self) -> usize { match self.usb_config().protocol { - ProtocolVersion::V1 => self.button_layout().total_keys + 1, // +1 for report ID - ProtocolVersion::V2 => self.button_layout().total_keys + 4, // +4 for V2 header + ProtocolVersion::V1 => self.button_layout().total_keys + 1, + ProtocolVersion::V2 => self.protocol_input_key_count() + 4, // RID + cmd + len16 + payload ProtocolVersion::Module6Keys => 65, - ProtocolVersion::Module15_32Keys => 512, } } - /// Get feature report size fn feature_report_size(&self) -> usize { - 32 // Standard feature report size + 32 } - /// Get output report size (image data) fn output_report_size(&self) -> usize { - 1024 // Standard 1KB output report size + 1024 } } /// Enum-based device configuration for no_std environment -#[derive(Debug, Clone, Copy)] +/// +/// Discriminants are stored by [`crate::config::init_runtime_device`]; do not reorder without +/// updating `from_runtime_tag`. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Device { - Mini, - RevisedMini, - Original, - OriginalV2, - Xl, - Plus, - Module6Keys, - Module15Keys, - Module32Keys, + Mini = 0, + /// Mini 2022 (Elgato PID 0x0090) + RevisedMini = 1, + /// Mini Discord (0x00B3) + MiniDiscord = 2, + /// First-gen Stream Deck (0x0060) + Original = 3, + /// Stream Deck 2019 (0x006D) + OriginalV2 = 4, + /// Stream Deck Mk.2 (0x0080) + Mk2 = 5, + /// Mk.2 scissor keys (0x00A5) + Mk2ScissorKeys = 6, + Xl = 7, + /// Stream Deck XL 2022 (0x008F) + Xl2022 = 8, + /// Stream Deck + (0x0084) + Plus = 9, + /// Stream Deck + XL — same USB PID as + per Elgato HID summary table; layout differs (9×4). + PlusXl = 10, + Neo = 11, + Module6Keys = 12, + Module15Keys = 13, + Module32Keys = 14, } +/// Tag value before [`crate::config::init_runtime_device`] runs. +pub const RUNTIME_DEVICE_TAG_UNINIT: u8 = 0xFF; + impl Device { - /// Get device by USB PID - pub fn from_pid(pid: u16) -> Option { - match pid { - 0x0063 => Some(Device::Mini), - 0x0080 => Some(Device::RevisedMini), // StreamDeck Revised Mini - 0x0060 => Some(Device::Original), - 0x006d => Some(Device::OriginalV2), - 0x006c => Some(Device::Xl), - 0x0084 => Some(Device::Plus), - 0x00B8 => Some(Device::Module6Keys), - 0x00B9 => Some(Device::Module15Keys), - 0x00BA => Some(Device::Module32Keys), + /// Decode a tag written by [`crate::config::init_runtime_device`]. + pub fn from_runtime_tag(tag: u8) -> Option { + if tag == RUNTIME_DEVICE_TAG_UNINIT { + return None; + } + match tag { + 0 => Some(Device::Mini), + 1 => Some(Device::RevisedMini), + 2 => Some(Device::MiniDiscord), + 3 => Some(Device::Original), + 4 => Some(Device::OriginalV2), + 5 => Some(Device::Mk2), + 6 => Some(Device::Mk2ScissorKeys), + 7 => Some(Device::Xl), + 8 => Some(Device::Xl2022), + 9 => Some(Device::Plus), + 10 => Some(Device::PlusXl), + 11 => Some(Device::Neo), + 12 => Some(Device::Module6Keys), + 13 => Some(Device::Module15Keys), + 14 => Some(Device::Module32Keys), _ => None, } } - /// Get all supported device PIDs - pub fn supported_pids() -> &'static [u16] { - &[ - 0x0060, 0x0063, 0x0080, 0x006d, 0x006c, 0x0084, 0x00B8, 0x00B9, 0x00BA, - ] - } - - /// Get PID for this device pub fn pid(&self) -> u16 { match self { Device::Mini => 0x0063, - Device::RevisedMini => 0x0080, + Device::RevisedMini => 0x0090, + Device::MiniDiscord => 0x00B3, Device::Original => 0x0060, Device::OriginalV2 => 0x006d, + Device::Mk2 => 0x0080, + Device::Mk2ScissorKeys => 0x00A5, Device::Xl => 0x006c, - Device::Plus => 0x0084, + Device::Xl2022 => 0x008F, + Device::Plus | Device::PlusXl => 0x0084, + Device::Neo => 0x009A, Device::Module6Keys => 0x00B8, Device::Module15Keys => 0x00B9, Device::Module32Keys => 0x00BA, } } + + /// Main Protocol GET Unit Information (feature report 0x08) payload bytes [1..=16] after report ID. + pub fn unit_information_tail(&self) -> [u8; 16] { + let (rows, cols, kw, kh, lcd_w, lcd_h) = match self { + Device::OriginalV2 | Device::Mk2 | Device::Mk2ScissorKeys | Device::Module15Keys => { + (3u8, 5u8, 72u16, 72u16, 480u16, 272u16) + } + Device::Xl | Device::Xl2022 | Device::Module32Keys => { + (4u8, 8u8, 96u16, 96u16, 1024u16, 600u16) + } + Device::Plus => (2u8, 4u8, 120u16, 120u16, 800u16, 480u16), + Device::PlusXl => (4u8, 9u8, 112u16, 112u16, 1280u16, 800u16), + Device::Neo => (2u8, 4u8, 96u16, 96u16, 480u16, 320u16), + Device::Mini | Device::RevisedMini | Device::MiniDiscord | Device::Module6Keys => { + (2u8, 3u8, 80u16, 80u16, 320u16, 240u16) + } + Device::Original => (3u8, 5u8, 72u16, 72u16, 480u16, 272u16), + }; + let mut b = [0u8; 16]; + b[0] = rows; + b[1] = cols; + let kw_e = kw.to_le_bytes(); + let kh_e = kh.to_le_bytes(); + let lw_e = lcd_w.to_le_bytes(); + let lh_e = lcd_h.to_le_bytes(); + b[2] = kw_e[0]; + b[3] = kw_e[1]; + b[4] = kh_e[0]; + b[5] = kh_e[1]; + b[6] = lw_e[0]; + b[7] = lw_e[1]; + b[8] = lh_e[0]; + b[9] = lh_e[1]; + b[10] = 24; + b[11] = 0x00; + b[12] = 0; + b[13] = 0; + b[14] = 0; + b[15] = 0; + b + } + + pub fn supports_background_feature(&self) -> bool { + matches!( + self, + Device::OriginalV2 + | Device::Mk2 + | Device::Mk2ScissorKeys + | Device::Xl + | Device::Xl2022 + | Device::Module15Keys + | Device::Module32Keys + ) + } + + pub fn supports_window_image_commands(&self) -> bool { + matches!(self, Device::Neo | Device::Plus | Device::PlusXl) + } } impl DeviceConfig for Device { fn device_name(&self) -> &'static str { match self { Device::Mini => "StreamDeck Mini", - Device::RevisedMini => "StreamDeck Mini (Revised)", + Device::RevisedMini => "StreamDeck Mini 2022", + Device::MiniDiscord => "StreamDeck Mini Discord", Device::Original => "StreamDeck Original", - Device::OriginalV2 => "StreamDeck Original V2", + Device::OriginalV2 => "StreamDeck Classic (2019)", + Device::Mk2 => "StreamDeck Mk.2", + Device::Mk2ScissorKeys => "StreamDeck Mk.2 (Scissor)", Device::Xl => "StreamDeck XL", - Device::Plus => "StreamDeck Plus", + Device::Xl2022 => "StreamDeck XL 2022", + Device::Plus => "StreamDeck +", + Device::PlusXl => "StreamDeck + XL", + Device::Neo => "StreamDeck Neo", Device::Module6Keys => "StreamDeck Module 6 Keys", Device::Module15Keys => "StreamDeck Module 15 Keys", Device::Module32Keys => "StreamDeck Module 32 Keys", } } + fn protocol_input_key_count(&self) -> usize { + match self { + Device::Neo => 10, + _ => self.button_layout().total_keys, + } + } + fn button_layout(&self) -> ButtonLayout { match self { - Device::Mini | Device::RevisedMini | Device::Module6Keys => { + Device::Mini | Device::RevisedMini | Device::MiniDiscord | Device::Module6Keys => { ButtonLayout::new(3, 2, true) } - Device::Module15Keys => ButtonLayout::new(5, 3, true), - Device::Module32Keys => ButtonLayout::new(8, 4, true), - Device::Original => ButtonLayout::new(5, 3, false), // right-to-left - Device::OriginalV2 => ButtonLayout::new(5, 3, true), - Device::Xl => ButtonLayout::new(8, 4, true), - Device::Plus => ButtonLayout::new(4, 2, true), + Device::Module15Keys | Device::OriginalV2 | Device::Mk2 | Device::Mk2ScissorKeys => { + ButtonLayout::new(5, 3, true) + } + Device::Original => ButtonLayout::new(5, 3, false), + Device::Xl | Device::Xl2022 | Device::Module32Keys => ButtonLayout::new(8, 4, true), + Device::Plus | Device::Neo => ButtonLayout::new(4, 2, true), + Device::PlusXl => ButtonLayout::new(9, 4, true), } } fn display_config(&self) -> DisplayConfig { match self { - Device::Mini | Device::RevisedMini | Device::Module6Keys => DisplayConfig { - image_width: 80, - image_height: 80, - format: ImageFormat::Bmp, - needs_rotation: true, - flip_horizontal: false, - flip_vertical: false, - }, - Device::Module15Keys => DisplayConfig { - image_width: 72, - image_height: 72, - format: ImageFormat::Jpeg, - needs_rotation: true, - flip_horizontal: false, - flip_vertical: false, - }, - Device::Module32Keys => DisplayConfig { - image_width: 96, - image_height: 96, - format: ImageFormat::Jpeg, - needs_rotation: true, - flip_horizontal: false, - flip_vertical: false, - }, + Device::Mini | Device::RevisedMini | Device::MiniDiscord | Device::Module6Keys => { + DisplayConfig { + image_width: 80, + image_height: 80, + format: ImageFormat::Bmp, + needs_rotation: true, + flip_horizontal: false, + flip_vertical: false, + } + } + Device::Module15Keys | Device::OriginalV2 | Device::Mk2 | Device::Mk2ScissorKeys => { + DisplayConfig { + image_width: 72, + image_height: 72, + format: ImageFormat::Jpeg, + needs_rotation: false, + flip_horizontal: true, + flip_vertical: true, + } + } Device::Original => DisplayConfig { image_width: 72, image_height: 72, @@ -259,15 +340,15 @@ impl DeviceConfig for Device { flip_horizontal: true, flip_vertical: false, }, - Device::OriginalV2 => DisplayConfig { - image_width: 72, - image_height: 72, + Device::Xl | Device::Xl2022 | Device::Module32Keys => DisplayConfig { + image_width: 96, + image_height: 96, format: ImageFormat::Jpeg, needs_rotation: false, flip_horizontal: true, flip_vertical: true, }, - Device::Xl => DisplayConfig { + Device::Neo => DisplayConfig { image_width: 96, image_height: 96, format: ImageFormat::Jpeg, @@ -283,6 +364,14 @@ impl DeviceConfig for Device { flip_horizontal: false, flip_vertical: false, }, + Device::PlusXl => DisplayConfig { + image_width: 112, + image_height: 112, + format: ImageFormat::Jpeg, + needs_rotation: false, + flip_horizontal: true, + flip_vertical: true, + }, } } @@ -297,7 +386,14 @@ impl DeviceConfig for Device { }, Device::RevisedMini => UsbConfig { vid: 0x0fd9, - pid: 0x0080, + pid: 0x0090, + product_name: "Stream Deck Mini", + manufacturer: "Elgato Systems", + protocol: ProtocolVersion::V1, + }, + Device::MiniDiscord => UsbConfig { + vid: 0x0fd9, + pid: 0x00B3, product_name: "Stream Deck Mini", manufacturer: "Elgato Systems", protocol: ProtocolVersion::V1, @@ -316,6 +412,20 @@ impl DeviceConfig for Device { manufacturer: "Elgato Systems", protocol: ProtocolVersion::V2, }, + Device::Mk2 => UsbConfig { + vid: 0x0fd9, + pid: 0x0080, + product_name: "Stream Deck", + manufacturer: "Elgato Systems", + protocol: ProtocolVersion::V2, + }, + Device::Mk2ScissorKeys => UsbConfig { + vid: 0x0fd9, + pid: 0x00A5, + product_name: "Stream Deck", + manufacturer: "Elgato Systems", + protocol: ProtocolVersion::V2, + }, Device::Xl => UsbConfig { vid: 0x0fd9, pid: 0x006c, @@ -323,10 +433,31 @@ impl DeviceConfig for Device { manufacturer: "Elgato Systems", protocol: ProtocolVersion::V2, }, + Device::Xl2022 => UsbConfig { + vid: 0x0fd9, + pid: 0x008F, + product_name: "Stream Deck XL", + manufacturer: "Elgato Systems", + protocol: ProtocolVersion::V2, + }, Device::Plus => UsbConfig { vid: 0x0fd9, pid: 0x0084, - product_name: "Stream Deck Plus", + product_name: "Stream Deck +", + manufacturer: "Elgato Systems", + protocol: ProtocolVersion::V2, + }, + Device::PlusXl => UsbConfig { + vid: 0x0fd9, + pid: 0x0084, + product_name: "Stream Deck + XL", + manufacturer: "Elgato Systems", + protocol: ProtocolVersion::V2, + }, + Device::Neo => UsbConfig { + vid: 0x0fd9, + pid: 0x009A, + product_name: "Stream Deck Neo", manufacturer: "Elgato Systems", protocol: ProtocolVersion::V2, }, @@ -342,14 +473,14 @@ impl DeviceConfig for Device { pid: 0x00B9, product_name: "Stream Deck Module 15 Keys", manufacturer: "Elgato Systems", - protocol: ProtocolVersion::Module15_32Keys, + protocol: ProtocolVersion::V2, }, Device::Module32Keys => UsbConfig { vid: 0x0fd9, pid: 0x00BA, product_name: "Stream Deck Module 32 Keys", manufacturer: "Elgato Systems", - protocol: ProtocolVersion::Module15_32Keys, + protocol: ProtocolVersion::V2, }, } } diff --git a/src/device/neo.rs b/src/device/neo.rs new file mode 100644 index 0000000..c3dc592 --- /dev/null +++ b/src/device/neo.rs @@ -0,0 +1,2 @@ +//! Stream Deck Neo (PID `0x009A`) — Main Protocol device with 4×2 keys, window strip, +//! and two capacitive sensors appended to the HID button payload (see Elgato Neo HID page). diff --git a/src/device/original.rs b/src/device/original.rs deleted file mode 100644 index 1c665df..0000000 --- a/src/device/original.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! StreamDeck Original device configuration -//! -//! The original StreamDeck with 15 keys (PID: 0x0060) - -use super::{ButtonLayout, DeviceConfig, DisplayConfig, ImageFormat, ProtocolVersion, UsbConfig}; - -/// StreamDeck Original configuration (PID: 0x0060) -pub struct OriginalConfig; - -impl DeviceConfig for OriginalConfig { - fn device_name(&self) -> &'static str { - "StreamDeck Original" - } - - fn button_layout(&self) -> ButtonLayout { - ButtonLayout::new(5, 3, false) // 5x3 layout, right-to-left mapping - } - - fn display_config(&self) -> DisplayConfig { - DisplayConfig { - image_width: 72, - image_height: 72, - format: ImageFormat::Bmp, - needs_rotation: false, - flip_horizontal: true, // Original needs horizontal flip - flip_vertical: false, - } - } - - fn usb_config(&self) -> UsbConfig { - UsbConfig { - vid: 0x0fd9, - pid: 0x0060, - product_name: "Stream Deck", - manufacturer: "Elgato Systems", - protocol: ProtocolVersion::V1, - } - } -} diff --git a/src/device/original_v2.rs b/src/device/original_v2.rs deleted file mode 100644 index 40d8bc4..0000000 --- a/src/device/original_v2.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! StreamDeck Original V2 device configuration -//! -//! The second generation original StreamDeck with 15 keys and JPEG support (PID: 0x006d) - -use super::{ButtonLayout, DeviceConfig, DisplayConfig, ImageFormat, ProtocolVersion, UsbConfig}; - -/// StreamDeck Original V2 configuration (PID: 0x006d) -pub struct OriginalV2Config; - -impl DeviceConfig for OriginalV2Config { - fn device_name(&self) -> &'static str { - "StreamDeck Original V2" - } - - fn button_layout(&self) -> ButtonLayout { - ButtonLayout::new(5, 3, true) // 5x3 layout, left-to-right - } - - fn display_config(&self) -> DisplayConfig { - DisplayConfig { - image_width: 72, - image_height: 72, - format: ImageFormat::Jpeg, - needs_rotation: false, - flip_horizontal: true, // V2 needs both horizontal and vertical flip - flip_vertical: true, - } - } - - fn usb_config(&self) -> UsbConfig { - UsbConfig { - vid: 0x0fd9, - pid: 0x006d, - product_name: "Stream Deck", - manufacturer: "Elgato Systems", - protocol: ProtocolVersion::V2, - } - } -} diff --git a/src/device/plus.rs b/src/device/plus.rs deleted file mode 100644 index a08b3c0..0000000 --- a/src/device/plus.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! StreamDeck Plus device configuration -//! -//! The StreamDeck Plus with 8 keys and additional controls (PID: 0x0080) - -use super::{ButtonLayout, DeviceConfig, DisplayConfig, ImageFormat, ProtocolVersion, UsbConfig}; - -/// StreamDeck Plus configuration (PID: 0x0080) -pub struct PlusConfig; - -impl DeviceConfig for PlusConfig { - fn device_name(&self) -> &'static str { - "StreamDeck Plus" - } - - fn button_layout(&self) -> ButtonLayout { - ButtonLayout::new(4, 2, true) // 4x2 layout, left-to-right - } - - fn display_config(&self) -> DisplayConfig { - DisplayConfig { - image_width: 120, - image_height: 120, - format: ImageFormat::Jpeg, - needs_rotation: false, - flip_horizontal: false, // Plus needs no transformation - flip_vertical: false, - } - } - - fn usb_config(&self) -> UsbConfig { - UsbConfig { - vid: 0x0fd9, - pid: 0x0084, - product_name: "Stream Deck Plus", - manufacturer: "Elgato Systems", - protocol: ProtocolVersion::V2, - } - } -} diff --git a/src/device/plus_xl.rs b/src/device/plus_xl.rs new file mode 100644 index 0000000..ca9a810 --- /dev/null +++ b/src/device/plus_xl.rs @@ -0,0 +1 @@ +//! Stream Deck + XL — Main Protocol (V2), USB PID `0x0084` (same as + per Elgato HID docs). diff --git a/src/device/xl.rs b/src/device/xl.rs deleted file mode 100644 index 4a6dd1a..0000000 --- a/src/device/xl.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! StreamDeck XL device configuration -//! -//! The large StreamDeck with 32 keys (PID: 0x006c) - -use super::{ButtonLayout, DeviceConfig, DisplayConfig, ImageFormat, ProtocolVersion, UsbConfig}; - -/// StreamDeck XL configuration (PID: 0x006c) -pub struct XlConfig; - -impl DeviceConfig for XlConfig { - fn device_name(&self) -> &'static str { - "StreamDeck XL" - } - - fn button_layout(&self) -> ButtonLayout { - ButtonLayout::new(8, 4, true) // 8x4 layout, left-to-right - } - - fn display_config(&self) -> DisplayConfig { - DisplayConfig { - image_width: 96, - image_height: 96, - format: ImageFormat::Jpeg, - needs_rotation: false, - flip_horizontal: true, // XL needs both horizontal and vertical flip - flip_vertical: true, - } - } - - fn usb_config(&self) -> UsbConfig { - UsbConfig { - vid: 0x0fd9, - pid: 0x006c, - product_name: "Stream Deck XL", - manufacturer: "Elgato Systems", - protocol: ProtocolVersion::V2, - } - } -} diff --git a/src/display.rs b/src/display.rs index 2527e2b..126831a 100644 --- a/src/display.rs +++ b/src/display.rs @@ -14,7 +14,7 @@ use heapless::Vec; use crate::channels::DISPLAY_CHANNEL; use crate::config::*; -use crate::types::DisplayCommand; +use crate::types::{DisplayCommand, MAX_BUTTON_SLOTS}; // =================================================================== // Display Controller Structure @@ -213,7 +213,6 @@ impl DisplayController { // Convert RGB888 to RGB565 and send to display let pixel_count = image_size * image_size; - let mut buffer = [0u8; 2]; // Buffer for one RGB565 pixel for i in 0..pixel_count { let rgb_offset = i * 3; @@ -221,15 +220,7 @@ impl DisplayController { let r = rgb_data[rgb_offset]; let g = rgb_data[rgb_offset + 1]; let b = rgb_data[rgb_offset + 2]; - - // Convert to RGB565 - let rgb565 = ((r as u16 & RGB565_RED_MASK) << 8) - | ((g as u16 & RGB565_GREEN_MASK) << 3) - | (b as u16 >> RGB565_BLUE_SHIFT); - - // Send as big-endian - buffer[0] = (rgb565 >> 8) as u8; - buffer[1] = (rgb565 & 0xFF) as u8; + let buffer = crate::protocol::image::rgb888_pixel_to_rgb565_be(r, g, b); let _ = self.spi.blocking_write(&buffer); } } @@ -428,12 +419,8 @@ pub async fn display_task( let mut controller = DisplayController::new(spi, cs, dc, rst, bl).await; - let mut image_buffers: [ImageBuffer; 32] = Default::default(); // Max keys for any device - - // Initialize image buffers - for buffer in &mut image_buffers { - *buffer = ImageBuffer::new(); - } + let mut image_buffers: [ImageBuffer; MAX_BUTTON_SLOTS] = + core::array::from_fn(|_| ImageBuffer::new()); let receiver = DISPLAY_CHANNEL.receiver(); @@ -451,8 +438,7 @@ pub async fn display_task( controller.set_brightness(brightness).await; } DisplayCommand::DisplayImage { key_id, data } => { - if key_id < 32 { - // Max keys for any device + if (key_id as usize) < MAX_BUTTON_SLOTS { let buffer = &mut image_buffers[key_id as usize]; match buffer.add_packet(&data) { @@ -473,6 +459,18 @@ pub async fn display_task( error!("Invalid key_id: {}", key_id); } } + DisplayCommand::DisplayFullScreen { data: _ } => { + info!("Full-screen image (stub; not rendered on ST7735)"); + } + DisplayCommand::DisplayWindow { data: _ } => { + info!("Window strip image (stub)"); + } + DisplayCommand::FillLcd { r, g, b } => { + info!("Fill LCD RGB({}, {}, {}) (stub)", r, g, b); + } + DisplayCommand::FillKey { key_index, r, g, b } => { + info!("Fill key {} RGB({}, {}, {}) (stub)", key_index, r, g, b); + } } } } diff --git a/src/entry.rs b/src/entry.rs new file mode 100644 index 0000000..5d8ef57 --- /dev/null +++ b/src/entry.rs @@ -0,0 +1,251 @@ +//! Shared firmware entry points for device-specific binaries. + +#![allow(unreachable_code)] + +use crate::buttons; +use crate::config::{self, MULTICORE_CHANNEL_SIZE}; +use crate::device::{Device, DeviceConfig}; +use crate::hardware; +use crate::supervisor::AppSupervisor; +use crate::types::DisplayCommand; +use crate::usb; +use defmt::{error, info, unwrap, warn}; +use embassy_executor::{Executor, Spawner}; +use embassy_rp::gpio::{Input, Level, Output, Pull}; +use embassy_rp::multicore::{spawn_core1, Stack}; +use embassy_rp::usb::Driver; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::channel::Channel; +use static_cell::StaticCell; + +// --------------------------------------------------------------------------- +// Single-core (`#[embassy_executor::main]`) entry +// --------------------------------------------------------------------------- + +/// Initialize runtime device, hardware tasks, and run the supervisor loop. +pub async fn run_single_core(spawner: Spawner, device: Device) { + config::init_runtime_device(device); + let p = embassy_rp::init(Default::default()); + let mut supervisor = AppSupervisor::new_for_device(device); + supervisor.print_startup_banner(); + match hardware::init_hardware_tasks_for_device(&spawner, p, device).await { + Ok(()) => { + info!("{} firmware initialized successfully", device.device_name()); + supervisor.print_init_success(); + } + Err(e) => { + error!("Failed to spawn hardware tasks: {:?}", e); + core::panic!("Hardware initialization failed"); + } + } + supervisor.run().await; +} + +/// Same as [`run_single_core`] without the extra `info!` after hardware init (minimal binaries). +pub async fn run_single_core_quiet(spawner: Spawner, device: Device) { + config::init_runtime_device(device); + let p = embassy_rp::init(Default::default()); + let mut supervisor = AppSupervisor::new_for_device(device); + supervisor.print_startup_banner(); + match hardware::init_hardware_tasks_for_device(&spawner, p, device).await { + Ok(()) => supervisor.print_init_success(), + Err(_) => core::panic!("Hardware init failed"), + } + supervisor.run().await; +} + +// --------------------------------------------------------------------------- +// Multicore (`cortex_m_rt::entry` + dual executors) +// --------------------------------------------------------------------------- + +/// Cross-core display pipeline (not yet wired from core 0). +pub static MULTICORE_IMAGE_CHANNEL: Channel< + CriticalSectionRawMutex, + DisplayCommand, + MULTICORE_CHANNEL_SIZE, +> = Channel::new(); + +static mut CORE1_STACK: Stack<4096> = Stack::new(); +static EXECUTOR0: StaticCell = StaticCell::new(); +static EXECUTOR1: StaticCell = StaticCell::new(); + +/// GPIO / button wiring for multicore Stream Deck–style builds. +#[derive(Clone, Copy)] +pub enum MulticoreCore0Layout { + /// Mini / Module 6: USB LED `PIN_20`, heartbeat `PIN_25` + `PIN_21`, six direct inputs. + MiniOrModule6Direct, + /// Module 15: USB `PIN_20`, status `PIN_25` / `PIN_21`, 5×3 matrix. + Module15Matrix, + /// Module 32: USB LED `PIN_25`, status `PIN_20` / `PIN_21`, 8×4 matrix. + Module32Matrix, +} + +/// Core 1 image buffer size for the display stub loop. +#[derive(Clone, Copy)] +pub enum MulticoreCore1Buffer { + /// 8 KiB (Mini, Module 6, Module 15). + B8192, + /// 16 KiB (Module 32). + B16384, +} + +/// Multicore bring-up: core 1 runs display stub; core 0 runs USB, buttons, supervisor. +pub fn run_multicore( + device: Device, + layout: MulticoreCore0Layout, + core1_buf: MulticoreCore1Buffer, +) -> ! { + let p = embassy_rp::init(Default::default()); + config::init_runtime_device(device); + + let supervisor = AppSupervisor::new_for_device(device); + supervisor.print_startup_banner(); + + spawn_core1( + p.CORE1, + unsafe { &mut *core::ptr::addr_of_mut!(CORE1_STACK) }, + move || { + let executor1 = EXECUTOR1.init(Executor::new()); + executor1.run(|spawner| match core1_buf { + MulticoreCore1Buffer::B8192 => { + unwrap!(spawner.spawn(multicore_core1_image_task_8192(device))); + } + MulticoreCore1Buffer::B16384 => { + unwrap!(spawner.spawn(multicore_core1_image_task_16384(device))); + } + }); + }, + ); + + let executor0 = EXECUTOR0.init(Executor::new()); + executor0.run(|spawner| { + unwrap!(spawner.spawn(multicore_core0_supervisor_task(supervisor))); + match layout { + MulticoreCore0Layout::MiniOrModule6Direct => { + unwrap!(spawner.spawn(usb::usb_task_for_device( + Driver::new(p.USB, crate::Irqs), + Output::new(p.PIN_20, Level::Low), + device, + ))); + unwrap!(spawner.spawn(buttons::button_task_direct({ + let mut inputs = heapless::Vec::new(); + let _ = inputs.push(Input::new(p.PIN_4, Pull::Up)); + let _ = inputs.push(Input::new(p.PIN_5, Pull::Up)); + let _ = inputs.push(Input::new(p.PIN_6, Pull::Up)); + let _ = inputs.push(Input::new(p.PIN_10, Pull::Up)); + let _ = inputs.push(Input::new(p.PIN_11, Pull::Up)); + let _ = inputs.push(Input::new(p.PIN_12, Pull::Up)); + inputs + }))); + unwrap!(spawner.spawn(hardware::status_task( + Output::new(p.PIN_25, Level::Low), + Output::new(p.PIN_21, Level::Low), + ))); + } + MulticoreCore0Layout::Module15Matrix => { + unwrap!(spawner.spawn(usb::usb_task_for_device( + Driver::new(p.USB, crate::Irqs), + Output::new(p.PIN_20, Level::Low), + device, + ))); + unwrap!(spawner.spawn(buttons::button_task_matrix_5x3( + Output::new(p.PIN_2, Level::High), + Output::new(p.PIN_3, Level::High), + Output::new(p.PIN_7, Level::High), + Input::new(p.PIN_4, Pull::Up), + Input::new(p.PIN_5, Pull::Up), + Input::new(p.PIN_6, Pull::Up), + Input::new(p.PIN_10, Pull::Up), + Input::new(p.PIN_11, Pull::Up), + ))); + unwrap!(spawner.spawn(hardware::status_task( + Output::new(p.PIN_25, Level::Low), + Output::new(p.PIN_21, Level::Low), + ))); + } + MulticoreCore0Layout::Module32Matrix => { + unwrap!(spawner.spawn(usb::usb_task_for_device( + Driver::new(p.USB, crate::Irqs), + Output::new(p.PIN_25, Level::Low), + device, + ))); + unwrap!(spawner.spawn(buttons::button_task_matrix_8x4( + Output::new(p.PIN_2, Level::High), + Output::new(p.PIN_3, Level::High), + Output::new(p.PIN_7, Level::High), + Output::new(p.PIN_9, Level::High), + Input::new(p.PIN_4, Pull::Up), + Input::new(p.PIN_5, Pull::Up), + Input::new(p.PIN_6, Pull::Up), + Input::new(p.PIN_10, Pull::Up), + Input::new(p.PIN_11, Pull::Up), + Input::new(p.PIN_12, Pull::Up), + Input::new(p.PIN_13, Pull::Up), + Input::new(p.PIN_16, Pull::Up), + ))); + unwrap!(spawner.spawn(hardware::status_task( + Output::new(p.PIN_20, Level::Low), + Output::new(p.PIN_21, Level::Low), + ))); + } + } + }); + + loop { + cortex_m::asm::wfe(); + } +} + +#[embassy_executor::task] +async fn multicore_core0_supervisor_task(mut supervisor: AppSupervisor) { + let device = supervisor.device(); + info!( + "Core 0: USB + buttons (multicore) — {}", + device.device_name() + ); + supervisor.print_init_success(); + supervisor.run().await; +} + +async fn multicore_core1_image_loop(device: Device, buf: &mut [u8]) { + info!("Core 1: image/display stub for {}", device.device_name()); + match hardware::init_hardware_tasks_core1(device).await { + Ok(()) => info!("Core 1: image pipeline init OK"), + Err(e) => { + error!("Core 1: init failed: {:?}", e); + core::panic!("Image processing initialization failed"); + } + } + let receiver = MULTICORE_IMAGE_CHANNEL.receiver(); + loop { + match receiver.receive().await { + DisplayCommand::DisplayImage { key_id, data } => { + info!("Core 1: image key {} ({} bytes)", key_id, data.len()); + if data.len() <= buf.len() { + let copy_len = data.len().min(buf.len()); + buf[..copy_len].copy_from_slice(&data[..copy_len]); + } else { + warn!("Core 1: image too large ({} > {})", data.len(), buf.len()); + } + } + DisplayCommand::SetBrightness(brightness) => { + info!("Core 1: brightness {}%", brightness); + } + DisplayCommand::ClearAll => info!("Core 1: clear all (stub)"), + DisplayCommand::Clear(key_id) => info!("Core 1: clear key {} (stub)", key_id), + _ => {} + } + } +} + +#[embassy_executor::task] +async fn multicore_core1_image_task_8192(device: Device) { + let mut image_processing_buffer = [0u8; 8192]; + multicore_core1_image_loop(device, &mut image_processing_buffer).await; +} + +#[embassy_executor::task] +async fn multicore_core1_image_task_16384(device: Device) { + let mut image_processing_buffer = [0u8; 16384]; + multicore_core1_image_loop(device, &mut image_processing_buffer).await; +} diff --git a/src/hardware.rs b/src/hardware.rs index c75c226..6ffdcba 100644 --- a/src/hardware.rs +++ b/src/hardware.rs @@ -11,9 +11,9 @@ use embassy_rp::{peripherals, Peripherals}; use heapless::Vec; use crate::buttons::{ - button_task_direct, button_task_matrix_3x2, button_task_matrix_5x3, button_task_matrix_8x4, + button_task_direct, button_task_matrix_3x2, button_task_matrix_4x2, button_task_matrix_5x3, + button_task_matrix_8x4, button_task_matrix_9x4, }; -use crate::config; use crate::device::{Device, DeviceConfig}; use crate::usb::usb_task_for_device; @@ -49,13 +49,9 @@ pub struct LedPins { } impl HardwareConfig { - /// Get hardware configuration for the current device - pub fn for_current_device() -> Self { - let device = config::get_current_device(); - Self::for_device(device) - } - - /// Get hardware configuration for a specific device + /// Button matrix BCM pin tables (must match [`create_all_pins_for_device`]). + /// + /// This is the canonical row/column assignment for each layout; do not duplicate in `config`. pub fn for_device(device: Device) -> Self { let layout = device.button_layout(); @@ -64,8 +60,12 @@ impl HardwareConfig { (2, 3) => (&[2u8, 3][..], &[4u8, 5, 6][..]), // Mini (3, 5) => (&[2u8, 3, 7][..], &[4u8, 5, 6, 10, 11][..]), // Original (4, 8) => (&[2u8, 3, 7, 9][..], &[4u8, 5, 6, 10, 11, 12, 13, 16][..]), // XL - (2, 4) => (&[2u8, 3][..], &[4u8, 5, 6, 10][..]), // Plus - _ => (&[2u8, 3][..], &[4u8, 5, 6][..]), // Fallback to Mini + (4, 9) => ( + &[2u8, 3, 7, 9][..], + &[4u8, 5, 6, 10, 11, 12, 13, 16, 22][..], + ), // + XL + (2, 4) => (&[2u8, 3][..], &[4u8, 5, 6, 10][..]), // Plus / Neo + _ => core::panic!("no pin mapping for matrix {}×{}", layout.cols, layout.rows), }; Self { @@ -88,12 +88,6 @@ impl HardwareConfig { } } -/// Initialize and spawn all hardware tasks for the current device (runtime selection) -pub async fn init_hardware_tasks(spawner: &Spawner, p: Peripherals) -> Result<(), SpawnError> { - let hw_config = HardwareConfig::for_current_device(); - init_hardware_tasks_with_config(spawner, p, &hw_config).await -} - /// Initialize and spawn all hardware tasks for a specific device (compile-time selection) pub async fn init_hardware_tasks_for_device( spawner: &Spawner, @@ -127,7 +121,9 @@ pub async fn init_hardware_tasks_core0( // For Mini devices, prefer Direct pin mode with 6 dedicated inputs if matches!( device, - crate::device::Device::Mini | crate::device::Device::RevisedMini + crate::device::Device::Mini + | crate::device::Device::RevisedMini + | crate::device::Device::MiniDiscord ) { crate::config::set_button_input_mode(crate::config::ButtonInputMode::Direct); } @@ -182,7 +178,9 @@ async fn init_hardware_tasks_with_config( let device = hw_config.device; if matches!( device, - crate::device::Device::Mini | crate::device::Device::RevisedMini + crate::device::Device::Mini + | crate::device::Device::RevisedMini + | crate::device::Device::MiniDiscord ) { crate::config::set_button_input_mode(crate::config::ButtonInputMode::Direct); } @@ -226,8 +224,10 @@ fn create_all_pins_for_device( if matches!( crate::config::button_input_mode(), crate::config::ButtonInputMode::Direct - ) && matches!(device, Device::Mini | Device::RevisedMini) - { + ) && matches!( + device, + Device::Mini | Device::RevisedMini | Device::MiniDiscord + ) { // Build six dedicated direct-input pins for Mini to avoid partial-move issues let _ = col_pins.push(Input::new(p.PIN_4, Pull::Up)); let _ = col_pins.push(Input::new(p.PIN_5, Pull::Up)); @@ -245,6 +245,15 @@ fn create_all_pins_for_device( let _ = col_pins.push(Input::new(p.PIN_5, Pull::Up)); let _ = col_pins.push(Input::new(p.PIN_6, Pull::Up)); } + (2, 4) => { + // Stream Deck + / Neo (4x2 keys) + let _ = row_pins.push(Output::new(p.PIN_2, Level::High)); + let _ = row_pins.push(Output::new(p.PIN_3, Level::High)); + let _ = col_pins.push(Input::new(p.PIN_4, Pull::Up)); + let _ = col_pins.push(Input::new(p.PIN_5, Pull::Up)); + let _ = col_pins.push(Input::new(p.PIN_6, Pull::Up)); + let _ = col_pins.push(Input::new(p.PIN_10, Pull::Up)); + } (3, 5) => { // 15 Keys Module (5x3) let _ = row_pins.push(Output::new(p.PIN_2, Level::High)); @@ -271,18 +280,23 @@ fn create_all_pins_for_device( let _ = col_pins.push(Input::new(p.PIN_13, Pull::Up)); let _ = col_pins.push(Input::new(p.PIN_16, Pull::Up)); } - _ => { - // Fallback to Mini layout if unknown - warn!( - "Using Mini button layout for {} - implement device-specific layout", - device.device_name() - ); + (4, 9) => { + // Stream Deck + XL (9x4) let _ = row_pins.push(Output::new(p.PIN_2, Level::High)); let _ = row_pins.push(Output::new(p.PIN_3, Level::High)); + let _ = row_pins.push(Output::new(p.PIN_7, Level::High)); + let _ = row_pins.push(Output::new(p.PIN_9, Level::High)); let _ = col_pins.push(Input::new(p.PIN_4, Pull::Up)); let _ = col_pins.push(Input::new(p.PIN_5, Pull::Up)); let _ = col_pins.push(Input::new(p.PIN_6, Pull::Up)); + let _ = col_pins.push(Input::new(p.PIN_10, Pull::Up)); + let _ = col_pins.push(Input::new(p.PIN_11, Pull::Up)); + let _ = col_pins.push(Input::new(p.PIN_12, Pull::Up)); + let _ = col_pins.push(Input::new(p.PIN_13, Pull::Up)); + let _ = col_pins.push(Input::new(p.PIN_16, Pull::Up)); + let _ = col_pins.push(Input::new(p.PIN_22, Pull::Up)); } + _ => core::panic!("no pin mapping for matrix {}×{}", layout.cols, layout.rows), } } @@ -309,6 +323,15 @@ fn spawn_button_task_with_pins( let col0 = col_pins.pop().unwrap(); spawner.spawn(button_task_matrix_3x2(row0, row1, col0, col1, col2)) } + (2, 4) => { + let row1 = row_pins.pop().unwrap(); + let row0 = row_pins.pop().unwrap(); + let col3 = col_pins.pop().unwrap(); + let col2 = col_pins.pop().unwrap(); + let col1 = col_pins.pop().unwrap(); + let col0 = col_pins.pop().unwrap(); + spawner.spawn(button_task_matrix_4x2(row0, row1, col0, col1, col2, col3)) + } (3, 5) => { let row2 = row_pins.pop().unwrap(); let row1 = row_pins.pop().unwrap(); @@ -339,16 +362,26 @@ fn spawn_button_task_with_pins( row0, row1, row2, row3, col0, col1, col2, col3, col4, col5, col6, col7, )) } - _ => { - // Fallback to 2x3 minimal - warn!("Unknown layout; falling back to 2x3 matrix task"); + (4, 9) => { + let row3 = row_pins.pop().unwrap(); + let row2 = row_pins.pop().unwrap(); let row1 = row_pins.pop().unwrap(); let row0 = row_pins.pop().unwrap(); + let col8 = col_pins.pop().unwrap(); + let col7 = col_pins.pop().unwrap(); + let col6 = col_pins.pop().unwrap(); + let col5 = col_pins.pop().unwrap(); + let col4 = col_pins.pop().unwrap(); + let col3 = col_pins.pop().unwrap(); let col2 = col_pins.pop().unwrap(); let col1 = col_pins.pop().unwrap(); let col0 = col_pins.pop().unwrap(); - spawner.spawn(button_task_matrix_3x2(row0, row1, col0, col1, col2)) + spawner.spawn(button_task_matrix_9x4( + row0, row1, row2, row3, col0, col1, col2, col3, col4, col5, col6, col7, + col8, + )) } + _ => core::panic!("no matrix button task for {}×{}", layout.cols, layout.rows), } } crate::config::ButtonInputMode::Direct => { @@ -358,7 +391,11 @@ fn spawn_button_task_with_pins( let _ = inputs.push(pin); } // Ensure Mini has exactly 6 inputs if possible - if matches!(device, Device::Mini | Device::RevisedMini) && inputs.len() > 6 { + if matches!( + device, + Device::Mini | Device::RevisedMini | Device::MiniDiscord + ) && inputs.len() > 6 + { while inputs.len() > 6 { let _ = inputs.pop(); } diff --git a/src/lib.rs b/src/lib.rs index 85587b0..4ae0dce 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,7 @@ pub mod channels; pub mod config; pub mod device; pub mod display; +pub mod entry; pub mod hardware; pub mod protocol; pub mod supervisor; diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 642623c..2cf1e47 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -3,13 +3,12 @@ //! Handles different protocol versions (V1 and V2) with unified interface pub mod module; -pub mod module_15_32; pub mod module_6; pub mod v1; pub mod v2; use crate::config::IMAGE_BUFFER_SIZE; -use crate::device::ProtocolVersion; +use crate::device::{Device, DeviceConfig, ProtocolVersion}; use crate::protocol::module::ModuleSetCommand; use heapless::Vec; @@ -17,16 +16,35 @@ use heapless::Vec; #[derive(Debug)] #[allow(clippy::large_enum_variant)] pub enum OutputReportResult { - /// Update Key Image (Module 15/32: cmd 0x07, Module 6: cmd 0x01 with ShowFlag=1) + /// Update Key Image (cmd 0x07) KeyImageComplete { key_id: u8, image: Vec, }, - /// Update Full Screen Image (Module 15/32: cmd 0x08) + /// Full LCD JPEG transfer complete (cmd 0x08) + FullScreenImageComplete { + image: Vec, + }, + /// Window strip JPEG complete (cmd 0x0B) + WindowImageComplete { + image: Vec, + }, + /// Partial window JPEG complete (cmd 0x0C) + PartialWindowImageComplete { + x: u16, + y: u16, + width: u16, + height: u16, + image: Vec, + }, + /// Background slot JPEG complete (cmd 0x0D) + BackgroundImageComplete { + index: u8, + image: Vec, + }, + /// Legacy / in-progress chunk (not assembled) FullScreenImageChunk, - /// Update Boot Logo (Module 15/32: cmd 0x09, Module 6 uses Feature combo) BootLogoImageChunk, - /// Output report not recognized/unsupported for current device Unhandled, } @@ -48,10 +66,121 @@ pub enum ImageProcessResult { /// Button mapping result for different devices #[derive(Debug)] pub struct ButtonMapping { - pub mapped_buttons: [bool; 32], // Max buttons supported (XL has 32) + pub mapped_buttons: [bool; crate::types::MAX_BUTTON_SLOTS], pub active_count: usize, } +/// Map physical scan order (row-major, `cols` × `rows`) to protocol key order. +/// `left_to_right` matches [`crate::device::ButtonLayout::left_to_right`]. +pub fn map_buttons_grid( + physical_buttons: &[bool], + cols: usize, + rows: usize, + left_to_right: bool, +) -> ButtonMapping { + let mut mapped_buttons = [false; crate::types::MAX_BUTTON_SLOTS]; + let total_keys = cols + .saturating_mul(rows) + .min(crate::types::MAX_BUTTON_SLOTS); + + for (physical_idx, &pressed) in physical_buttons.iter().take(total_keys).enumerate() { + let mapped_idx = if left_to_right { + physical_idx + } else { + let row = physical_idx / cols; + let col = physical_idx % cols; + let reversed_col = cols.saturating_sub(1).saturating_sub(col); + row * cols + reversed_col + }; + + if mapped_idx < crate::types::MAX_BUTTON_SLOTS { + mapped_buttons[mapped_idx] = pressed; + } + } + + ButtonMapping { + mapped_buttons, + active_count: total_keys, + } +} + +// --- Feature GET report helpers (shared by V1 / V2 / Module) --- + +#[inline] +pub fn feature_report_clamp(total_len: usize, buf_len: usize) -> usize { + total_len.min(buf_len) +} + +#[inline] +pub fn feature_report_zero_prefix(buf: &mut [u8], cap: usize) { + buf.iter_mut().take(cap).for_each(|b| *b = 0); +} + +/// V1 Mini-style: `[0]=RID`, `0x0C 31 33 00`, ASCII payload from byte 5. +pub fn fill_feature_v1_fw_string_report( + buf: &mut [u8], + report_id: u8, + total_len: usize, + ascii: &[u8], +) -> Option { + let cap = feature_report_clamp(total_len, buf.len()); + if cap == 0 { + return None; + } + feature_report_zero_prefix(buf, cap); + buf[0] = report_id; + buf[1] = 0x0c; + buf[2] = 0x31; + buf[3] = 0x33; + buf[4] = 0x00; + let start = 5usize; + let end = (start + ascii.len()).min(cap); + buf[start..end].copy_from_slice(&ascii[..(end - start)]); + Some(cap) +} + +/// Zeroed buffer, `[0]=RID`, copy `ascii` starting at `ascii_start`. +pub fn fill_feature_rid_ascii( + buf: &mut [u8], + report_id: u8, + total_len: usize, + ascii_start: usize, + ascii: &[u8], +) -> Option { + let cap = feature_report_clamp(total_len, buf.len()); + if cap == 0 { + return None; + } + feature_report_zero_prefix(buf, cap); + buf[0] = report_id; + let end = (ascii_start + ascii.len()).min(cap); + if end > ascii_start { + buf[ascii_start..end].copy_from_slice(&ascii[..(end - ascii_start)]); + } + Some(cap) +} + +/// Main protocol FW GET (reports `0x04` / `0x05` / `0x07`): `[0]=RID`, `[1]=0x0C`, four zero bytes, ASCII from byte 6. +pub fn fill_feature_v2_fw_version_report( + buf: &mut [u8], + report_id: u8, + total_len: usize, + ascii: &[u8], +) -> Option { + let cap = feature_report_clamp(total_len, buf.len()); + if cap == 0 { + return None; + } + feature_report_zero_prefix(buf, cap); + buf[0] = report_id; + buf[1] = 0x0c; + buf[2..6].fill(0); + let start = 6usize; + let end = (start + ascii.len()).min(cap); + buf[start..end].copy_from_slice(&ascii[..(end - start)]); + Some(cap) +} + /// Protocol handler trait for different StreamDeck versions pub trait ProtocolHandlerTrait { /// Get protocol version @@ -95,44 +224,46 @@ pub enum ProtocolHandler { V1(v1::V1Handler), V2(v2::V2Handler), Module6Keys(module_6::Module6KeysHandler), - Module15_32Keys(module_15_32::Module15_32KeysHandler), } impl ProtocolHandler { - /// Create appropriate protocol handler based on version - pub fn create(version: ProtocolVersion) -> Self { - match version { + /// Create handler for a compile-time / runtime selected device (preferred). + pub fn create_for_device(device: Device) -> Self { + match device.usb_config().protocol { ProtocolVersion::V1 => ProtocolHandler::V1(v1::V1Handler::new()), - ProtocolVersion::V2 => ProtocolHandler::V2(v2::V2Handler::new()), + ProtocolVersion::V2 => ProtocolHandler::V2(v2::V2Handler::new(device)), ProtocolVersion::Module6Keys => { ProtocolHandler::Module6Keys(module_6::Module6KeysHandler::new()) } - ProtocolVersion::Module15_32Keys => { - ProtocolHandler::Module15_32Keys(module_15_32::Module15_32KeysHandler::new()) - } } } - /// Get protocol version - pub fn version(&self) -> ProtocolVersion { + fn as_trait(&self) -> &dyn ProtocolHandlerTrait { match self { - ProtocolHandler::V1(_) => ProtocolVersion::V1, - ProtocolHandler::V2(_) => ProtocolVersion::V2, - ProtocolHandler::Module6Keys(_) => ProtocolVersion::Module6Keys, - ProtocolHandler::Module15_32Keys(_) => ProtocolVersion::Module15_32Keys, + ProtocolHandler::V1(h) => h, + ProtocolHandler::V2(h) => h, + ProtocolHandler::Module6Keys(h) => h, } } - /// Parse Output Report (host -> device) - pub fn parse_output_report(&mut self, data: &[u8]) -> OutputReportResult { + fn as_trait_mut(&mut self) -> &mut dyn ProtocolHandlerTrait { match self { - ProtocolHandler::V1(handler) => handler.parse_output_report(data), - ProtocolHandler::V2(handler) => handler.parse_output_report(data), - ProtocolHandler::Module6Keys(handler) => handler.parse_output_report(data), - ProtocolHandler::Module15_32Keys(handler) => handler.parse_output_report(data), + ProtocolHandler::V1(h) => h, + ProtocolHandler::V2(h) => h, + ProtocolHandler::Module6Keys(h) => h, } } + /// Get protocol version + pub fn version(&self) -> ProtocolVersion { + self.as_trait().version() + } + + /// Parse Output Report (host -> device) + pub fn parse_output_report(&mut self, data: &[u8]) -> OutputReportResult { + self.as_trait_mut().parse_output_report(data) + } + /// Map physical button layout to protocol button order pub fn map_buttons( &self, @@ -141,52 +272,23 @@ impl ProtocolHandler { rows: usize, left_to_right: bool, ) -> ButtonMapping { - match self { - ProtocolHandler::V1(handler) => { - handler.map_buttons(physical_buttons, cols, rows, left_to_right) - } - ProtocolHandler::V2(handler) => { - handler.map_buttons(physical_buttons, cols, rows, left_to_right) - } - ProtocolHandler::Module6Keys(handler) => { - handler.map_buttons(physical_buttons, cols, rows, left_to_right) - } - ProtocolHandler::Module15_32Keys(handler) => { - handler.map_buttons(physical_buttons, cols, rows, left_to_right) - } - } + self.as_trait() + .map_buttons(physical_buttons, cols, rows, left_to_right) } /// Generate HID report descriptor for this protocol pub fn hid_descriptor(&self) -> &'static [u8] { - match self { - ProtocolHandler::V1(handler) => handler.hid_descriptor(), - ProtocolHandler::V2(handler) => handler.hid_descriptor(), - ProtocolHandler::Module6Keys(handler) => handler.hid_descriptor(), - ProtocolHandler::Module15_32Keys(handler) => handler.hid_descriptor(), - } + self.as_trait().hid_descriptor() } /// Get input report format size pub fn input_report_size(&self, button_count: usize) -> usize { - match self { - ProtocolHandler::V1(handler) => handler.input_report_size(button_count), - ProtocolHandler::V2(handler) => handler.input_report_size(button_count), - ProtocolHandler::Module6Keys(handler) => handler.input_report_size(button_count), - ProtocolHandler::Module15_32Keys(handler) => handler.input_report_size(button_count), - } + self.as_trait().input_report_size(button_count) } /// Format button state into input report pub fn format_button_report(&self, buttons: &ButtonMapping, report: &mut [u8]) -> usize { - match self { - ProtocolHandler::V1(handler) => handler.format_button_report(buttons, report), - ProtocolHandler::V2(handler) => handler.format_button_report(buttons, report), - ProtocolHandler::Module6Keys(handler) => handler.format_button_report(buttons, report), - ProtocolHandler::Module15_32Keys(handler) => { - handler.format_button_report(buttons, report) - } - } + self.as_trait().format_button_report(buttons, report) } /// Process feature report commands @@ -195,24 +297,12 @@ impl ProtocolHandler { report_id: u8, data: &[u8], ) -> Option { - match self { - ProtocolHandler::V1(handler) => handler.handle_feature_report(report_id, data), - ProtocolHandler::V2(handler) => handler.handle_feature_report(report_id, data), - ProtocolHandler::Module6Keys(handler) => handler.handle_feature_report(report_id, data), - ProtocolHandler::Module15_32Keys(handler) => { - handler.handle_feature_report(report_id, data) - } - } + self.as_trait_mut().handle_feature_report(report_id, data) } /// Delegate feature GET report building to the specific handler pub fn get_feature_report(&mut self, report_id: u8, buf: &mut [u8]) -> Option { - match self { - ProtocolHandler::V1(handler) => handler.get_feature_report(report_id, buf), - ProtocolHandler::V2(handler) => handler.get_feature_report(report_id, buf), - ProtocolHandler::Module6Keys(handler) => handler.get_feature_report(report_id, buf), - ProtocolHandler::Module15_32Keys(handler) => handler.get_feature_report(report_id, buf), - } + self.as_trait_mut().get_feature_report(report_id, buf) } } @@ -220,21 +310,25 @@ impl ProtocolHandler { pub mod image { use super::*; + /// One RGB888 triplet to big-endian RGB565 bytes (ST7736 / SPI wire order). + #[inline] + pub fn rgb888_pixel_to_rgb565_be(r: u8, g: u8, b: u8) -> [u8; 2] { + let r5 = (r >> 3) as u16; + let g6 = (g >> 2) as u16; + let b5 = (b >> 3) as u16; + let rgb565 = (r5 << 11) | (g6 << 5) | b5; + [(rgb565 >> 8) as u8, (rgb565 & 0xFF) as u8] + } + /// Convert RGB888 to RGB565 for display pub fn rgb888_to_rgb565(rgb888: &[u8]) -> Vec { let mut rgb565_data = Vec::new(); for chunk in rgb888.chunks_exact(3) { if let [r, g, b] = chunk { - let r5 = (r >> 3) as u16; - let g6 = (g >> 2) as u16; - let b5 = (b >> 3) as u16; - - let rgb565 = (r5 << 11) | (g6 << 5) | b5; - - // Store as big-endian for display - let _ = rgb565_data.push((rgb565 >> 8) as u8); - let _ = rgb565_data.push((rgb565 & 0xFF) as u8); + let be = rgb888_pixel_to_rgb565_be(*r, *g, *b); + let _ = rgb565_data.push(be[0]); + let _ = rgb565_data.push(be[1]); } } diff --git a/src/protocol/module.rs b/src/protocol/module.rs index 3103249..2a39b16 100644 --- a/src/protocol/module.rs +++ b/src/protocol/module.rs @@ -12,8 +12,9 @@ pub enum ModuleSetCommand { UpdateBootLogo { slice: u8 }, SetBrightness { value: u8 }, SetIdleTime { seconds: i32 }, - SetKeyColor { key_index: u8, r: u8, g: u8, b: u8 }, // Module 15/32 only - ShowBackgroundByIndex { index: u8 }, // Module 15/32 only + SetKeyColor { key_index: u8, r: u8, g: u8, b: u8 }, + FillLcdColor { r: u8, g: u8, b: u8 }, + ShowBackgroundByIndex { index: u8 }, } #[derive(Debug, Clone, Copy, PartialEq, defmt::Format)] diff --git a/src/protocol/module_15_32.rs b/src/protocol/module_15_32.rs deleted file mode 100644 index fd70720..0000000 --- a/src/protocol/module_15_32.rs +++ /dev/null @@ -1,300 +0,0 @@ -//! StreamDeck Module HID Protocol Handler (15/32 keys) -//! -//! Unified handler for Module 15 and Module 32 per Elgato HID API. -//! Reference: https://docs.elgato.com/streamdeck/hid/module-15_32 - -use super::{ButtonMapping, ProtocolHandlerTrait}; -use crate::device::ProtocolVersion; -use crate::protocol::module::{FirmwareType, ModuleGetCommand, ModuleSetCommand}; -use crate::protocol::OutputReportResult; - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum ModuleModel { - Module15, - Module32, -} - -#[derive(Debug)] -pub struct Module15_32KeysHandler { - model: ModuleModel, -} - -impl Module15_32KeysHandler { - pub fn new() -> Self { - Self { - model: ModuleModel::Module15, - } - } - pub fn with_model(model: ModuleModel) -> Self { - Self { model } - } - - fn parse_module_set_command(&self, report_id: u8, data: &[u8]) -> Option { - match report_id { - // Set Backlight Brightness (Feature report ID 0x03, Command 0x08) - 0x03 => { - if data.len() >= 3 && data[1] == 0x08 { - Some(ModuleSetCommand::SetBrightness { value: data[2] }) - } else { - None - } - } - _ => None, - } - } - - fn parse_module_get_command(&self, report_id: u8) -> Option { - match report_id { - 0x04 => Some(ModuleGetCommand::GetFirmwareVersion(FirmwareType::LD)), - 0x05 => Some(ModuleGetCommand::GetFirmwareVersion(FirmwareType::AP2)), - 0x07 => Some(ModuleGetCommand::GetFirmwareVersion(FirmwareType::AP1)), - 0x06 => Some(ModuleGetCommand::GetUnitSerialNumber), - 0x0A => Some(ModuleGetCommand::GetIdleTime), - 0x08 => Some(ModuleGetCommand::GetUnitInformation), - _ => None, - } - } - - fn get_firmware_version(&self, firmware_type: FirmwareType) -> &'static [u8] { - match firmware_type { - FirmwareType::LD => b"1.00.000", - FirmwareType::AP2 => b"1.00.000", - FirmwareType::AP1 => b"1.00.000", - } - } - - fn get_unit_serial_number(&self) -> &'static [u8] { - b"A1B2C3D4E5F6G7" - } -} - -impl Default for Module15_32KeysHandler { - fn default() -> Self { - Self::new() - } -} - -impl ProtocolHandlerTrait for Module15_32KeysHandler { - fn version(&self) -> ProtocolVersion { - ProtocolVersion::Module15_32Keys - } - - fn parse_output_report(&mut self, data: &[u8]) -> OutputReportResult { - let report_id = data[0]; - let command = data[1]; - - match report_id { - 0x02 => { - match command { - 0x07 => { - // Update key Image - let _key_index = data[2]; - let _transfer_done = data[3]; - let _chunk_content = u16::from_le_bytes([data[4], data[5]]); - let _chunk_index = u16::from_le_bytes([data[6], data[7]]); - let _chunk_data = &data[8..]; - OutputReportResult::Unhandled - } - 0x08 => { - // Update Full Screen Image - let _key_index = data[2]; - let _transfer_done = data[3]; - let _chunk_content = u16::from_le_bytes([data[4], data[5]]); - let _chunk_index = u16::from_le_bytes([data[6], data[7]]); - let _chunk_data = &data[8..]; - OutputReportResult::Unhandled - } - 0x09 => { - // Update Boot Logo - let _reserved = data[2]; - let _transfer_done = data[3]; - let _chunk_index = u16::from_le_bytes([data[4], data[5]]); - let _chunk_contents_size = u16::from_le_bytes([data[6], data[7]]); - let _chunk_data = &data[8..]; - OutputReportResult::Unhandled - } - 0x0D => { - // Update Background - let _background_index = data[2]; - let _transfer_done = data[3]; - let _chunk_index = u16::from_le_bytes([data[4], data[5]]); - let _chunk_contents_size = u16::from_le_bytes([data[6], data[7]]); - let _chunk_data = &data[8..]; - OutputReportResult::Unhandled - } - _ => OutputReportResult::Unhandled, - } - } - _ => OutputReportResult::Unhandled, - } - } - - fn map_buttons( - &self, - physical_buttons: &[bool], - cols: usize, - rows: usize, - left_to_right: bool, - ) -> ButtonMapping { - let max = match self.model { - ModuleModel::Module15 => 15, - ModuleModel::Module32 => 32, - }; - let mut mapped = [false; 32]; - for y in 0..rows { - for x in 0..cols { - let src_index = if left_to_right { - y * cols + x - } else { - y * cols + (cols - 1 - x) - }; - let dst_index = y * cols + x; - if src_index < physical_buttons.len() && dst_index < max { - mapped[dst_index] = physical_buttons[src_index]; - } - } - } - ButtonMapping { - mapped_buttons: mapped, - active_count: max, - } - } - - fn hid_descriptor(&self) -> &'static [u8] { - // Input(0x01), Output(0x02), Feature IDs (0x03,0x04,0x05,0x06,0x07,0x08,0x0A) - const DESC: &[u8] = &[ - 0x05, 0x0C, 0x09, 0x01, 0xA1, 0x01, 0x85, 0x01, 0x05, 0x09, 0x19, 0x01, 0x29, 0x20, - 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x20, 0x81, 0x02, 0x85, 0x02, 0x0A, - 0x00, 0xFF, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x96, 0xFF, 0x03, 0x91, 0x02, - 0x85, 0x03, 0x0A, 0x00, 0xFF, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x10, - 0xB1, 0x04, 0x85, 0x04, 0x0A, 0x00, 0xFF, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, - 0x95, 0x10, 0xB1, 0x04, 0x85, 0x05, 0x0A, 0x00, 0xFF, 0x15, 0x00, 0x26, 0xFF, 0x00, - 0x75, 0x08, 0x95, 0x10, 0xB1, 0x04, 0x85, 0x06, 0x0A, 0x00, 0xFF, 0x15, 0x00, 0x26, - 0xFF, 0x00, 0x75, 0x08, 0x95, 0x10, 0xB1, 0x04, 0x85, 0x07, 0x0A, 0x00, 0xFF, 0x15, - 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x10, 0xB1, 0x04, 0x85, 0x08, 0x0A, 0x00, - 0xFF, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x10, 0xB1, 0x04, 0x85, 0x0A, - 0x0A, 0x00, 0xFF, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x10, 0xB1, 0x04, - 0xC0, - ]; - DESC - } - - fn input_report_size(&self, _button_count: usize) -> usize { - 512 - } - - fn format_button_report(&self, buttons: &ButtonMapping, report: &mut [u8]) -> usize { - // Report ID 0x01, Command 0x00, Length = number of keys, then states - let max_keys = match self.model { - ModuleModel::Module15 => 15, - ModuleModel::Module32 => 32, - }; - let used = core::cmp::min(max_keys, buttons.mapped_buttons.len()); - let needed = 4 + used; - if report.len() < needed { - return 0; - } - report[0] = 0x01; // Report ID - report[1] = 0x00; // Command: key state change - report[2] = used as u8; // length LSB (fits within u8 for 32) - report[3] = 0x00; // length MSB - for i in 0..used { - report[4 + i] = if buttons.mapped_buttons[i] { 1 } else { 0 }; - } - needed - } - - fn handle_feature_report(&mut self, report_id: u8, data: &[u8]) -> Option { - if let Some(cmd) = self.parse_module_set_command(report_id, data) { - return Some(cmd); - } - None - } - - fn get_feature_report(&mut self, report_id: u8, buf: &mut [u8]) -> Option { - let total_len = 32.min(buf.len()); - buf.iter_mut().take(total_len).for_each(|b| *b = 0); - if let Some(cmd) = self.parse_module_get_command(report_id) { - match cmd { - ModuleGetCommand::GetFirmwareVersion(ftype) => { - let ver = self.get_firmware_version(ftype); - buf[0] = report_id; - buf[1] = 0x0C; // data length - // bytes 2..5 checksum ignored (0) - // version ASCII at offset 6, 8 bytes - let start = 6; - let end = (start + ver.len()).min(total_len); - if end > start { - buf[start..end].copy_from_slice(&ver[..(end - start)]); - } - return Some(total_len); - } - ModuleGetCommand::GetUnitSerialNumber => { - let serial = self.get_unit_serial_number(); - buf[0] = 0x06; - let data_len = core::cmp::min(serial.len(), 14) as u8; - buf[1] = data_len; // 0x0C or 0x0E - let start = 2; - let end = (start + data_len as usize).min(total_len); - if end > start { - buf[start..end].copy_from_slice(&serial[..(end - start)]); - } - return Some(total_len); - } - ModuleGetCommand::GetIdleTime => { - buf[0] = 0x0A; - buf[1] = 0x04; // data length - let secs = crate::config::get_idle_time_seconds(); - let le = secs.to_le_bytes(); - buf[2] = le[0]; - buf[3] = le[1]; - buf[4] = le[2]; - buf[5] = le[3]; - return Some(total_len); - } - ModuleGetCommand::GetUnitInformation => { - // Feature Report - Get Unit Information (Report ID 0x08) - // Layout depends on model per docs. - // Offsets: - // [0]=ReportID(0x08) - // [1]=rows, [2]=cols, - // [3..4]=key width LE, [5..6]=key height LE, - // [7..8]=LCD width LE, [9..10]=LCD height LE, - // [11]=Image BPP, [12]=Color scheme, - // [13]=#key images in gallery, [14]=#LCD images in gallery, - // [15]=#frames for DEMO, [16]=Reserved - - let (rows, cols, key_w, key_h, lcd_w, lcd_h) = match self.model { - ModuleModel::Module15 => (3u8, 5u8, 72u16, 72u16, 480u16, 272u16), - ModuleModel::Module32 => (4u8, 8u8, 96u16, 96u16, 1024u16, 600u16), - }; - - buf[0] = 0x08; - buf[1] = rows; - buf[2] = cols; - let kw = key_w.to_le_bytes(); - let kh = key_h.to_le_bytes(); - let lw = lcd_w.to_le_bytes(); - let lh = lcd_h.to_le_bytes(); - buf[3] = kw[0]; - buf[4] = kw[1]; - buf[5] = kh[0]; - buf[6] = kh[1]; - buf[7] = lw[0]; - buf[8] = lw[1]; - buf[9] = lh[0]; - buf[10] = lh[1]; - // JPEG images per docs; use 24bpp and RGB color scheme (0x00) - buf[11] = 24; // Image BPP - buf[12] = 0x00; // Image Color Scheme (assume RGB) - buf[13] = 0x00; // Gallery counts unknown -> 0 - buf[14] = 0x00; // Gallery counts unknown -> 0 - buf[15] = 0x00; // DEMO frames - buf[16] = 0x00; // Reserved - return Some(total_len); - } - } - } - None - } -} diff --git a/src/protocol/module_6.rs b/src/protocol/module_6.rs index de897ec..d7e70c5 100644 --- a/src/protocol/module_6.rs +++ b/src/protocol/module_6.rs @@ -4,7 +4,10 @@ //! Modules per public HID API docs. Image upload parsing is stubbed until we //! confirm exact chunk layout from PCAPs. -use super::{ButtonMapping, OutputReportResult, ProtocolHandlerTrait}; +use super::{ + feature_report_clamp, feature_report_zero_prefix, fill_feature_rid_ascii, map_buttons_grid, + ButtonMapping, OutputReportResult, ProtocolHandlerTrait, +}; use crate::device::ProtocolVersion; use crate::protocol::module::{FirmwareType, ModuleGetCommand, ModuleSetCommand}; @@ -141,25 +144,7 @@ impl ProtocolHandlerTrait for Module6KeysHandler { rows: usize, left_to_right: bool, ) -> ButtonMapping { - let mut mapped = [false; 32]; - - for y in 0..rows { - for x in 0..cols { - let src_index = if left_to_right { - y * cols + x - } else { - y * cols + (cols - 1 - x) - }; - let dst_index = y * cols + x; - if src_index < physical_buttons.len() && dst_index < 32 { - mapped[dst_index] = physical_buttons[src_index]; - } - } - } - ButtonMapping { - mapped_buttons: mapped, - active_count: 6, - } + map_buttons_grid(physical_buttons, cols, rows, left_to_right) } fn hid_descriptor(&self) -> &'static [u8] { @@ -250,34 +235,22 @@ impl ProtocolHandlerTrait for Module6KeysHandler { impl Module6KeysHandler { pub fn get_feature_report_bytes(&self, report_id: u8, buf: &mut [u8]) -> Option { let total_len = 32.min(buf.len()); - buf.iter_mut().take(total_len).for_each(|b| *b = 0); if let Some(cmd) = self.parse_module_get_command(report_id) { match cmd { ModuleGetCommand::GetFirmwareVersion(ftype) => { let ver = self.get_firmware_version(ftype); - buf[0] = report_id; - // bytes 1..4 are N/A (0), version ASCII at offset 5 - let start = 5; - let end = (start + ver.len()).min(total_len); - // bytes 1..4 already zeroed above - if end > start { - buf[start..end].copy_from_slice(&ver[..(end - start)]); - } - return Some(total_len); + fill_feature_rid_ascii(buf, report_id, total_len, 5, ver) } ModuleGetCommand::GetUnitSerialNumber => { - let serial = self.get_unit_serial_number(); - buf[0] = 0x03; - let start = 5; - let end = (start + serial.len()).min(total_len); - if end > start { - buf[start..end].copy_from_slice(&serial[..(end - start)]); - } - return Some(total_len); + fill_feature_rid_ascii(buf, 0x03, total_len, 5, self.get_unit_serial_number()) } ModuleGetCommand::GetIdleTime => { + let cap = feature_report_clamp(total_len, buf.len()); + if cap == 0 { + return None; + } + feature_report_zero_prefix(buf, cap); buf[0] = 0xA3; - // Data length for INT32 duration is 4 bytes buf[1] = 0x04; let secs = crate::config::get_idle_time_seconds(); let le = secs.to_le_bytes(); @@ -285,13 +258,12 @@ impl Module6KeysHandler { buf[3] = le[1]; buf[4] = le[2]; buf[5] = le[3]; - return Some(total_len); - } - _ => { - return None; + Some(cap) } + _ => None, } + } else { + None } - None } } diff --git a/src/protocol/v1.rs b/src/protocol/v1.rs index bb30169..db4fd41 100644 --- a/src/protocol/v1.rs +++ b/src/protocol/v1.rs @@ -2,7 +2,11 @@ //! //! Handles Original, Mini, and Revised Mini devices using BMP format -use super::{ButtonMapping, OutputReportResult, ProtocolHandlerTrait}; +use super::{ + feature_report_clamp, feature_report_zero_prefix, fill_feature_rid_ascii, + fill_feature_v1_fw_string_report, map_buttons_grid, ButtonMapping, OutputReportResult, + ProtocolHandlerTrait, +}; use crate::config::{ FEATURE_REPORT_BRIGHTNESS_V1, IMAGE_PROCESSING_BUFFER_SIZE, STREAMDECK_BRIGHTNESS_RESET_MAGIC, STREAMDECK_MAGIC_1, STREAMDECK_MAGIC_2, STREAMDECK_MAGIC_3, STREAMDECK_RESET_MAGIC, @@ -115,29 +119,7 @@ impl ProtocolHandlerTrait for V1Handler { rows: usize, left_to_right: bool, ) -> ButtonMapping { - let mut mapped_buttons = [false; 32]; - let total_keys = cols * rows; - - for (physical_idx, &pressed) in physical_buttons.iter().take(total_keys).enumerate() { - let mapped_idx = if left_to_right { - physical_idx // Direct mapping for Mini and Revised Mini - } else { - // Right-to-left mapping for Original StreamDeck - let row = physical_idx / cols; - let col = physical_idx % cols; - let reversed_col = cols - 1 - col; - row * cols + reversed_col - }; - - if mapped_idx < 32 { - mapped_buttons[mapped_idx] = pressed; - } - } - - ButtonMapping { - mapped_buttons, - active_count: total_keys, - } + map_buttons_grid(physical_buttons, cols, rows, left_to_right) } fn hid_descriptor(&self) -> &'static [u8] { @@ -290,62 +272,23 @@ impl ProtocolHandlerTrait for V1Handler { } fn get_feature_report(&mut self, report_id: u8, buf: &mut [u8]) -> Option { + const FW_VER: &[u8] = b"3.00.000"; match report_id { - 0xA0..=0xA2 => { - let total_len = 32.min(buf.len()); - buf.iter_mut().take(total_len).for_each(|b| *b = 0); - buf[0] = report_id; - buf[1] = 0x0c; // Length - buf[2] = 0x31; // Type - buf[3] = 0x33; // Type - buf[4] = 0x00; // Null terminator - let version = b"3.00.000"; - let start = 5; - let end = (start + version.len()).min(total_len); - buf[start..end].copy_from_slice(&version[..(end - start)]); - Some(total_len) - } - 0x03 => { - let total_len = 32.min(buf.len()); - buf.iter_mut().take(total_len).for_each(|b| *b = 0); - buf[0] = report_id; - buf[1] = 0x0c; // Length - buf[2] = 0x31; // Type - buf[3] = 0x33; // Type - buf[4] = 0x00; // Null terminator - let serial = crate::config::USB_SERIAL.as_bytes(); - let start = 5; - let end = (start + serial.len()).min(total_len); - buf[start..end].copy_from_slice(&serial[..(end - start)]); - Some(total_len) - } - 0x04 => { - let total_len = 17.min(buf.len()); - buf.iter_mut().take(total_len).for_each(|b| *b = 0); - buf[0] = report_id; - let version = b"3.00.000"; - let start = 5; - let end = (start + version.len()).min(total_len); - buf[start..end].copy_from_slice(&version[..(end - start)]); - Some(total_len) - } - 0x05 => { - let total_len = 32.min(buf.len()); - buf.iter_mut().take(total_len).for_each(|b| *b = 0); - buf[0] = report_id; - buf[1] = 0x0c; // Length - buf[2] = 0x31; // Type - buf[3] = 0x33; // Type - buf[4] = 0x00; // Null terminator - let version = b"3.00.000"; - let start = 5; - let end = (start + version.len()).min(total_len); - buf[start..end].copy_from_slice(&version[..(end - start)]); - Some(total_len) - } + 0xA0..=0xA2 => fill_feature_v1_fw_string_report(buf, report_id, 32, FW_VER), + 0x03 => fill_feature_v1_fw_string_report( + buf, + report_id, + 32, + crate::config::USB_SERIAL.as_bytes(), + ), + 0x04 => fill_feature_rid_ascii(buf, report_id, 17, 5, FW_VER), + 0x05 => fill_feature_v1_fw_string_report(buf, report_id, 32, FW_VER), crate::config::FEATURE_REPORT_GET_IDLE_TIME => { - let total_len = 32.min(buf.len()); - buf.iter_mut().take(total_len).for_each(|b| *b = 0); + let cap = feature_report_clamp(32, buf.len()); + if cap == 0 { + return None; + } + feature_report_zero_prefix(buf, cap); buf[0] = report_id; buf[1] = 0x06; let seconds = crate::config::get_idle_time_seconds(); @@ -354,13 +297,16 @@ impl ProtocolHandlerTrait for V1Handler { buf[3] = secs_le[1]; buf[4] = secs_le[2]; buf[5] = secs_le[3]; - Some(total_len) + Some(cap) } 0x07 => { - let total_len = 16.min(buf.len()); - buf.iter_mut().take(total_len).for_each(|b| *b = 0); + let cap = feature_report_clamp(16, buf.len()); + if cap == 0 { + return None; + } + feature_report_zero_prefix(buf, cap); buf[0] = report_id; - Some(total_len) + Some(cap) } _ => None, } diff --git a/src/protocol/v2.rs b/src/protocol/v2.rs index 4915fd9..52eca15 100644 --- a/src/protocol/v2.rs +++ b/src/protocol/v2.rs @@ -1,149 +1,342 @@ -//! StreamDeck V2 Protocol Handler -//! -//! Handles Original V2, XL, MK2, and Plus devices using JPEG format +//! Stream Deck Main / Expanded protocol (V2) — JPEG image chunks, feature reports per Elgato General Reference. -use super::{ButtonMapping, OutputReportResult, ProtocolHandlerTrait}; +use super::{ + feature_report_clamp, feature_report_zero_prefix, fill_feature_v2_fw_version_report, + map_buttons_grid, ButtonMapping, OutputReportResult, ProtocolHandlerTrait, +}; use crate::config::{ IMAGE_COMMAND_V2, IMAGE_PROCESSING_BUFFER_SIZE, OUTPUT_REPORT_IMAGE, V2_COMMAND_BRIGHTNESS, V2_COMMAND_RESET, }; +use crate::device::Device; use crate::device::ProtocolVersion; use crate::protocol::module::ModuleSetCommand; +use crate::types::MAX_BUTTON_SLOTS; use heapless::Vec; -/// V2 Protocol Handler for JPEG-based StreamDeck devices +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum V2ImageKind { + Key, + FullScreen, + Window, + PartialWindow, + Background, +} + +/// Parsed 0x0C partial-window image chunk header. +#[derive(Debug, Clone, Copy)] +struct PartialWindowChunk { + x: u16, + y: u16, + w: u16, + h: u16, + is_last: bool, + chunk_index: u16, + chunk_size: u16, + data_start: usize, +} + +/// Arguments for [`V2Handler::ingest_chunk`]. +struct IngestChunkParams<'a> { + kind: V2ImageKind, + slot: u8, + partial: (u16, u16, u16, u16), + sequence: u16, + is_last: bool, + payload_len: usize, + data_start: usize, + data: &'a [u8], +} + +/// V2 / Main protocol handler (device-specific GET unit info, optional window/background transfers). #[derive(Debug)] pub struct V2Handler { + device: Device, image_buffer: Vec, - receiving_image: bool, - expected_key: u8, + receiving: bool, + kind: V2ImageKind, + /// Key index (0x07) or background index (0x0D) + slot: u8, expected_sequence: u16, + partial: (u16, u16, u16, u16), } impl V2Handler { - pub fn new() -> Self { + pub fn new(device: Device) -> Self { Self { + device, image_buffer: Vec::new(), - receiving_image: false, - expected_key: 0, + receiving: false, + kind: V2ImageKind::Key, + slot: 0, expected_sequence: 0, + partial: (0, 0, 0, 0), } } - /// Reset image reception state - fn reset_image_state(&mut self) { + fn reset_transfer(&mut self) { self.image_buffer.clear(); - self.receiving_image = false; - self.expected_key = 0; + self.receiving = false; self.expected_sequence = 0; + self.slot = 0; + self.partial = (0, 0, 0, 0); } -} -impl Default for V2Handler { - fn default() -> Self { - Self::new() + fn feature_command(data: &[u8], report_id: u8) -> Option { + if data.is_empty() { + return None; + } + if data[0] == report_id && data.len() > 1 { + Some(data[1]) + } else { + Some(data[0]) + } } -} -impl ProtocolHandlerTrait for V2Handler { - fn version(&self) -> ProtocolVersion { - ProtocolVersion::V2 + /// Parse 0x07 / 0x08 / 0x0B style: cmd, byte2, is_last, size_le, index_le, payload@8 + fn parse_standard_image_chunk(data: &[u8]) -> Option<(u8, u8, bool, u16, u16, usize)> { + if data.len() < 8 { + return None; + } + let cmd = data[0]; + let b2 = data[1]; + let is_last = data[2] != 0; + let size = u16::from_le_bytes([data[3], data[4]]); + let index = u16::from_le_bytes([data[5], data[6]]); + Some((cmd, b2, is_last, size, index, 7)) } - fn parse_output_report(&mut self, data: &[u8]) -> OutputReportResult { + /// Background 0x0D: chunk index @3-4, size @5-6 (swapped vs key image) + fn parse_background_chunk(data: &[u8]) -> Option<(u8, bool, u16, u16, usize)> { if data.len() < 8 { - return OutputReportResult::Unhandled; + return None; } + let bg_index = data[1]; + let is_last = data[2] != 0; + let chunk_index = u16::from_le_bytes([data[3], data[4]]); + let chunk_size = u16::from_le_bytes([data[5], data[6]]); + Some((bg_index, is_last, chunk_index, chunk_size, 7)) + } - // V2 Output Report: Command 0x07 (key), 0x08 (full LCD), 0x09 (boot logo) - // Key image format primary: [0x02, 0x07, key_id, is_last, len_lo, len_hi, seq_lo, seq_hi, data...] - // Some HID stacks strip the report ID before delivering data to set_report. Accept both forms. - let (cmd, key_id, is_last, payload_len, sequence, data_start) = - if data[0] == OUTPUT_REPORT_IMAGE { - let cmd = data[1]; - if cmd == IMAGE_COMMAND_V2 { - ( - cmd, - data[2], - data[3] != 0, - u16::from_le_bytes([data[4], data[5]]), - u16::from_le_bytes([data[6], data[7]]), - 8, - ) - } else { - (cmd, 0, false, 0, 0, 0) - } - } else if data[0] == IMAGE_COMMAND_V2 && data.len() >= 7 { - // Missing report ID (0x02) case for 0x07 - ( - IMAGE_COMMAND_V2, - data[1], - data[2] != 0, - u16::from_le_bytes([data[3], data[4]]), - u16::from_le_bytes([data[5], data[6]]), - 7, - ) - } else { - return OutputReportResult::Unhandled; - }; - - if cmd != IMAGE_COMMAND_V2 { - // For now, only branch key updates. Full screen / boot logo recognized but not assembled here. - return match cmd { - 0x08 => OutputReportResult::FullScreenImageChunk, - 0x09 => OutputReportResult::BootLogoImageChunk, - _ => OutputReportResult::Unhandled, - }; + /// Partial window 0x0C + fn parse_partial_chunk(data: &[u8]) -> Option { + if data.len() < 0x11 { + return None; } + let x = u16::from_le_bytes([data[1], data[2]]); + let y = u16::from_le_bytes([data[3], data[4]]); + let w = u16::from_le_bytes([data[5], data[6]]); + let h = u16::from_le_bytes([data[7], data[8]]); + let is_last = data[9] != 0; + let chunk_index = u16::from_le_bytes([data[10], data[11]]); + let chunk_size = u16::from_le_bytes([data[12], data[13]]); + Some(PartialWindowChunk { + x, + y, + w, + h, + is_last, + chunk_index, + chunk_size, + data_start: 0x10, + }) + } + + fn ingest_chunk(&mut self, p: IngestChunkParams<'_>) -> OutputReportResult { + let IngestChunkParams { + kind, + slot, + partial, + sequence, + is_last, + payload_len, + data_start, + data, + } = p; - // First packet (sequence 0) starts image reception if sequence == 0 { - self.reset_image_state(); - self.receiving_image = true; - self.expected_key = key_id; + self.reset_transfer(); + self.receiving = true; + self.kind = kind; + self.slot = slot; + self.partial = partial; self.expected_sequence = 0; } - // Validate sequence and key - if !self.receiving_image - || key_id != self.expected_key + if !self.receiving + || self.kind != kind + || self.slot != slot + || (kind == V2ImageKind::PartialWindow && self.partial != partial) || sequence != self.expected_sequence { - // Reset and ignore to keep host happy - self.reset_image_state(); + self.reset_transfer(); return OutputReportResult::Unhandled; } - // Copy payload data - let copy_len = (payload_len as usize).min(data.len() - data_start); - + let copy_len = payload_len.min(data.len().saturating_sub(data_start)); if copy_len > 0 && self .image_buffer .extend_from_slice(&data[data_start..data_start + copy_len]) .is_err() { - self.reset_image_state(); + self.reset_transfer(); return OutputReportResult::Unhandled; } - self.expected_sequence += 1; + self.expected_sequence = self.expected_sequence.wrapping_add(1); if is_last { - // Image complete - let mut complete_image = Vec::new(); - let _ = complete_image.extend_from_slice(&self.image_buffer); - let completed_key = self.expected_key; - self.reset_image_state(); - - OutputReportResult::KeyImageComplete { - key_id: completed_key, - image: complete_image, + let mut out = Vec::new(); + let _ = out.extend_from_slice(&self.image_buffer); + self.reset_transfer(); + match kind { + V2ImageKind::Key => OutputReportResult::KeyImageComplete { + key_id: slot, + image: out, + }, + V2ImageKind::FullScreen => { + OutputReportResult::FullScreenImageComplete { image: out } + } + V2ImageKind::Window => OutputReportResult::WindowImageComplete { image: out }, + V2ImageKind::PartialWindow => OutputReportResult::PartialWindowImageComplete { + x: partial.0, + y: partial.1, + width: partial.2, + height: partial.3, + image: out, + }, + V2ImageKind::Background => OutputReportResult::BackgroundImageComplete { + index: slot, + image: out, + }, } } else { OutputReportResult::Unhandled } } +} + +impl ProtocolHandlerTrait for V2Handler { + fn version(&self) -> ProtocolVersion { + ProtocolVersion::V2 + } + + fn parse_output_report(&mut self, data: &[u8]) -> OutputReportResult { + if data.len() < 2 { + return OutputReportResult::Unhandled; + } + + let payload = if data[0] == OUTPUT_REPORT_IMAGE { + &data[1..] + } else { + data + }; + + if payload.is_empty() { + return OutputReportResult::Unhandled; + } + + let cmd = payload[0]; + + match cmd { + IMAGE_COMMAND_V2 => { + let Some((_, key_id, is_last, psize, seq, start)) = + Self::parse_standard_image_chunk(payload) + else { + return OutputReportResult::Unhandled; + }; + self.ingest_chunk(IngestChunkParams { + kind: V2ImageKind::Key, + slot: key_id, + partial: (0, 0, 0, 0), + sequence: seq, + is_last, + payload_len: psize as usize, + data_start: start, + data: payload, + }) + } + 0x08 => { + let Some((_, _res, is_last, psize, seq, start)) = + Self::parse_standard_image_chunk(payload) + else { + return OutputReportResult::Unhandled; + }; + self.ingest_chunk(IngestChunkParams { + kind: V2ImageKind::FullScreen, + slot: 0, + partial: (0, 0, 0, 0), + sequence: seq, + is_last, + payload_len: psize as usize, + data_start: start, + data: payload, + }) + } + 0x0B if self.device.supports_window_image_commands() => { + let Some((_, _res, is_last, psize, seq, start)) = + Self::parse_standard_image_chunk(payload) + else { + return OutputReportResult::Unhandled; + }; + self.ingest_chunk(IngestChunkParams { + kind: V2ImageKind::Window, + slot: 0, + partial: (0, 0, 0, 0), + sequence: seq, + is_last, + payload_len: psize as usize, + data_start: start, + data: payload, + }) + } + 0x0C if self.device.supports_window_image_commands() => { + let Some(PartialWindowChunk { + x, + y, + w, + h, + is_last, + chunk_index, + chunk_size, + data_start: start, + }) = Self::parse_partial_chunk(payload) + else { + return OutputReportResult::Unhandled; + }; + self.ingest_chunk(IngestChunkParams { + kind: V2ImageKind::PartialWindow, + slot: 0, + partial: (x, y, w, h), + sequence: chunk_index, + is_last, + payload_len: chunk_size as usize, + data_start: start, + data: payload, + }) + } + 0x0D if self.device.supports_background_feature() => { + let Some((bg_index, is_last, seq, psize, start)) = + Self::parse_background_chunk(payload) + else { + return OutputReportResult::Unhandled; + }; + self.ingest_chunk(IngestChunkParams { + kind: V2ImageKind::Background, + slot: bg_index, + partial: (0, 0, 0, 0), + sequence: seq, + is_last, + payload_len: psize as usize, + data_start: start, + data: payload, + }) + } + 0x09 => OutputReportResult::BootLogoImageChunk, + _ => OutputReportResult::Unhandled, + } + } fn map_buttons( &self, @@ -152,123 +345,79 @@ impl ProtocolHandlerTrait for V2Handler { rows: usize, left_to_right: bool, ) -> ButtonMapping { - let mut mapped_buttons = [false; 32]; - let total_keys = cols * rows; - - // V2 devices generally use left-to-right mapping - for (physical_idx, &pressed) in physical_buttons.iter().take(total_keys).enumerate() { - let mapped_idx = if left_to_right { - physical_idx - } else { - // Right-to-left if needed (rare for V2 devices) - let row = physical_idx / cols; - let col = physical_idx % cols; - let reversed_col = cols - 1 - col; - row * cols + reversed_col - }; - - if mapped_idx < 32 { - mapped_buttons[mapped_idx] = pressed; - } - } - - ButtonMapping { - mapped_buttons, - active_count: total_keys, - } + map_buttons_grid(physical_buttons, cols, rows, left_to_right) } fn hid_descriptor(&self) -> &'static [u8] { - // V2 StreamDeck HID descriptor (similar to V1 but optimized for V2 protocol) - &[ - 0x05, 0x0c, // Usage Page (Consumer) - 0x09, 0x01, // Usage (Consumer Control) - 0xa1, 0x01, // Collection (Application) - 0x09, 0x01, // Usage (Consumer Control) - 0x05, 0x09, // Usage Page (Button) - 0x19, 0x01, // Usage Minimum (0x01) - 0x29, 0x20, // Usage Maximum (0x20) - Support up to 32 buttons - 0x15, 0x00, // Logical Minimum (0) - 0x26, 0xff, 0x00, // Logical Maximum (255) - 0x75, 0x08, // Report Size (8) - 0x95, 0x20, // Report Count (32) - Support up to 32 buttons - 0x85, 0x01, // Report ID (0x01) - 0x81, 0x02, // Input (Data,Var,Abs) - 0x0a, 0x00, 0xff, // Usage (Button 255) - 0x15, 0x00, // Logical Minimum (0) - 0x26, 0xff, 0x00, // Logical Maximum (255) - 0x75, 0x08, // Report Size (8) - 0x96, 0x00, 0x04, // Report Count (1024) - Standard packet size - 0x85, 0x02, // Report ID (0x02) - 0x91, 0x02, // Output (Data,Var,Abs) - 0x0a, 0x00, 0xff, // Usage (Button 255) - 0x15, 0x00, // Logical Minimum (0) - 0x26, 0xff, 0x00, // Logical Maximum (255) - 0x75, 0x08, // Report Size (8) - 0x95, 0x20, // Report Count (32) - 0x85, 0x03, // Report ID (0x03) - 0xb1, 0x04, // Feature (Data,Array,Rel) - 0x0a, 0x00, 0xff, // Usage (Button 255) - 0x15, 0x00, // Logical Minimum (0) - 0x26, 0xff, 0x00, // Logical Maximum (255) - 0x75, 0x08, // Report Size (8) - 0x95, 0x20, // Report Count (32) - 0x85, 0x04, // Report ID (0x04) - 0xb1, 0x04, // Feature (Data,Array,Rel) - 0x0a, 0x00, 0xff, // Usage (Button 255) - 0x15, 0x00, // Logical Minimum (0) - 0x26, 0xff, 0x00, // Logical Maximum (255) - 0x75, 0x08, // Report Size (8) - 0x95, 0x20, // Report Count (32) - 0x85, 0x05, // Report ID (0x05) - 0xb1, 0x04, // Feature (Data,Array,Rel) - 0xc0, // End Collection - ] + const DESC: &[u8] = &[ + 0x05, 0x0c, 0x09, 0x01, 0xa1, 0x01, 0x09, 0x01, 0x05, 0x09, 0x19, 0x01, 0x29, 0x30, + 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x30, 0x85, 0x01, 0x81, 0x02, 0x0a, + 0x00, 0xff, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x96, 0x00, 0x04, 0x85, 0x02, + 0x91, 0x02, 0x0a, 0x00, 0xff, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x20, + 0x85, 0x03, 0xb1, 0x04, 0x0a, 0x00, 0xff, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, + 0x95, 0x20, 0x85, 0x04, 0xb1, 0x04, 0x0a, 0x00, 0xff, 0x15, 0x00, 0x26, 0xff, 0x00, + 0x75, 0x08, 0x95, 0x20, 0x85, 0x05, 0xb1, 0x04, 0x0a, 0x00, 0xff, 0x15, 0x00, 0x26, + 0xff, 0x00, 0x75, 0x08, 0x95, 0x20, 0x85, 0x06, 0xb1, 0x04, 0x0a, 0x00, 0xff, 0x15, + 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x20, 0x85, 0x07, 0xb1, 0x04, 0x0a, 0x00, + 0xff, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x20, 0x85, 0x08, 0xb1, 0x04, + 0x0a, 0x00, 0xff, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x20, 0x85, 0x0a, + 0xb1, 0x04, 0xc0, + ]; + DESC } fn input_report_size(&self, button_count: usize) -> usize { - // V2 input reports: 3-byte header + button states - 3 + button_count + // Report ID 0x01 + Command + UINT16 length + payload (per General Reference) + 1 + 1 + 2 + button_count } fn format_button_report(&self, buttons: &ButtonMapping, report: &mut [u8]) -> usize { - if report.len() < 4 { + let n = buttons + .active_count + .min(MAX_BUTTON_SLOTS) + .min(report.len().saturating_sub(4)); + if report.len() < 4 + n { return 0; } - - // V2 format: [header_bytes, button_states...] - report[0] = 0x00; // Header byte 1 - report[1] = 0x00; // Header byte 2 - report[2] = 0x00; // Header byte 3 - - let button_bytes = (buttons.active_count).min(report.len() - 3); - for i in 0..button_bytes { - report[i + 3] = if buttons.mapped_buttons[i] { 1 } else { 0 }; + report[0] = 0x01; + report[1] = 0x00; + let len = n as u16; + report[2] = (len & 0xff) as u8; + report[3] = ((len >> 8) & 0xff) as u8; + for i in 0..n { + report[4 + i] = if buttons.mapped_buttons[i] { 1 } else { 0 }; } - - // Fill remaining bytes with 0 - for b in report.iter_mut().skip(button_bytes + 3) { + for b in report.iter_mut().skip(4 + n) { *b = 0; } - - 3 + button_bytes + 4 + n } fn handle_feature_report(&mut self, report_id: u8, data: &[u8]) -> Option { - if report_id == 0x03 && data.len() >= 2 { - // V2 commands: [0x03, command_byte, ...] - match data[1] { - V2_COMMAND_RESET => { - // V2 Reset: [0x03, 0x02, ...] - Some(ModuleSetCommand::Reset) + if report_id == 0x03 { + let cmd = Self::feature_command(data, 0x03)?; + match cmd { + V2_COMMAND_RESET => Some(ModuleSetCommand::Reset), + 0x05 if data.len() >= 5 => Some(ModuleSetCommand::FillLcdColor { + r: data[2], + g: data[3], + b: data[4], + }), + 0x06 if data.len() >= 6 => Some(ModuleSetCommand::SetKeyColor { + key_index: data[2], + r: data[3], + g: data[4], + b: data[5], + }), + V2_COMMAND_BRIGHTNESS if data.len() >= 3 => { + Some(ModuleSetCommand::SetBrightness { value: data[2] }) } - V2_COMMAND_BRIGHTNESS => { - // V2 Brightness: [0x03, 0x08, brightness, ...] - if data.len() >= 3 { - Some(ModuleSetCommand::SetBrightness { value: data[2] }) - } else { - None - } + 0x0D if data.len() >= 6 => { + let secs = i32::from_le_bytes([data[2], data[3], data[4], data[5]]); + Some(ModuleSetCommand::SetIdleTime { seconds: secs }) + } + 0x13 if self.device.supports_background_feature() && data.len() >= 3 => { + Some(ModuleSetCommand::ShowBackgroundByIndex { index: data[2] }) } _ => None, } @@ -278,49 +427,91 @@ impl ProtocolHandlerTrait for V2Handler { } fn get_feature_report(&mut self, report_id: u8, buf: &mut [u8]) -> Option { + const FW_VER: &[u8] = b"3.00.000"; + let total_len = 32.min(buf.len()); match report_id { - 0xA0..=0xA2 => { - let total_len = 32.min(buf.len()); - buf.iter_mut().take(total_len).for_each(|b| *b = 0); - buf[0] = report_id; - buf[1] = 0x0c; // Length - buf[2] = 0x31; // Type - buf[3] = 0x33; // Type - buf[4] = 0x00; // Null terminator - let version = b"3.00.000"; - let start = 5; - let end = (start + version.len()).min(total_len); - buf[start..end].copy_from_slice(&version[..(end - start)]); - Some(total_len) + 0x04 | 0x05 | 0x07 => { + fill_feature_v2_fw_version_report(buf, report_id, total_len, FW_VER) } - 0x03 => { - let total_len = 32.min(buf.len()); - buf.iter_mut().take(total_len).for_each(|b| *b = 0); - buf[0] = report_id; - buf[1] = 0x0c; // Length - buf[2] = 0x31; // Type - buf[3] = 0x33; // Type - buf[4] = 0x00; // Null terminator + 0x06 => { + let cap = feature_report_clamp(total_len, buf.len()); + if cap == 0 { + return None; + } + feature_report_zero_prefix(buf, cap); + buf[0] = 0x06; let serial = crate::config::USB_SERIAL.as_bytes(); - let start = 5; - let end = (start + serial.len()).min(total_len); + let dl = core::cmp::min(serial.len(), 14) as u8; + buf[1] = dl; + let start = 2usize; + let end = (start + dl as usize).min(cap); buf[start..end].copy_from_slice(&serial[..(end - start)]); - Some(total_len) + Some(cap) } - crate::config::FEATURE_REPORT_GET_IDLE_TIME => { - let total_len = 32.min(buf.len()); - buf.iter_mut().take(total_len).for_each(|b| *b = 0); - buf[0] = report_id; - buf[1] = 0x06; + 0x08 => { + let cap = feature_report_clamp(total_len, buf.len()); + if cap == 0 { + return None; + } + feature_report_zero_prefix(buf, cap); + buf[0] = 0x08; + let tail = self.device.unit_information_tail(); + let copy = core::cmp::min(tail.len(), cap.saturating_sub(1)); + buf[1..1 + copy].copy_from_slice(&tail[..copy]); + Some(cap) + } + 0x0A => { + let cap = feature_report_clamp(total_len, buf.len()); + if cap == 0 { + return None; + } + feature_report_zero_prefix(buf, cap); + buf[0] = 0x0A; + buf[1] = 0x04; let seconds = crate::config::get_idle_time_seconds(); - let secs_le = seconds.to_le_bytes(); - buf[2] = secs_le[0]; - buf[3] = secs_le[1]; - buf[4] = secs_le[2]; - buf[5] = secs_le[3]; - Some(total_len) + let le = seconds.to_le_bytes(); + buf[2..6].copy_from_slice(&le); + Some(cap) } _ => None, } } } + +impl V2Handler { + /// Main protocol input: touch **TAP** (command `0x02`, payload length `0x0A` per Elgato + / + XL docs). + /// Writes report ID `0x01` at `[0]`, then command, length, and fields. + pub fn format_input_touch_tap(x: u16, y: u16, out: &mut [u8]) -> usize { + if out.len() < 10 { + return 0; + } + out[0] = 0x01; + out[1] = 0x02; + out[2] = 0x0a; + out[3] = 0x00; + out[4] = 0x01; + out[5] = 0x00; + out[6..8].copy_from_slice(&x.to_le_bytes()); + out[8..10].copy_from_slice(&y.to_le_bytes()); + 10 + } + + /// Main protocol input: encoder **ROTATE** (`0x03`, sub-type `0x01`, `ticks` i8 per encoder). + pub fn format_input_encoder_rotate(ticks: &[i8], out: &mut [u8]) -> usize { + let n = ticks.len().min(8); + let need = 5 + n; + if out.len() < need { + return 0; + } + let plen = (n + 1) as u16; + out[0] = 0x01; + out[1] = 0x03; + out[2] = (plen & 0xff) as u8; + out[3] = ((plen >> 8) & 0xff) as u8; + out[4] = 0x01; + for (i, &t) in ticks.iter().take(n).enumerate() { + out[5 + i] = t as u8; + } + need + } +} diff --git a/src/supervisor.rs b/src/supervisor.rs index c6390a2..1423386 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -3,7 +3,6 @@ //! This module provides application-level supervision, monitoring, //! and lifecycle management functionality. -use crate::config; use crate::device::{Device, DeviceConfig}; use crate::types::APP_VERSION; use defmt::*; @@ -17,11 +16,6 @@ pub struct AppSupervisor { } impl AppSupervisor { - /// Create a new application supervisor - pub fn new() -> Self { - Self::new_for_device(config::get_current_device()) - } - /// Create a new application supervisor for a specific device pub fn new_for_device(device: Device) -> Self { Self { @@ -31,6 +25,11 @@ impl AppSupervisor { } } + /// Selected build [`Device`] (same as passed to [`Self::new_for_device`]). + pub fn device(&self) -> Device { + self.device + } + /// Print application startup banner with device information pub fn print_startup_banner(&self) { let device = self.device; @@ -108,9 +107,3 @@ impl AppSupervisor { self.uptime_seconds } } - -impl Default for AppSupervisor { - fn default() -> Self { - Self::new() - } -} diff --git a/src/types.rs b/src/types.rs index 30b2e29..26aa4b6 100644 --- a/src/types.rs +++ b/src/types.rs @@ -6,12 +6,15 @@ use crate::config::IMAGE_BUFFER_SIZE; use heapless::Vec; +/// Maximum logical keys / protocol slots (Stream Deck + XL has 36 keys) +pub const MAX_BUTTON_SLOTS: usize = 48; + /// Button state structure for communicating button presses between tasks #[derive(Clone, Copy, Debug, defmt::Format)] pub struct ButtonState { /// Array of button states - true if pressed, false if released /// Using fixed size for compatibility across all devices - pub buttons: [bool; 32], // Max buttons for any StreamDeck device (XL has 32) + pub buttons: [bool; MAX_BUTTON_SLOTS], /// Whether any button state has changed since last report pub changed: bool, /// Number of active buttons for this device @@ -22,9 +25,9 @@ impl ButtonState { /// Create new button state with all buttons released pub fn new(active_count: usize) -> Self { Self { - buttons: [false; 32], + buttons: [false; MAX_BUTTON_SLOTS], changed: false, - active_count: active_count.min(32), + active_count: active_count.min(MAX_BUTTON_SLOTS), } } @@ -46,6 +49,37 @@ impl ButtonState { } } +/// Touch activity on Stream Deck + / + XL window (host protocol; reserved for hardware) +#[derive(Clone, Copy, Debug, defmt::Format)] +pub enum TouchActivityKind { + Tap, + Press, + Flick, +} + +#[derive(Clone, Copy, Debug, defmt::Format)] +pub struct TouchActivity { + pub kind: TouchActivityKind, + pub x: u16, + pub y: u16, + pub x2: u16, + pub y2: u16, +} + +/// Encoder activity (Stream Deck + / + XL; reserved for hardware) +#[derive(Clone, Copy, Debug, defmt::Format)] +pub enum EncoderActivityKind { + Button, + Rotate, +} + +#[derive(Clone, Copy, Debug, defmt::Format)] +pub struct EncoderActivity { + pub kind: EncoderActivityKind, + pub states: [i8; 8], + pub count: usize, +} + /// USB commands that can be sent from the HID handler to other tasks #[derive(Clone, Debug)] #[allow(clippy::large_enum_variant)] @@ -60,6 +94,51 @@ pub enum UsbCommand { #[allow(clippy::large_enum_variant)] data: Vec, }, + /// Full-screen JPEG assembled (reserved for display pipeline) + FullScreenImage { + #[allow(clippy::large_enum_variant)] + data: Vec, + }, + /// Window strip JPEG (Neo / + / + XL) + WindowImage { + #[allow(clippy::large_enum_variant)] + data: Vec, + }, + /// Partial window update (reserved) + PartialWindowImage { + x: u16, + y: u16, + width: u16, + height: u16, + #[allow(clippy::large_enum_variant)] + data: Vec, + }, + /// Background slot image (Classic / XL) + BackgroundImage { + index: u8, + #[allow(clippy::large_enum_variant)] + data: Vec, + }, + /// Fill entire LCD with RGB (feature report) + FillLcdColor { + r: u8, + g: u8, + b: u8, + }, + /// Fill one key with RGB (feature report) + FillKeyColor { + key_index: u8, + r: u8, + g: u8, + b: u8, + }, + /// Show stored background by index (XL family feature 0x03 / 0x13) + ShowBackgroundByIndex { + index: u8, + }, + /// Touch / encoder events for future wiring (no-op in USB task until implemented) + TouchActivity(TouchActivity), + EncoderActivity(EncoderActivity), } /// Display commands for controlling the display subsystem @@ -78,6 +157,27 @@ pub enum DisplayCommand { #[allow(clippy::large_enum_variant)] data: Vec, }, + /// Full LCD image (JPEG assembled) + DisplayFullScreen { + #[allow(clippy::large_enum_variant)] + data: Vec, + }, + /// Window / info strip image + DisplayWindow { + #[allow(clippy::large_enum_variant)] + data: Vec, + }, + FillLcd { + r: u8, + g: u8, + b: u8, + }, + FillKey { + key_index: u8, + r: u8, + g: u8, + b: u8, + }, } /// Application version information diff --git a/src/usb.rs b/src/usb.rs index 96e57b6..4f73dba 100644 --- a/src/usb.rs +++ b/src/usb.rs @@ -20,6 +20,59 @@ use embassy_usb::class::hid::{ use embassy_usb::control::OutResponse; use embassy_usb::{Builder, Config}; +type UsbCommandSender = embassy_sync::channel::Sender< + 'static, + embassy_sync::blocking_mutex::raw::ThreadModeRawMutex, + UsbCommand, + 4, +>; + +/// Send assembled output-report payloads to the USB command channel (shared by HID paths). +fn dispatch_output_report_result(result: OutputReportResult, sender: &UsbCommandSender) { + match result { + OutputReportResult::KeyImageComplete { key_id, image } => { + info!("Image complete for key {} ({} bytes)", key_id, image.len()); + let _ = sender.try_send(UsbCommand::ImageData { + key_id, + data: image, + }); + } + OutputReportResult::FullScreenImageComplete { image } => { + let _ = sender.try_send(UsbCommand::FullScreenImage { data: image }); + } + OutputReportResult::WindowImageComplete { image } => { + let _ = sender.try_send(UsbCommand::WindowImage { data: image }); + } + OutputReportResult::PartialWindowImageComplete { + x, + y, + width, + height, + image, + } => { + let _ = sender.try_send(UsbCommand::PartialWindowImage { + x, + y, + width, + height, + data: image, + }); + } + OutputReportResult::BackgroundImageComplete { index, image } => { + let _ = sender.try_send(UsbCommand::BackgroundImage { index, data: image }); + } + OutputReportResult::FullScreenImageChunk => { + debug!("Full screen image chunk received (not assembled)"); + } + OutputReportResult::BootLogoImageChunk => { + debug!("Boot logo image chunk received (not assembled)"); + } + OutputReportResult::Unhandled => { + debug!("Unhandled output report"); + } + } +} + // =================================================================== // USB Configuration // =================================================================== @@ -59,8 +112,7 @@ struct StreamDeckHidHandler { impl StreamDeckHidHandler { fn new_for_device(device: Device) -> Self { - let protocol_version = device.usb_config().protocol; - let protocol_handler = ProtocolHandler::create(protocol_version); + let protocol_handler = ProtocolHandler::create_for_device(device); Self { protocol_handler, @@ -108,6 +160,26 @@ impl RequestHandler for StreamDeckHidHandler { crate::config::set_idle_time_seconds(seconds); info!("Set idle time to {} seconds", seconds); } + ModuleSetCommand::FillLcdColor { r, g, b } => { + let _ = self.usb_command_sender.try_send(UsbCommand::FillLcdColor { + r, + g, + b, + }); + } + ModuleSetCommand::SetKeyColor { key_index, r, g, b } => { + let _ = self.usb_command_sender.try_send(UsbCommand::FillKeyColor { + key_index, + r, + g, + b, + }); + } + ModuleSetCommand::ShowBackgroundByIndex { index } => { + let _ = self + .usb_command_sender + .try_send(UsbCommand::ShowBackgroundByIndex { index }); + } _ => {} } } @@ -132,24 +204,10 @@ impl StreamDeckHidHandler { ); } - match self.protocol_handler.parse_output_report(data) { - OutputReportResult::KeyImageComplete { key_id, image } => { - info!("Image complete for key {} ({} bytes)", key_id, image.len()); - let _ = self.usb_command_sender.try_send(UsbCommand::ImageData { - key_id, - data: image, - }); - } - OutputReportResult::FullScreenImageChunk => { - debug!("Full screen image chunk received (not assembled)"); - } - OutputReportResult::BootLogoImageChunk => { - debug!("Boot logo image chunk received (not assembled)"); - } - OutputReportResult::Unhandled => { - debug!("Unhandled output report"); - } - } + dispatch_output_report_result( + self.protocol_handler.parse_output_report(data), + &self.usb_command_sender, + ); } } @@ -157,11 +215,6 @@ impl StreamDeckHidHandler { // USB Task Implementation // =================================================================== -#[embassy_executor::task] -pub async fn usb_task(driver: Driver<'static, peripherals::USB>, usb_led: Output<'static>) { - usb_task_impl(driver, usb_led, config::get_current_device()).await -} - #[embassy_executor::task] pub async fn usb_task_for_device( driver: Driver<'static, peripherals::USB>, @@ -214,7 +267,7 @@ async fn usb_task_impl( } // Get HID descriptor from protocol handler - let protocol_handler = ProtocolHandler::create(device.usb_config().protocol); + let protocol_handler = ProtocolHandler::create_for_device(device); let hid_descriptor = protocol_handler.hid_descriptor(); let hid_config = HidConfig { @@ -270,13 +323,63 @@ async fn usb_task_impl( key_id, data.len() ); - // Send to core 1 for processing via inter-core channel - // TODO: Replace with actual inter-core channel when implemented let _ = DISPLAY_CHANNEL .sender() .send(DisplayCommand::DisplayImage { key_id, data }) .await; } + UsbCommand::FullScreenImage { data } => { + let _ = DISPLAY_CHANNEL + .sender() + .send(DisplayCommand::DisplayFullScreen { data }) + .await; + } + UsbCommand::WindowImage { data } => { + let _ = DISPLAY_CHANNEL + .sender() + .send(DisplayCommand::DisplayWindow { data }) + .await; + } + UsbCommand::PartialWindowImage { + x, + y, + width, + height, + data, + } => { + debug!( + "Partial window {}x{}@{},{} ({} bytes)", + width, + height, + x, + y, + data.len() + ); + } + UsbCommand::BackgroundImage { index, data } => { + debug!("Background {} ({} bytes)", index, data.len()); + } + UsbCommand::FillLcdColor { r, g, b } => { + let _ = DISPLAY_CHANNEL + .sender() + .send(DisplayCommand::FillLcd { r, g, b }) + .await; + } + UsbCommand::FillKeyColor { key_index, r, g, b } => { + let _ = DISPLAY_CHANNEL + .sender() + .send(DisplayCommand::FillKey { key_index, r, g, b }) + .await; + } + UsbCommand::ShowBackgroundByIndex { index } => { + debug!("Show background index {}", index); + } + UsbCommand::TouchActivity(t) => { + debug!("Touch activity: {:?}", t); + } + UsbCommand::EncoderActivity(e) => { + debug!("Encoder activity: {:?}", e); + } } } }; @@ -284,10 +387,10 @@ async fn usb_task_impl( // Spawn combined IO future: send button reports and read OUT image packets let io_fut = async { let receiver = BUTTON_CHANNEL.receiver(); - let protocol_handler = ProtocolHandler::create(device.usb_config().protocol); + let protocol_handler = ProtocolHandler::create_for_device(device); // OUT image reader protocol state - let mut out_protocol = ProtocolHandler::create(device.usb_config().protocol); + let mut out_protocol = ProtocolHandler::create_for_device(device); let mut out_buf = [0u8; 4096]; // Button sender loop @@ -297,12 +400,15 @@ async fn usb_task_impl( if button_state.changed { let layout = device.button_layout(); - let button_mapping = protocol_handler.map_buttons( + let mut button_mapping = protocol_handler.map_buttons( &button_state.buttons, layout.cols, layout.rows, layout.left_to_right, ); + button_mapping.active_count = device + .protocol_input_key_count() + .min(crate::types::MAX_BUTTON_SLOTS); let mut report = [0u8; 64]; // RP2040 USB hardware limitation let report_len = @@ -329,21 +435,8 @@ async fn usb_task_impl( Ok(n) => { let data = &out_buf[..n]; if !data.is_empty() { - match out_protocol.parse_output_report(data) { - OutputReportResult::KeyImageComplete { key_id, image } => { - let img_len = image.len(); - let _ = USB_COMMAND_CHANNEL.sender().try_send( - UsbCommand::ImageData { - key_id, - data: image, - }, - ); - info!("Image complete for key {} ({} bytes)", key_id, img_len); - } - OutputReportResult::FullScreenImageChunk => {} - OutputReportResult::BootLogoImageChunk => {} - OutputReportResult::Unhandled => {} - } + let result = out_protocol.parse_output_report(data); + dispatch_output_report_result(result, &USB_COMMAND_CHANNEL.sender()); } } Err(e) => {