diff --git a/.agents/plans/in-progress/004-support-display-fs-v1-3-5-inch.md b/.agents/plans/in-progress/004-support-display-fs-v1-3-5-inch.md new file mode 100644 index 0000000..035af7f --- /dev/null +++ b/.agents/plans/in-progress/004-support-display-fs-v1-3-5-inch.md @@ -0,0 +1,49 @@ +# Plan: Support Display FS V1 (3.5 inch) + +Status: IN-PROGRESS + +## Goal + +Add first-class support for the 3.5-inch Display FS V1 in the CLI, including detection, resolution handling, protocol parity, and documentation, while keeping the 0.96-inch flow intact. + +## Tasks + +- [ ] **Task 1: Model detection + config baseline** + - Scope: `src/port.rs`, `src/protocol.rs`, `src/main.rs` + - Depends on: none + - Acceptance: + - CLI can identify which display type is connected (0.96 vs 3.5) using VID/PID or port description + - A single configuration struct holds model-specific defaults (resolution, baud rate, orientation) + - Notes: Add a `DisplayModel` enum and extend detection to use port description hints ("AB"/"AD") in addition to VID/PID. + +- [ ] **Task 2: Protocol parity for 3.5-inch commands** + - Scope: `src/protocol.rs` + - Depends on: Task 1 + - Acceptance: + - Implement read/write helpers for WHO_AM_I, brightness, and unconnect settings + - Add humiture report command handling (enable/read) with a typed response + - Notes: Align command bytes with WeAct protocol v1.1 and keep them model-agnostic where possible. + +- [ ] **Task 3: Image pipeline for large display** + - Scope: `src/image.rs`, `src/text.rs`, `src/protocol.rs` + - Depends on: Task 1 + - Acceptance: + - Rendering handles 320x480 and 480x320 orientations without clipping + - Bitmap writes support chunked writes sized for the active model + - Notes: Consider optional FastLZ support as follow-up if performance is inadequate. + +- [ ] **Task 4: CLI options + UX updates** + - Scope: `src/main.rs`, `README.md`, `site/index.html`, `site/styles.css` + - Depends on: Task 1 + - Acceptance: + - CLI flags allow forcing a model and baud rate override + - Help text documents the 3.5-inch usage and sensor availability + - Notes: Keep defaults auto-detect; only require manual selection when detection is ambiguous. + +- [ ] **Task 5: Tests + docs refresh** + - Scope: `src/port.rs`, `README.md`, `AGENTS.md`, `.agents/research/weact-display-fs-v1-3-5-research.md` + - Depends on: Task 2 + - Acceptance: + - Unit tests cover model detection paths and protocol command enums + - Documentation clearly describes both devices and the plan forward + - Notes: Update plan status to COMPLETED once verification passes. diff --git a/.agents/research/weact-display-fs-v1-3-5-research.md b/.agents/research/weact-display-fs-v1-3-5-research.md new file mode 100644 index 0000000..1226c38 --- /dev/null +++ b/.agents/research/weact-display-fs-v1-3-5-research.md @@ -0,0 +1,118 @@ +# Research: WeAct Display FS V1 (3.5 inch) + +**Date:** 2026-02-11 +**Status:** Complete +**Tags:** display-fs, weact, usb-cdc, protocol, rust, python + +## Summary + +The 3.5-inch Display FS V1 is a USB 2.0 Full Speed CDC device with a 320x480 RGB565 IPS LCD and on-board humidity/temperature sensor. WeAct publishes a Python System Monitor app (forked from Turing Smart Screen) with protocol and implementation details, and a SourceForge protocol spreadsheet describing the full command set (write/read/auto-report). The display shares the same command protocol as the 0.96-inch version, but differs in resolution, baud rate (1,152,000 in reference code), and adds a humiture auto-report command. + +## Key Learnings + +- Official WeAct docs list the 3.5" model as 320x480 RGB565 with USB2.0 FS (CDC) and a humidity/temperature sensor; the 0.96" is 80x160 RGB565 with no sensor. +- Reference Python code auto-detects the device by scanning the port description for "AB" (3.5") and "AD" (0.96") and sets the resolution accordingly. +- The reference Python app opens the serial port at **1,152,000 baud** for both devices. +- The WeAct protocol spreadsheet defines command bytes, packet lengths, read responses, and humiture auto-report format. +- Image transfer supports raw RGB565 payloads or a FastLZ-compressed variant (4k chunking) with 500ms timeout per chunk. + +## Details + +### Hardware Specs (WeAct Studio) + +From WeAct Studio System Monitor README / SourceForge: + +- **Display FS V1 (3.5 inch)** + - Resolution: **320 x 480** RGB565 + - Communication: **USB2.0 FS (CDC)** + - Sensor: **Humidity + Temperature** + - Size: **58.5mm x 87.7mm x 9.0mm** +- **Display FS V1 (0.96 inch)** + - Resolution: **80 x 160** RGB565 + - Communication: **USB2.0 FS (CDC)** + - Sensor: **None** + - Size: **43mm x 14.5mm** + +### Device Detection (Python reference) + +From `weact_device_setting.py` in WeActStudio.SystemMonitor: + +- Auto-detects ports by scanning the port description for: + - `"AB"` → 3.5-inch device (type 0) + - `"AD"` → 0.96-inch device (type 1) +- Sets dimensions based on type: + - Type 0: 320x480 (portrait), 480x320 (landscape) + - Type 1: 80x160 (portrait), 160x80 (landscape) +- Opens serial port at **1,152,000 baud**, 8N1, 200ms timeout. + +### Protocol (SourceForge XLSX v1.1) + +Source: `WeAct Studio Display Communication protocol_v1.1.xlsx` (Doc folder on SourceForge). + +**Write commands** (all packets end with `0x0A`): + +- `0x02 SET_ORIENTATION` (len 3): `0x02, x, 0x0A` where `x=0-4` +- `0x03 SET_BRIGHTNESS` (len 5): `0x03, x, y_l8, y_h8, 0x0A` + - `x = brightness (0-255)` + - `y = brightness time (0-5000 ms)` +- `0x04 FULL` (len 12): `0x04, xs_l8, xs_h8, ys_l8, ys_h8, xe_l8, xe_h8, ye_l8, ye_h8, c_l8, c_h8, 0x0A` +- `0x05 SET_BITMAP` (len 10): `0x05, xs_l8, xs_h8, ys_l8, ys_h8, xe_l8, xe_h8, ye_l8, ye_h8, 0x0A` + - Image data (RGB565) follows immediately. Timeout 500ms. +- `0x06 ENABLE_HUMITURE_REPORT` (len 4): `0x06, y_l8, y_h8, 0x0A` + - `y=report time (500-65535), y=0 disables` +- `0x07 FREE` (len 2): `0x07, 0x0A` (show initial screen) +- `0x08 LCD_REG_ADDR` (len 3): `0x08, x, 0x0A` +- `0x09 LCD_REG_DATA` (len 3): `0x09, x, 0x0A` +- `0x10 SET_UNCONNECT_BRIGHTNESS` (len 3): `0x10, x, 0x0A` +- `0x11 SET_UNCONNECT_ORIENTATION` (len 3): `0x11, x, 0x0A` (`x=0-5`) +- `0x15 SET_BITMAP_WITH_FASTLZ` (len 10): same header as `SET_BITMAP`, compressed payload +- `0x40 SYSTEM_RESET` (len 2): `0x40, 0x0A` + +**Orientation values** + +- `PORTRAIT = 0` +- `REVERSE_PORTRAIT = 1` +- `LANDSCAPE = 2` +- `REVERSE_LANDSCAPE = 3` +- `ROTATE = 5` + +**Read commands** (request and response, all end with `0x0A`): + +- `0x81 WHO_AM_I` → device info +- `0x82 READ_ORIENTATION` → `0x82, x, 0x0A` +- `0x83 READ_BRIGHTNESS` → `0x83, x, 0x0A` +- `0x90 READ_UNCONNECT_BRIGHTNESS` → `0x90, x, 0x0A` +- `0x91 READ_UNCONNECT_ORIENTATION` → `0x91, x, 0x0A` +- `0xC2 READ_SYSTEM_VERSION` → `0xC2, version..., 0x0A` +- `0xC3 READ_SYSTEM_SERIAL_NUM` → `0xC3, serial..., 0x0A` + +**Auto report** + +- `0x86 HUMITURE_REPORT` (len 6): `0x86, t_l8, t_h8, h_l8, h_h8, 0x0A` + - Temperature/humidity values are `u16`/`i16` and scaled by 100 in the reference UI. + +### Reference Implementation (Python) + +- `weact_device_setting.py` sends commands to set orientation/brightness and display bitmap data. +- Uses FastLZ for bitmap updates; chunk size is `width * 4` bytes. +- Converts RGB to RGB565 little-endian. +- Uses a background RX thread and reads auto humiture reports when enabled. + +## Sources + +- [WeActStudio.SystemMonitor README (Support Hardware)](https://raw.githubusercontent.com/WeActStudio/WeActStudio.SystemMonitor/main/README.md) – 3.5-inch specs, USB CDC, sensor. +- [WeAct Studio System Monitor protocol XLSX v1.1](https://sourceforge.net/projects/weact-studio-system-monitor/files/Doc/WeAct%20Studio%20Display%20Communication%20protocol_v1.1.xlsx/download) – command set, read responses, auto-report. +- [WeActStudio.SystemMonitor weact_device_setting.py](https://raw.githubusercontent.com/WeActStudio/WeActStudio.SystemMonitor/main/weact_device_setting.py) – detection logic, baud rate, command usage. +- [WeAct Studio System Monitor SourceForge files](https://sourceforge.net/projects/weact-studio-system-monitor/files/) – hardware tables and docs links. +- [Bit Trade One WADFS-35 manual](https://bit-trade-one.co.jp/en/wadfs35/manual/) – Windows usage and tool references. + +## Open Questions + +- [ ] Confirm USB VID/PID and port description strings for the 3.5-inch device on macOS/Linux. +- [ ] Validate whether the 3.5-inch device requires 1,152,000 baud or supports 115,200. +- [ ] Verify FastLZ payload format and chunking details for large frames. +- [ ] Identify any model-specific limitations (brightness range, orientation quirks). + +## Related Research + +- [[display_fs_v1_research.md]] – 0.96-inch device overview. diff --git a/AGENTS.md b/AGENTS.md index 03412af..6e07ad4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,17 +1,20 @@ -# Project: Display FS V1 (0.96 inch) +# Project: Display FS V1 (0.96 inch + 3.5 inch) ## Overview -Standalone CLI application to interact with the Display FS V1 (0.96 inch), detect if it's connected, and display content. +Standalone CLI application to interact with the Display FS V1 family (0.96 inch + 3.5 inch), detect if it's connected, and display content. ## Hardware -- **Device:** WeAct Studio Display FS V1 (0.96 inch IPS LCD) -- **Connection:** USB-C (appears as serial/COM port) -- **Resolution:** 80x160 pixels (portrait orientation) -- **Communication:** USB Serial (UART) at 115200 baud -- **USB Chip:** CH340/CH341 USB-Serial converter -- **Known VID/PID:** CH340 (1A86:7523), CH341 (1A86:5523) +- **Device (small):** WeAct Studio Display FS V1 (0.96 inch IPS LCD) + - **Resolution:** 80x160 pixels (portrait orientation) + - **Communication:** USB CDC/serial (UART), 115200 baud (current CLI) + - **USB Chip:** CH340/CH341 USB-Serial converter + - **Known VID/PID:** CH340 (1A86:7523), CH341 (1A86:5523) +- **Device (large):** WeAct Studio Display FS V1 (3.5 inch IPS LCD) + - **Resolution:** 320x480 pixels (portrait orientation) + - **Communication:** USB2.0 FS (CDC); reference Python app uses 1,152,000 baud + - **Sensors:** Humidity + Temperature ## Project Structure diff --git a/README.md b/README.md index f04cd4c..ae88025 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Display FS V1 -CLI tool to interact with the WeAct Studio Display FS V1 (0.96 inch IPS LCD). +CLI tool to interact with the WeAct Studio Display FS V1 family (0.96 inch + 3.5 inch IPS LCDs). ## Features - **Standalone executable** - No runtime dependencies, just download and run - Auto-detect display via USB (CH340/CH341 USB-Serial) -- Display text on the 160x80 pixel screen +- Display text on the 160x80 or 320x480 pixel screen - Cross-platform support (Windows, Linux, macOS) ## Website @@ -27,14 +27,20 @@ just docs-serve ## Hardware -| Specification | Value | -|---------------|------------------------------| -| Device | WeAct Studio Display FS V1 | -| Screen Size | 0.96 inch IPS LCD | -| Resolution | 80x160 pixels (portrait) | -| Connection | USB-C (serial) | -| Baud Rate | 115200 | -| USB Chip | CH340/CH341 | +| Specification | 0.96 inch | 3.5 inch | +|---------------|------------------------------------|-----------------------------------| +| Device | WeAct Studio Display FS V1 | WeAct Studio Display FS V1 | +| Resolution | 80x160 pixels (portrait) | 320x480 pixels (portrait) | +| Connection | USB-C (serial) | USB 2.0 FS (CDC) | +| Baud Rate | 115200 | 1152000 (reference Python app) | +| USB Chip | CH340/CH341 | USB CDC | +| Sensors | None | Humidity + Temperature | + +## Model Detection + +The CLI auto-detects the display model by VID/PID and USB product strings. If detection is +ambiguous, pass `--model small` or `--model large` and, if needed, override the baud rate with +`--baud-rate`. ## Quick Start @@ -134,6 +140,8 @@ Options: -d, --delay Delay between pages [default: 2.0] -l, --loop Loop display continuously --detect Only check if display is connected + --model Force display model: small or large + --baud-rate Override baud rate (advanced) -h, --help Print help ``` diff --git a/site/README.md b/site/README.md index f3d2c57..6024c23 100644 --- a/site/README.md +++ b/site/README.md @@ -1,6 +1,6 @@ # Display FS V1 Site -Static HTML landing page for GitHub Pages. +Static HTML landing page for GitHub Pages, covering both 0.96" and 3.5" Display FS V1 devices. ## Local preview diff --git a/site/index.html b/site/index.html index fa2ef57..3d82bfd 100644 --- a/site/index.html +++ b/site/index.html @@ -3,7 +3,7 @@ - Display FS V1 — Tiny USB Status Display + Display FS V1 — Dual-Size USB Status Display @@ -18,40 +18,47 @@
-

USB status display, 80×160 pixels

-

Small screen. Serious signal.

+

USB status display, 80×160 and 320×480

+

One CLI. Two screen sizes.

- Display FS V1 is a tiny USB-C IPS display. This CLI talks to it directly over serial so you can - push crisp text, status, and now-playing info from your terminal. + Display FS V1 now covers both the 0.96" and 3.5" WeAct Studio panels. The CLI talks directly + over USB CDC/serial so you can push crisp text, status dashboards, and now-playing info from + your terminal.

-
-
-
- DISPLAY FS - READY +
+
+
+ DISPLAY FS + READY +
-
-
-
-

Resolution

-

160×80

-
-
-

Panel

-

0.96" IPS

-
-
-

Connection

-

USB-C

+
+ 0.96" USB-C + 3.5" USB CDC + RGB565
-
-

Protocol

-

Serial

+
+
+

Resolutions

+

160×80 + 320×480

+
+
+

Panels

+

0.96" + 3.5" IPS

+
+
+

Connection

+

USB-C / USB CDC

+
+
+

Protocol

+

USB CDC + RGB565

+
@@ -62,7 +69,7 @@

Small screen. Serious signal.

Get started in 30 seconds

-

Detect the display, push text, flip orientation, and loop updates.

+

Detect the display, push text, flip orientation, and loop updates on either size.

@@ -72,10 +79,17 @@

Install

./display-fs show "Hello"
-

Useful commands

+

0.96" quick hits

./display-fs show --auto "Status OK"
 ./display-fs show --detect
 ./display-fs preset clock --loop
+
+
+

3.5" examples

+
./display-fs show --model large \
+  --baud-rate 1152000 --auto "Studio"
+./display-fs spotify --model large \
+  --baud-rate 1152000 --loop

Japanese/CJK

@@ -83,26 +97,33 @@

Japanese/CJK

just install-jp ./display-fs show "東京"
+
+

Orientation + flip

+
./display-fs show --orientation portrait \
+  --flip "Tall text"
+./display-fs show --model large \
+  --orientation landscape
+

Hardware notes

-

WeAct Studio Display FS V1 (0.96") — USB 2.0 Full Speed CDC device.

+

Both Display FS V1 panels use USB 2.0 Full Speed CDC and RGB565.

-

Resolution

-

80×160 pixels, RGB565 color.

+

0.96" display

+

80×160 RGB565 over CH340/CH341 @ 115200 baud.

-

USB bridge

-

CH340/CH341 serial chip at 115200 baud.

+

3.5" display

+

320×480 RGB565 over USB CDC @ 1,152,000 baud.

-

Dimensions

-

43 × 14.5 mm, reversible USB-A/C footprint.

+

Sensors

+

3.5" model includes humidity + temperature telemetry.

@@ -110,12 +131,12 @@

Dimensions

What you can display

-

Built-in presets for system stats, Spotify now-playing, and auto-fit text.

+

Built-in presets for system stats, Spotify now-playing, and auto-fit text on both sizes.

Auto-fit text

-

Largest readable type on the 80×160 canvas, automatically.

+

Largest readable type on 160×80 or 320×480 canvases, automatically.

Orientation + flip

@@ -127,7 +148,11 @@

Presets

Spotify (macOS)

-

Live now-playing with smooth refresh.

+

Live now-playing with smooth refresh on either panel.

+
+
+

Model overrides

+

Force `--model small|large` and override baud when detection is ambiguous.

diff --git a/site/styles.css b/site/styles.css index fa3d56b..75ee412 100644 --- a/site/styles.css +++ b/site/styles.css @@ -180,12 +180,36 @@ h1 { align-items: center; } +.screen-inner span:last-child { + font-size: 1.1rem; + letter-spacing: 0.22em; +} + .spec-grid { display: grid; grid-template-columns: repeat(2, minmax(120px, 1fr)); gap: 14px; } +.chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.chip-row span { + display: inline-flex; + align-items: center; + padding: 6px 12px; + border-radius: 999px; + background: #f7f1ea; + border: 1px solid #eadfd3; + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + .label { font-size: 0.72rem; color: var(--muted); @@ -237,6 +261,12 @@ main { padding: 22px; border-radius: 16px; box-shadow: 0 12px 32px rgba(15, 12, 6, 0.08); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.card:hover { + transform: translateY(-4px); + box-shadow: 0 20px 40px rgba(15, 12, 6, 0.14); } .card h3 { diff --git a/src/image.rs b/src/image.rs index 63edc17..c21aac5 100644 --- a/src/image.rs +++ b/src/image.rs @@ -40,6 +40,15 @@ impl Orientation { Orientation::PortraitFlip => PHYSICAL_HEIGHT, } } + + pub fn dimensions_for_display(self, physical_width: u32, physical_height: u32) -> (u32, u32) { + match self { + Orientation::Landscape | Orientation::LandscapeFlip => { + (physical_height, physical_width) + } + Orientation::Portrait | Orientation::PortraitFlip => (physical_width, physical_height), + } + } } // Legacy constants for backward compatibility (default to landscape: 160x80) @@ -61,6 +70,15 @@ pub fn create_blank_image_oriented(orientation: Orientation) -> RgbImage { RgbImage::from_pixel(orientation.width(), orientation.height(), Rgb([0, 0, 0])) } +pub fn create_blank_image_for_display( + orientation: Orientation, + physical_width: u32, + physical_height: u32, +) -> RgbImage { + let (width, height) = orientation.dimensions_for_display(physical_width, physical_height); + RgbImage::from_pixel(width, height, Rgb([0, 0, 0])) +} + pub fn create_text_image(text: &str, font_size: f32) -> RgbImage { create_text_image_oriented(text, font_size, Orientation::default()) } @@ -76,6 +94,18 @@ pub fn create_text_image_oriented( img } +pub fn create_text_image_for_display( + text: &str, + font_size: f32, + orientation: Orientation, + physical_width: u32, + physical_height: u32, +) -> RgbImage { + let mut img = create_blank_image_for_display(orientation, physical_width, physical_height); + draw_text_oriented_for_display(&mut img, text, font_size, orientation, physical_width, physical_height); + img +} + fn draw_text_oriented(img: &mut RgbImage, text: &str, font_size: f32, orientation: Orientation) { use ab_glyph::{Font, ScaleFont}; @@ -101,6 +131,38 @@ fn draw_text_oriented(img: &mut RgbImage, text: &str, font_size: f32, orientatio } } +fn draw_text_oriented_for_display( + img: &mut RgbImage, + text: &str, + font_size: f32, + orientation: Orientation, + physical_width: u32, + physical_height: u32, +) { + use ab_glyph::{Font, ScaleFont}; + + let font = FontRef::try_from_slice(FONT_DATA).expect("Failed to load embedded font"); + let scale = PxScale::from(font_size); + let scaled_font = font.as_scaled(scale); + let line_height = scaled_font.height(); + + let lines: Vec<&str> = text.lines().collect(); + let total_height = line_height * lines.len() as f32; + + let (display_width, display_height) = + orientation.dimensions_for_display(physical_width, physical_height); + + let start_y = ((display_height as f32 - total_height) / 2.0).max(0.0) as i32; + + for (i, line) in lines.iter().enumerate() { + let (line_width, _) = measure_text(&font, scale, line); + let x = ((display_width as i32 - line_width as i32) / 2).max(0); + let y = start_y + (i as f32 * line_height) as i32; + + draw_text_mut(img, Rgb([255, 255, 255]), x, y, scale, &font, line); + } +} + pub fn measure_text_with_font_size(text: &str, font_size: f32) -> (u32, u32) { let font = FontRef::try_from_slice(FONT_DATA).expect("Failed to load embedded font"); let scale = PxScale::from(font_size); @@ -170,6 +232,37 @@ pub fn calculate_auto_fit_size_oriented(text: &str, orientation: Orientation) -> low } +pub fn calculate_auto_fit_size_for_display( + text: &str, + orientation: Orientation, + physical_width: u32, + physical_height: u32, +) -> f32 { + if text.is_empty() { + return MIN_FONT_SIZE; + } + + let (width, height) = orientation.dimensions_for_display(physical_width, physical_height); + let max_text_width = width - HORIZONTAL_PADDING; + let max_text_height = height - VERTICAL_PADDING; + + let mut low = MIN_FONT_SIZE; + let mut high = MAX_FONT_SIZE; + + while high - low > 0.5 { + let mid = (low + high) / 2.0; + let (width, height) = measure_multiline_text(text, mid); + + if width <= max_text_width && height <= max_text_height { + low = mid; + } else { + high = mid; + } + } + + low +} + fn measure_text(font: &FontRef, scale: PxScale, text: &str) -> (u32, u32) { use ab_glyph::{Font, ScaleFont}; @@ -206,6 +299,28 @@ pub fn calculate_max_chars_per_line_oriented(font_size: f32, orientation: Orient } } +pub fn calculate_max_chars_per_line_for_display( + font_size: f32, + orientation: Orientation, + physical_width: u32, + physical_height: u32, +) -> usize { + let font = FontRef::try_from_slice(FONT_DATA).expect("Failed to load embedded font"); + let scale = PxScale::from(font_size); + + use ab_glyph::{Font, ScaleFont}; + let scaled_font = font.as_scaled(scale); + + let avg_width = scaled_font.h_advance(font.glyph_id('x')); + let (width, _) = orientation.dimensions_for_display(physical_width, physical_height); + + if avg_width == 0.0 { + return 0; + } + + (width as f32 / avg_width).floor() as usize +} + pub fn calculate_max_lines(font_size: f32) -> usize { calculate_max_lines_oriented(font_size, Orientation::default()) } @@ -226,6 +341,27 @@ pub fn calculate_max_lines_oriented(font_size: f32, orientation: Orientation) -> } } +pub fn calculate_max_lines_for_display( + font_size: f32, + orientation: Orientation, + physical_width: u32, + physical_height: u32, +) -> usize { + use ab_glyph::{Font, ScaleFont}; + + let font = FontRef::try_from_slice(FONT_DATA).expect("Failed to load embedded font"); + let scale = PxScale::from(font_size); + let scaled_font = font.as_scaled(scale); + let line_height = scaled_font.height(); + + if line_height == 0.0 { + return 0; + } + + let (_, height) = orientation.dimensions_for_display(physical_width, physical_height); + (height as f32 / line_height).floor() as usize +} + /// Convert image to RGB565 bytes for display (uses image dimensions) pub fn image_to_rgb565_bytes(img: &RgbImage) -> Vec { image_to_rgb565_bytes_oriented(img, Orientation::default()) @@ -289,6 +425,58 @@ pub fn image_to_rgb565_bytes_oriented(img: &RgbImage, orientation: Orientation) data } +pub fn image_to_rgb565_bytes_for_display( + img: &RgbImage, + orientation: Orientation, + physical_width: u32, + physical_height: u32, +) -> Vec { + let mut data = Vec::with_capacity((physical_width * physical_height * 2) as usize); + + match orientation { + Orientation::Portrait => { + for y in 0..img.height() { + for x in 0..img.width() { + let pixel = img.get_pixel(x, y); + push_rgb565(&mut data, pixel[0], pixel[1], pixel[2]); + } + } + } + Orientation::Landscape => { + for py in 0..physical_height { + for px in 0..physical_width { + let lx = py; + let ly = (physical_width - 1) - px; + let pixel = img.get_pixel(lx, ly); + push_rgb565(&mut data, pixel[0], pixel[1], pixel[2]); + } + } + } + Orientation::PortraitFlip => { + for py in 0..physical_height { + for px in 0..physical_width { + let lx = (physical_width - 1) - px; + let ly = (physical_height - 1) - py; + let pixel = img.get_pixel(lx, ly); + push_rgb565(&mut data, pixel[0], pixel[1], pixel[2]); + } + } + } + Orientation::LandscapeFlip => { + for py in 0..physical_height { + for px in 0..physical_width { + let lx = (physical_height - 1) - py; + let ly = px; + let pixel = img.get_pixel(lx, ly); + push_rgb565(&mut data, pixel[0], pixel[1], pixel[2]); + } + } + } + } + + data +} + fn push_rgb565(data: &mut Vec, r: u8, g: u8, b: u8) { let r5 = (r >> 3) & 0x1F; let g6 = (g >> 2) & 0x3F; diff --git a/src/lib.rs b/src/lib.rs index cc78c9c..aaeb81e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,13 +7,20 @@ pub mod spotify; pub mod text; pub use image::{ - calculate_auto_fit_size, calculate_auto_fit_size_oriented, calculate_max_chars_per_line, - calculate_max_chars_per_line_oriented, calculate_max_lines, calculate_max_lines_oriented, - create_text_image, create_text_image_oriented, image_to_rgb565_bytes, - image_to_rgb565_bytes_oriented, measure_text_with_font_size, Orientation, DISPLAY_HEIGHT, - DISPLAY_WIDTH, + calculate_auto_fit_size, calculate_auto_fit_size_for_display, calculate_auto_fit_size_oriented, + calculate_max_chars_per_line, calculate_max_chars_per_line_for_display, + calculate_max_chars_per_line_oriented, calculate_max_lines, calculate_max_lines_for_display, + calculate_max_lines_oriented, create_blank_image_for_display, create_text_image, + create_text_image_for_display, create_text_image_oriented, image_to_rgb565_bytes, + image_to_rgb565_bytes_for_display, image_to_rgb565_bytes_oriented, measure_text_with_font_size, + Orientation, DISPLAY_HEIGHT, DISPLAY_WIDTH, +}; +pub use port::{ + find_display_port, is_display_connected, open_connection, DisplayConfig, DisplayModel, + PortInfo, +}; +pub use protocol::{ + send_image_to_display, send_image_to_display_for_model, send_image_to_display_oriented, }; -pub use port::{find_display_port, is_display_connected, open_connection, PortInfo}; -pub use protocol::{send_image_to_display, send_image_to_display_oriented}; pub use spotify::{get_now_playing, NowPlaying}; -pub use text::split_into_pages; +pub use text::{split_into_pages, split_into_pages_for_display}; diff --git a/src/main.rs b/src/main.rs index 3a48ea0..4743e71 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,9 @@ use clap::{Parser, Subcommand, ValueEnum}; use display_fs::{ - calculate_auto_fit_size_oriented, create_text_image_oriented, find_display_port, - get_now_playing, image_to_rgb565_bytes_oriented, is_display_connected, open_connection, - send_image_to_display_oriented, split_into_pages, Orientation, + calculate_auto_fit_size_for_display, create_text_image_for_display, find_display_port, + get_now_playing, image_to_rgb565_bytes_for_display, is_display_connected, open_connection, + send_image_to_display_for_model, split_into_pages_for_display, DisplayConfig, DisplayModel, + Orientation, }; use std::process::{Command, ExitCode}; use std::thread; @@ -89,6 +90,14 @@ struct DisplayOptions { /// Speed preset (overrides --delay if provided) #[arg(long, value_enum)] speed: Option, + + /// Force display model (small = 0.96 inch, large = 3.5 inch) + #[arg(long, value_enum)] + model: Option, + + /// Override baud rate (advanced) + #[arg(long)] + baud_rate: Option, } impl DisplayOptions { @@ -99,6 +108,34 @@ impl DisplayOptions { pub fn orientation(&self) -> Orientation { self.orientation.to_orientation(self.flip) } + + pub fn override_config(&self, base: DisplayConfig) -> DisplayConfig { + let mut config = base; + if let Some(model) = self.model { + config = model.to_model().config(); + } + if let Some(baud_rate) = self.baud_rate { + config.baud_rate = baud_rate; + } + config + } +} + +#[derive(Clone, Copy, ValueEnum)] +enum DisplayModelArg { + /// 0.96-inch display + Small, + /// 3.5-inch display + Large, +} + +impl DisplayModelArg { + fn to_model(self) -> DisplayModel { + match self { + DisplayModelArg::Small => DisplayModel::Small, + DisplayModelArg::Large => DisplayModel::Large, + } + } } #[derive(clap::Args)] @@ -324,8 +361,12 @@ fn run_demo(display: DisplayOptions) -> ExitCode { } }; + let display_config = display.override_config(port_info.model.config()); println!("✓ Found display on {}", port_info.name); + println!("Opening connection to {} at {} baud...", port_info.name, display_config.baud_rate); + let mut port_info = port_info; + port_info.baud_rate = display_config.baud_rate; let mut connection = match open_connection(&port_info) { Ok(c) => c, Err(e) => { @@ -342,13 +383,38 @@ fn run_demo(display: DisplayOptions) -> ExitCode { let text = preset.run_command(); println!("[{}] {}", desc, text); - let font_size = get_effective_font_size(&text, &display); - let img = create_text_image_oriented(&text, font_size, orientation); - let image_data = image_to_rgb565_bytes_oriented(&img, orientation); - - if let Err(e) = - send_image_to_display_oriented(&mut connection, &image_data, orientation) - { + let font_size = if display.auto { + let size = calculate_auto_fit_size_for_display( + &text, + orientation, + display_config.width as u32, + display_config.height as u32, + ); + println!("Auto-fit font size: {:.1}", size); + size + } else { + display.font_size + }; + let img = create_text_image_for_display( + &text, + font_size, + orientation, + display_config.width as u32, + display_config.height as u32, + ); + let image_data = image_to_rgb565_bytes_for_display( + &img, + orientation, + display_config.width as u32, + display_config.height as u32, + ); + + if let Err(e) = send_image_to_display_for_model( + &mut connection, + display_config, + &image_data, + orientation, + ) { println!("✗ Failed to send image: {}", e); return ExitCode::FAILURE; } @@ -369,8 +435,12 @@ fn run_spotify(args: SpotifyArgs) -> ExitCode { } }; + let display_config = args.display.override_config(port_info.model.config()); println!("✓ Found display on {}", port_info.name); + println!("Opening connection to {} at {} baud...", port_info.name, display_config.baud_rate); + let mut port_info = port_info; + port_info.baud_rate = display_config.baud_rate; let mut connection = match open_connection(&port_info) { Ok(c) => c, Err(e) => { @@ -405,13 +475,38 @@ fn run_spotify(args: SpotifyArgs) -> ExitCode { let should_update = current != last_track; if should_update { - let font_size = get_effective_font_size(&text, &args.display); - let img = create_text_image_oriented(&text, font_size, orientation); - let image_data = image_to_rgb565_bytes_oriented(&img, orientation); - - if let Err(e) = - send_image_to_display_oriented(&mut connection, &image_data, orientation) - { + let font_size = if args.display.auto { + let size = calculate_auto_fit_size_for_display( + &text, + orientation, + display_config.width as u32, + display_config.height as u32, + ); + println!("Auto-fit font size: {:.1}", size); + size + } else { + args.display.font_size + }; + let img = create_text_image_for_display( + &text, + font_size, + orientation, + display_config.width as u32, + display_config.height as u32, + ); + let image_data = image_to_rgb565_bytes_for_display( + &img, + orientation, + display_config.width as u32, + display_config.height as u32, + ); + + if let Err(e) = send_image_to_display_for_model( + &mut connection, + display_config, + &image_data, + orientation, + ) { println!("✗ Failed to send image: {}", e); return ExitCode::FAILURE; } @@ -445,6 +540,7 @@ fn detect_display() -> ExitCode { if let Some(port) = find_display_port() { println!("✓ Found display on {}", port.name); println!(" VID: {:04X}, PID: {:04X}", port.vid, port.pid); + println!(" Model: {:?}", port.model); return ExitCode::SUCCESS; } } @@ -455,18 +551,7 @@ fn detect_display() -> ExitCode { ExitCode::FAILURE } -fn get_effective_font_size(text: &str, display: &DisplayOptions) -> f32 { - if display.auto { - let size = calculate_auto_fit_size_oriented(text, display.orientation()); - println!("Auto-fit font size: {:.1}", size); - size - } else { - display.font_size - } -} - fn display_text(text: &str, display: &DisplayOptions) -> ExitCode { - let font_size = get_effective_font_size(text, display); let delay = display.effective_delay(); let loop_mode = display.r#loop; let orientation = display.orientation(); @@ -483,9 +568,29 @@ fn display_text(text: &str, display: &DisplayOptions) -> ExitCode { } }; + let display_config = display.override_config(port_info.model.config()); + let font_size = if display.auto { + let size = calculate_auto_fit_size_for_display( + text, + orientation, + display_config.width as u32, + display_config.height as u32, + ); + println!("Auto-fit font size: {:.1}", size); + size + } else { + display.font_size + }; + println!("✓ Found display on {}", port_info.name); - let pages = split_into_pages(text, font_size); + let pages = split_into_pages_for_display( + text, + font_size, + orientation, + display_config.width as u32, + display_config.height as u32, + ); let pages = if pages.is_empty() { vec![text.to_string()] } else { @@ -500,7 +605,12 @@ fn display_text(text: &str, display: &DisplayOptions) -> ExitCode { page_count, font_size, orientation ); - println!("Opening connection to {}...", port_info.name); + println!( + "Opening connection to {} at {} baud...", + port_info.name, display_config.baud_rate + ); + let mut port_info = port_info; + port_info.baud_rate = display_config.baud_rate; let mut connection = match open_connection(&port_info) { Ok(c) => c, Err(e) => { @@ -518,10 +628,26 @@ fn display_text(text: &str, display: &DisplayOptions) -> ExitCode { println!("Displaying page {}/{}...", i + 1, page_count); } - let img = create_text_image_oriented(page, font_size, orientation); - let image_data = image_to_rgb565_bytes_oriented(&img, orientation); - - match send_image_to_display_oriented(&mut connection, &image_data, orientation) { + let img = create_text_image_for_display( + page, + font_size, + orientation, + display_config.width as u32, + display_config.height as u32, + ); + let image_data = image_to_rgb565_bytes_for_display( + &img, + orientation, + display_config.width as u32, + display_config.height as u32, + ); + + match send_image_to_display_for_model( + &mut connection, + display_config, + &image_data, + orientation, + ) { Ok(()) => { if page_count == 1 && !loop_mode { println!("✓ Image sent successfully!"); diff --git a/src/port.rs b/src/port.rs index 157024e..9c2b628 100644 --- a/src/port.rs +++ b/src/port.rs @@ -2,7 +2,6 @@ use serialport::{SerialPort, SerialPortInfo, SerialPortType}; use std::time::Duration; use thiserror::Error; -const BAUD_RATE: u32 = 115200; const TIMEOUT_MS: u64 = 1000; const DISPLAY_FS_VID_PID: [(u16, u16); 3] = [ @@ -11,6 +10,11 @@ const DISPLAY_FS_VID_PID: [(u16, u16); 3] = [ (0x1A86, 0xFE0C), // WeAct Studio Display FS V1 ]; +const DISPLAY_FS_VID: u16 = 0x1A86; +const DISPLAY_FS_PID_LARGE: u16 = 0xFE0C; +const DISPLAY_FS_BAUD_SMALL: u32 = 115200; +const DISPLAY_FS_BAUD_LARGE: u32 = 1_152_000; + #[derive(Error, Debug)] pub enum PortError { #[error("Display not found")] @@ -24,6 +28,43 @@ pub struct PortInfo { pub name: String, pub vid: u16, pub pid: u16, + pub model: DisplayModel, + pub baud_rate: u32, + pub product: Option, + pub manufacturer: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisplayModel { + Small, + Large, +} + +#[derive(Debug, Clone, Copy)] +pub struct DisplayConfig { + pub model: DisplayModel, + pub width: u16, + pub height: u16, + pub baud_rate: u32, +} + +impl DisplayModel { + pub fn config(self) -> DisplayConfig { + match self { + DisplayModel::Small => DisplayConfig { + model: self, + width: 80, + height: 160, + baud_rate: DISPLAY_FS_BAUD_SMALL, + }, + DisplayModel::Large => DisplayConfig { + model: self, + width: 320, + height: 480, + baud_rate: DISPLAY_FS_BAUD_LARGE, + }, + } + } } pub fn list_ports() -> Vec { @@ -36,10 +77,16 @@ pub fn find_display_port() -> Option { let vid = usb_info.vid; let pid = usb_info.pid; if DISPLAY_FS_VID_PID.contains(&(vid, pid)) { + let model = detect_display_model(usb_info)?; + let config = model.config(); return Some(PortInfo { name: port.port_name, vid, pid, + model, + baud_rate: config.baud_rate, + product: usb_info.product.clone(), + manufacturer: usb_info.manufacturer.clone(), }); } } @@ -52,12 +99,32 @@ pub fn is_display_connected() -> bool { } pub fn open_connection(port: &PortInfo) -> Result, PortError> { - let connection = serialport::new(&port.name, BAUD_RATE) + let connection = serialport::new(&port.name, port.baud_rate) .timeout(Duration::from_millis(TIMEOUT_MS)) .open()?; Ok(connection) } +fn detect_display_model(usb_info: &serialport::UsbPortInfo) -> Option { + let product = usb_info.product.as_deref().unwrap_or_default().to_lowercase(); + if usb_info.vid == DISPLAY_FS_VID && usb_info.pid == DISPLAY_FS_PID_LARGE { + if product.contains("0.96") { + return Some(DisplayModel::Small); + } + return Some(DisplayModel::Large); + } + if product.contains("0.96") { + return Some(DisplayModel::Small); + } + if product.contains("display fs v1") { + return Some(DisplayModel::Large); + } + match (usb_info.vid, usb_info.pid) { + (0x1A86, 0x7523) | (0x1A86, 0x5523) => Some(DisplayModel::Small), + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -106,9 +173,34 @@ mod tests { name: "COM3".to_string(), vid: 0x1A86, pid: 0x7523, + model: DisplayModel::Small, + baud_rate: DISPLAY_FS_BAUD_SMALL, + product: Some("Display FS 0.96 Inch".to_string()), + manufacturer: Some("WeAct Studio".to_string()), }; assert_eq!(port.name, "COM3"); assert_eq!(port.vid, 0x1A86); assert_eq!(port.pid, 0x7523); } + + #[test] + fn test_detect_display_model_product_hints() { + let usb_info = serialport::UsbPortInfo { + vid: DISPLAY_FS_VID, + pid: DISPLAY_FS_PID_LARGE, + serial_number: None, + manufacturer: Some("WeAct Studio".to_string()), + product: Some("Display FS V1".to_string()), + }; + assert_eq!(detect_display_model(&usb_info), Some(DisplayModel::Large)); + + let usb_info_small = serialport::UsbPortInfo { + vid: DISPLAY_FS_VID, + pid: DISPLAY_FS_PID_LARGE, + serial_number: None, + manufacturer: Some("WeAct Studio".to_string()), + product: Some("Display FS 0.96 Inch".to_string()), + }; + assert_eq!(detect_display_model(&usb_info_small), Some(DisplayModel::Small)); + } } diff --git a/src/protocol.rs b/src/protocol.rs index 4129137..03bddc5 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1,4 +1,5 @@ use crate::image::Orientation; +use crate::port::DisplayConfig; use serialport::SerialPort; use std::io::Write; use std::thread::sleep; @@ -23,6 +24,29 @@ pub fn create_bitmap_header() -> [u8; 10] { const PHYSICAL_WIDTH: u16 = 80; const PHYSICAL_HEIGHT: u16 = 160; +fn create_bitmap_header_for_display_oriented( + config: DisplayConfig, + _orientation: Orientation, +) -> [u8; 10] { + let x0: u16 = 0; + let y0: u16 = 0; + let x1: u16 = config.width - 1; + let y1: u16 = config.height - 1; + + [ + CMD_SET_BITMAP, + (x0 & 0xFF) as u8, + (x0 >> 8) as u8, + (y0 & 0xFF) as u8, + (y0 >> 8) as u8, + (x1 & 0xFF) as u8, + (x1 >> 8) as u8, + (y1 & 0xFF) as u8, + (y1 >> 8) as u8, + CMD_END, + ] +} + pub fn create_bitmap_header_oriented(_orientation: Orientation) -> [u8; 10] { // Always use physical dimensions - rotation is handled in image data let x0: u16 = 0; @@ -51,6 +75,35 @@ pub fn send_image_to_display( send_image_to_display_oriented(port, image_data, Orientation::default()) } +pub fn send_image_to_display_for_model( + port: &mut Box, + config: DisplayConfig, + image_data: &[u8], + orientation: Orientation, +) -> Result<(), ProtocolError> { + port.clear(serialport::ClearBuffer::All) + .map_err(|e| ProtocolError::SendFailed(std::io::Error::other(e)))?; + + let orient_cmd = create_orientation_command(orientation); + port.write_all(&orient_cmd)?; + port.flush()?; + sleep(Duration::from_millis(50)); + + let header = create_bitmap_header_for_display_oriented(config, orientation); + port.write_all(&header)?; + port.flush()?; + + let chunk_size = config.width as usize * 4; + for chunk in image_data.chunks(chunk_size) { + port.write_all(chunk)?; + } + + port.flush()?; + sleep(Duration::from_millis(100)); + + Ok(()) +} + /// Create orientation command to initialize display orientation fn create_orientation_command(orientation: Orientation) -> [u8; 3] { // Orientation values: 0=portrait, 1=landscape @@ -150,4 +203,20 @@ mod tests { // Always uses physical width: 80 * 4 = 320 assert_eq!(PHYSICAL_WIDTH as usize * 4, 320); } + + #[test] + fn test_bitmap_header_for_display_dimensions() { + let config = DisplayConfig { + model: crate::port::DisplayModel::Large, + width: 320, + height: 480, + baud_rate: 1_152_000, + }; + + let header = create_bitmap_header_for_display_oriented(config, Orientation::Portrait); + assert_eq!(header[5], 0x3F); // x1 low (319) + assert_eq!(header[6], 0x01); // x1 high + assert_eq!(header[7], 0xDF); // y1 low (479) + assert_eq!(header[8], 0x01); // y1 high + } } diff --git a/src/text.rs b/src/text.rs index 2ca7f2c..335314a 100644 --- a/src/text.rs +++ b/src/text.rs @@ -1,5 +1,8 @@ -use crate::image::{calculate_max_chars_per_line, calculate_max_lines, DISPLAY_WIDTH}; -use crate::measure_text_with_font_size; +use crate::image::{ + calculate_max_chars_per_line, calculate_max_chars_per_line_for_display, calculate_max_lines, + calculate_max_lines_for_display, DISPLAY_WIDTH, +}; +use crate::{measure_text_with_font_size, Orientation}; /// Split text into pages that fit on the display. /// Uses word-aware splitting (never breaks mid-word). @@ -22,6 +25,36 @@ pub fn split_into_pages(text: &str, font_size: f32) -> Vec { .collect() } +pub fn split_into_pages_for_display( + text: &str, + font_size: f32, + orientation: Orientation, + physical_width: u32, + physical_height: u32, +) -> Vec { + let max_lines = calculate_max_lines_for_display( + font_size, + orientation, + physical_width, + physical_height, + ); + + if max_lines == 0 { + return vec![]; + } + + let lines = wrap_text_for_display(text, font_size, orientation, physical_width, physical_height); + + if lines.is_empty() { + return vec![]; + } + + lines + .chunks(max_lines) + .map(|chunk| chunk.join("\n")) + .collect() +} + /// Wrap text into lines that fit within the display width. /// Respects word boundaries and existing newlines. fn wrap_text(text: &str, font_size: f32) -> Vec { @@ -84,12 +117,105 @@ fn wrap_text(text: &str, font_size: f32) -> Vec { result } +fn wrap_text_for_display( + text: &str, + font_size: f32, + orientation: Orientation, + physical_width: u32, + physical_height: u32, +) -> Vec { + if text.is_empty() { + return Vec::new(); + } + + let mut result = Vec::new(); + let max_chars = calculate_max_chars_per_line_for_display( + font_size, + orientation, + physical_width, + physical_height, + ); + + for paragraph in text.split('\n') { + if paragraph.is_empty() { + result.push(String::new()); + continue; + } + + let words: Vec<&str> = paragraph.split_whitespace().collect(); + if words.is_empty() { + result.push(String::new()); + continue; + } + + let mut current_line = String::new(); + + for word in words { + if current_line.is_empty() { + if fits_in_width_for_display(word, font_size, orientation, physical_width, physical_height) { + current_line = word.to_string(); + } else { + current_line = truncate_to_fit_for_display( + word, + font_size, + max_chars, + orientation, + physical_width, + physical_height, + ); + result.push(current_line); + current_line = String::new(); + } + } else { + let test_line = format!("{} {}", current_line, word); + if fits_in_width_for_display(&test_line, font_size, orientation, physical_width, physical_height) { + current_line = test_line; + } else { + result.push(current_line); + if fits_in_width_for_display(word, font_size, orientation, physical_width, physical_height) { + current_line = word.to_string(); + } else { + current_line = truncate_to_fit_for_display( + word, + font_size, + max_chars, + orientation, + physical_width, + physical_height, + ); + result.push(current_line); + current_line = String::new(); + } + } + } + } + + if !current_line.is_empty() { + result.push(current_line); + } + } + + result +} + /// Check if text fits within display width fn fits_in_width(text: &str, font_size: f32) -> bool { let (width, _) = measure_text_with_font_size(text, font_size); width <= DISPLAY_WIDTH } +fn fits_in_width_for_display( + text: &str, + font_size: f32, + orientation: Orientation, + physical_width: u32, + physical_height: u32, +) -> bool { + let (width, _) = measure_text_with_font_size(text, font_size); + let (max_width, _) = orientation.dimensions_for_display(physical_width, physical_height); + width <= max_width +} + /// Truncate word to fit within display width fn truncate_to_fit(word: &str, font_size: f32, max_chars: usize) -> String { let mut result = word.to_string(); @@ -102,6 +228,32 @@ fn truncate_to_fit(word: &str, font_size: f32, max_chars: usize) -> String { result } +fn truncate_to_fit_for_display( + word: &str, + font_size: f32, + max_chars: usize, + orientation: Orientation, + physical_width: u32, + physical_height: u32, +) -> String { + let mut result = word.to_string(); + let limit = max_chars.min(word.len()); + + while !result.is_empty() + && !fits_in_width_for_display( + &result, + font_size, + orientation, + physical_width, + physical_height, + ) + { + result = result.chars().take(limit.saturating_sub(1)).collect(); + } + + result +} + #[cfg(test)] mod tests { use super::*;