Compare commits
7 Commits
replace-ru
...
main
Author | SHA1 | Date | |
---|---|---|---|
d1fc5713a1 | |||
d1a614fbbb | |||
2a49b081cb | |||
7e2dafa3d2 | |||
90cace679b | |||
99cbaf3b9f | |||
5de105960b |
158
.github/README.md
vendored
Normal file
158
.github/README.md
vendored
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
# GitHub Actions Workflows
|
||||||
|
|
||||||
|
This directory contains GitHub Actions workflows for automated CI/CD processes.
|
||||||
|
|
||||||
|
## Workflows Overview
|
||||||
|
|
||||||
|
### 🔨 `build.yml` - Build Desktop App
|
||||||
|
**Triggers:** Push to main/develop, Pull Requests, Releases
|
||||||
|
|
||||||
|
**Purpose:** Builds the desktop application for all supported platforms (macOS, Windows, Linux)
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Cross-platform builds (macOS Universal, Windows x64, Linux x64)
|
||||||
|
- Automatic artifact uploads
|
||||||
|
- Release asset publishing
|
||||||
|
- Caching for faster builds
|
||||||
|
|
||||||
|
**Artifacts:**
|
||||||
|
- **macOS**: DMG installer and .app bundle
|
||||||
|
- **Windows**: MSI and NSIS installers
|
||||||
|
- **Linux**: DEB package and AppImage
|
||||||
|
|
||||||
|
### 🧪 `ci.yml` - Continuous Integration
|
||||||
|
**Triggers:** Push to main/develop, Pull Requests
|
||||||
|
|
||||||
|
**Purpose:** Code quality checks and testing
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Frontend build verification
|
||||||
|
- Rust formatting and linting (rustfmt, clippy)
|
||||||
|
- Rust unit tests
|
||||||
|
- Security audits for both frontend and backend dependencies
|
||||||
|
|
||||||
|
### 🚀 `release.yml` - Manual Release
|
||||||
|
**Triggers:** Manual workflow dispatch
|
||||||
|
|
||||||
|
**Purpose:** Create tagged releases with built applications
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Manual version input
|
||||||
|
- Pre-release option
|
||||||
|
- Automatic release notes generation
|
||||||
|
- Cross-platform builds and uploads
|
||||||
|
- Comprehensive installation instructions
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
1. Go to Actions tab in GitHub
|
||||||
|
2. Select "Release" workflow
|
||||||
|
3. Click "Run workflow"
|
||||||
|
4. Enter version (e.g., v1.0.0)
|
||||||
|
5. Choose if it's a pre-release
|
||||||
|
6. Click "Run workflow"
|
||||||
|
|
||||||
|
### 🔄 `dependencies.yml` - Dependency Management
|
||||||
|
**Triggers:** Weekly schedule (Mondays 9 AM UTC), Manual dispatch
|
||||||
|
|
||||||
|
**Purpose:** Automated dependency updates and security monitoring
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Weekly dependency updates
|
||||||
|
- Automatic PR creation for updates
|
||||||
|
- Security vulnerability detection
|
||||||
|
- Automatic issue creation for security alerts
|
||||||
|
|
||||||
|
## Setup Requirements
|
||||||
|
|
||||||
|
### Repository Secrets
|
||||||
|
No additional secrets are required beyond the default `GITHUB_TOKEN`.
|
||||||
|
|
||||||
|
### Branch Protection (Recommended)
|
||||||
|
Configure branch protection rules for `main` branch:
|
||||||
|
- Require status checks to pass before merging
|
||||||
|
- Require branches to be up to date before merging
|
||||||
|
- Include status checks: `lint-and-test`, `security-audit`
|
||||||
|
|
||||||
|
### Release Process
|
||||||
|
|
||||||
|
#### Automated (Recommended)
|
||||||
|
1. Merge changes to `main` branch
|
||||||
|
2. Use the manual release workflow to create a new release
|
||||||
|
3. The workflow will automatically build and upload all platform binaries
|
||||||
|
|
||||||
|
#### Manual
|
||||||
|
1. Create a new tag: `git tag v1.0.0`
|
||||||
|
2. Push the tag: `git push origin v1.0.0`
|
||||||
|
3. Create a release on GitHub
|
||||||
|
4. The build workflow will automatically attach binaries
|
||||||
|
|
||||||
|
## Platform-Specific Notes
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
- Builds universal binaries (Intel + Apple Silicon)
|
||||||
|
- Requires macOS 13.0 or later
|
||||||
|
- DMG installer includes code signing (if certificates are configured)
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
- Builds for x64 architecture
|
||||||
|
- Provides both MSI and NSIS installers
|
||||||
|
- Compatible with Windows 10 and later
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
- Builds for x64 architecture
|
||||||
|
- Provides DEB package for Debian/Ubuntu
|
||||||
|
- Provides AppImage for universal Linux compatibility
|
||||||
|
- Requires WebKit2GTK and other system dependencies
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Build Failures
|
||||||
|
1. Check the specific platform logs in the Actions tab
|
||||||
|
2. Ensure all dependencies are properly declared
|
||||||
|
3. Verify Tauri configuration is correct
|
||||||
|
|
||||||
|
### Security Audit Failures
|
||||||
|
1. Review the security report in the workflow logs
|
||||||
|
2. Update vulnerable dependencies
|
||||||
|
3. Consider using `pnpm audit --fix` for frontend issues
|
||||||
|
4. Use `cargo update` for Rust dependency updates
|
||||||
|
|
||||||
|
### Cache Issues
|
||||||
|
If builds are failing due to cache corruption:
|
||||||
|
1. Go to Actions tab
|
||||||
|
2. Click on "Caches" in the sidebar
|
||||||
|
3. Delete relevant caches
|
||||||
|
4. Re-run the workflow
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
### Adding New Platforms
|
||||||
|
To add support for additional platforms, modify the `matrix` section in `build.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: 'macos-latest'
|
||||||
|
args: '--target aarch64-apple-darwin'
|
||||||
|
target: 'aarch64-apple-darwin'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modifying Build Steps
|
||||||
|
Each workflow can be customized by:
|
||||||
|
1. Adding new steps
|
||||||
|
2. Modifying existing commands
|
||||||
|
3. Adding environment variables
|
||||||
|
4. Configuring different Node.js/Rust versions
|
||||||
|
|
||||||
|
### Adding Code Quality Tools (Optional)
|
||||||
|
If you want to add code quality tools in the future:
|
||||||
|
1. **ESLint**: Add ESLint configuration and dependencies for JavaScript/TypeScript linting
|
||||||
|
2. **Prettier**: Add Prettier for consistent code formatting
|
||||||
|
3. **TypeScript strict checking**: Enable stricter TypeScript rules and type checking
|
||||||
|
|
||||||
|
### Changing Schedule
|
||||||
|
Modify the `cron` expression in `dependencies.yml` to change the update frequency:
|
||||||
|
```yaml
|
||||||
|
schedule:
|
||||||
|
- cron: '0 9 * * 1' # Every Monday at 9 AM UTC
|
||||||
|
```
|
124
.github/workflows/build.yml
vendored
Normal file
124
.github/workflows/build.yml
vendored
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
name: Build Desktop App
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, develop ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
release:
|
||||||
|
types: [ published ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUST_BACKTRACE: 1
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: 'macos-latest'
|
||||||
|
args: '--target universal-apple-darwin'
|
||||||
|
target: 'universal-apple-darwin'
|
||||||
|
- platform: 'ubuntu-22.04'
|
||||||
|
args: ''
|
||||||
|
target: 'x86_64-unknown-linux-gnu'
|
||||||
|
- platform: 'windows-latest'
|
||||||
|
args: ''
|
||||||
|
target: 'x86_64-pc-windows-msvc'
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.platform }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies (Ubuntu only)
|
||||||
|
if: matrix.platform == 'ubuntu-22.04'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
uses: swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: './src-tauri -> target'
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Build Tauri app
|
||||||
|
uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
args: ${{ matrix.args }}
|
||||||
|
|
||||||
|
- name: Upload artifacts (macOS)
|
||||||
|
if: matrix.platform == 'macos-latest'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: macos-app
|
||||||
|
path: |
|
||||||
|
src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg
|
||||||
|
src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app
|
||||||
|
|
||||||
|
- name: Upload artifacts (Linux)
|
||||||
|
if: matrix.platform == 'ubuntu-22.04'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: linux-app
|
||||||
|
path: |
|
||||||
|
src-tauri/target/release/bundle/deb/*.deb
|
||||||
|
src-tauri/target/release/bundle/appimage/*.AppImage
|
||||||
|
|
||||||
|
- name: Upload artifacts (Windows)
|
||||||
|
if: matrix.platform == 'windows-latest'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: windows-app
|
||||||
|
path: |
|
||||||
|
src-tauri/target/release/bundle/msi/*.msi
|
||||||
|
src-tauri/target/release/bundle/nsis/*.exe
|
||||||
|
|
||||||
|
release:
|
||||||
|
if: github.event_name == 'release'
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
|
||||||
|
- name: Display structure of downloaded files
|
||||||
|
run: ls -la
|
||||||
|
|
||||||
|
- name: Upload release assets
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
macos-app/**/*
|
||||||
|
linux-app/**/*
|
||||||
|
windows-app/**/*
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
99
.github/workflows/ci.yml
vendored
Normal file
99
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, develop ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-and-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
uses: swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: './src-tauri -> target'
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Check Rust formatting
|
||||||
|
run: cargo fmt --all --check
|
||||||
|
working-directory: src-tauri
|
||||||
|
|
||||||
|
- name: Lint Rust code
|
||||||
|
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
working-directory: src-tauri
|
||||||
|
|
||||||
|
- name: Run Rust tests
|
||||||
|
run: cargo test --all-features
|
||||||
|
working-directory: src-tauri
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Check Tauri build
|
||||||
|
run: cargo check --all-targets --all-features
|
||||||
|
working-directory: src-tauri
|
||||||
|
|
||||||
|
security-audit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Install cargo-audit
|
||||||
|
run: cargo install cargo-audit
|
||||||
|
|
||||||
|
- name: Run security audit
|
||||||
|
run: cargo audit
|
||||||
|
working-directory: src-tauri
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Run npm audit
|
||||||
|
run: pnpm audit --audit-level moderate
|
126
.github/workflows/dependencies.yml
vendored
Normal file
126
.github/workflows/dependencies.yml
vendored
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
name: Update Dependencies
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Run every Monday at 9:00 AM UTC
|
||||||
|
- cron: '0 9 * * 1'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-dependencies:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Install cargo-edit
|
||||||
|
run: cargo install cargo-edit
|
||||||
|
|
||||||
|
- name: Update frontend dependencies
|
||||||
|
run: |
|
||||||
|
pnpm update --latest
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
- name: Update Rust dependencies
|
||||||
|
run: |
|
||||||
|
cargo update
|
||||||
|
working-directory: src-tauri
|
||||||
|
|
||||||
|
- name: Check if build still works
|
||||||
|
run: |
|
||||||
|
pnpm build
|
||||||
|
cargo check --all-targets --all-features
|
||||||
|
working-directory: src-tauri
|
||||||
|
|
||||||
|
- name: Create Pull Request
|
||||||
|
uses: peter-evans/create-pull-request@v5
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
commit-message: 'chore: update dependencies'
|
||||||
|
title: 'chore: update dependencies'
|
||||||
|
body: |
|
||||||
|
## Automated Dependency Update
|
||||||
|
|
||||||
|
This PR updates all dependencies to their latest versions.
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Updated frontend dependencies via `pnpm update --latest`
|
||||||
|
- Updated Rust dependencies via `cargo update`
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- ✅ Frontend build passes
|
||||||
|
- ✅ Rust compilation check passes
|
||||||
|
|
||||||
|
Please review the changes and run full tests before merging.
|
||||||
|
branch: chore/update-dependencies
|
||||||
|
delete-branch: true
|
||||||
|
|
||||||
|
security-updates:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Install cargo-audit
|
||||||
|
run: cargo install cargo-audit
|
||||||
|
|
||||||
|
- name: Check for security vulnerabilities
|
||||||
|
run: |
|
||||||
|
echo "## Frontend Security Audit" >> security-report.md
|
||||||
|
pnpm audit --audit-level moderate >> security-report.md || true
|
||||||
|
|
||||||
|
echo "## Rust Security Audit" >> security-report.md
|
||||||
|
cd src-tauri
|
||||||
|
cargo audit >> ../security-report.md || true
|
||||||
|
|
||||||
|
- name: Create security issue if vulnerabilities found
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
const report = fs.readFileSync('security-report.md', 'utf8');
|
||||||
|
|
||||||
|
if (report.includes('vulnerabilities') || report.includes('RUSTSEC')) {
|
||||||
|
github.rest.issues.create({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
title: '🔒 Security vulnerabilities detected',
|
||||||
|
body: `## Security Audit Report\n\n\`\`\`\n${report}\n\`\`\`\n\nPlease review and update the affected dependencies.`,
|
||||||
|
labels: ['security', 'dependencies']
|
||||||
|
});
|
||||||
|
}
|
140
.github/workflows/release.yml
vendored
Normal file
140
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Release version (e.g., v1.0.0)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
prerelease:
|
||||||
|
description: 'Mark as pre-release'
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
create-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
release_id: ${{ steps.create_release.outputs.id }}
|
||||||
|
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
id: create_release
|
||||||
|
uses: actions/create-release@v1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
tag_name: ${{ github.event.inputs.version }}
|
||||||
|
release_name: Release ${{ github.event.inputs.version }}
|
||||||
|
draft: false
|
||||||
|
prerelease: ${{ github.event.inputs.prerelease }}
|
||||||
|
body: |
|
||||||
|
## Changes in this Release
|
||||||
|
|
||||||
|
- Auto-generated release for version ${{ github.event.inputs.version }}
|
||||||
|
|
||||||
|
## Downloads
|
||||||
|
|
||||||
|
Choose the appropriate installer for your operating system:
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
- **DMG**: Universal binary for Intel and Apple Silicon Macs
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
- **MSI**: Windows Installer package
|
||||||
|
- **EXE**: NSIS installer
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
- **DEB**: Debian/Ubuntu package
|
||||||
|
- **AppImage**: Portable application
|
||||||
|
|
||||||
|
## Installation Notes
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
1. Download the DMG file
|
||||||
|
2. Open the DMG and drag the app to Applications folder
|
||||||
|
3. On first launch, you may need to right-click and select "Open" due to Gatekeeper
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
1. Download the MSI or EXE installer
|
||||||
|
2. Run the installer as administrator
|
||||||
|
3. Follow the installation wizard
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
1. **DEB**: `sudo dpkg -i ambient-light-desktop_*.deb`
|
||||||
|
2. **AppImage**: Make executable and run directly
|
||||||
|
|
||||||
|
## System Requirements
|
||||||
|
|
||||||
|
- **macOS**: 13.0 or later
|
||||||
|
- **Windows**: Windows 10 or later
|
||||||
|
- **Linux**: Ubuntu 22.04 or equivalent
|
||||||
|
|
||||||
|
build-and-upload:
|
||||||
|
needs: create-release
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: 'macos-latest'
|
||||||
|
args: '--target universal-apple-darwin'
|
||||||
|
target: 'universal-apple-darwin'
|
||||||
|
- platform: 'ubuntu-22.04'
|
||||||
|
args: ''
|
||||||
|
target: 'x86_64-unknown-linux-gnu'
|
||||||
|
- platform: 'windows-latest'
|
||||||
|
args: ''
|
||||||
|
target: 'x86_64-pc-windows-msvc'
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.platform }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies (Ubuntu only)
|
||||||
|
if: matrix.platform == 'ubuntu-22.04'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
uses: swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: './src-tauri -> target'
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Build and release
|
||||||
|
uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||||
|
args: ${{ matrix.args }}
|
36
.gitignore
vendored
36
.gitignore
vendored
@ -1,3 +1,39 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/settings.json
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage/
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# ESLint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Prettier cache
|
||||||
|
.prettiercache
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
src-tauri/target/
|
||||||
|
src-tauri/Cargo.lock
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
@ -1,7 +0,0 @@
|
|||||||
node_modules
|
|
||||||
.DS_Store
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
node_modules/*
|
|
||||||
src-tauri
|
|
@ -1,8 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
semi: true,
|
|
||||||
trailingComma: "all",
|
|
||||||
singleQuote: true,
|
|
||||||
printWidth: 90,
|
|
||||||
tabWidth: 2,
|
|
||||||
endOfLine: "auto",
|
|
||||||
};
|
|
3
.vscode/tasks.json
vendored
3
.vscode/tasks.json
vendored
@ -12,9 +12,6 @@
|
|||||||
"tauri",
|
"tauri",
|
||||||
"dev"
|
"dev"
|
||||||
],
|
],
|
||||||
"problemMatcher": [
|
|
||||||
"$eslint-stylish"
|
|
||||||
],
|
|
||||||
"options": {
|
"options": {
|
||||||
"env": {
|
"env": {
|
||||||
"RUST_LOG": "info"
|
"RUST_LOG": "info"
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
# Display Ambient Light Desktop App
|
# Display Ambient Light Desktop App
|
||||||
|
|
||||||
|
[](https://github.com/USERNAME/REPOSITORY/actions/workflows/build.yml)
|
||||||
|
[](https://github.com/USERNAME/REPOSITORY/actions/workflows/ci.yml)
|
||||||
|
[](https://github.com/USERNAME/REPOSITORY/actions/workflows/release.yml)
|
||||||
|
|
||||||
A desktop application built with Tauri 2.0 for ambient light control, supporting multi-monitor screen sampling and LED strip control to create immersive ambient lighting effects.
|
A desktop application built with Tauri 2.0 for ambient light control, supporting multi-monitor screen sampling and LED strip control to create immersive ambient lighting effects.
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
99
docs/device-auto-refresh-implementation.md
Normal file
99
docs/device-auto-refresh-implementation.md
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
# LED Strip Test Device Auto-Refresh Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Implemented automatic refresh functionality for the device dropdown in the LED strip test interface. The device list now updates in real-time when devices are discovered, connected, or disconnected.
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### 1. Frontend Changes (`src/components/led-strip-test/led-strip-test.tsx`)
|
||||||
|
|
||||||
|
#### Added Event Listener Import
|
||||||
|
```typescript
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Enhanced Device Loading Logic
|
||||||
|
- **Initial Load**: Still loads devices on component mount using `get_boards()`
|
||||||
|
- **Real-time Updates**: Added listener for `boards_changed` events from backend
|
||||||
|
- **Smart Selection**: Automatically handles device selection when devices are added/removed:
|
||||||
|
- If current device disconnects, automatically selects first available device
|
||||||
|
- If no device was selected and devices become available, selects the first one
|
||||||
|
- Properly cleans up event listeners on component unmount
|
||||||
|
|
||||||
|
#### Improved UI Display
|
||||||
|
- **Device Count**: Shows number of devices found in label
|
||||||
|
- **Connection Status**: Each device option shows:
|
||||||
|
- Status icon (🟢 Connected, 🟡 Connecting, 🔴 Disconnected)
|
||||||
|
- Device name and address
|
||||||
|
- Connection status text
|
||||||
|
- **Empty State**: Shows "Searching..." when no devices found
|
||||||
|
|
||||||
|
#### Type Safety Improvements
|
||||||
|
- Updated `BoardInfo` interface to match backend types
|
||||||
|
- Proper handling of `connect_status` union type
|
||||||
|
- Type-safe status checking functions
|
||||||
|
|
||||||
|
### 2. Backend Integration
|
||||||
|
The implementation leverages existing backend infrastructure:
|
||||||
|
- **UdpRpc Manager**: Continuously searches for devices via mDNS
|
||||||
|
- **Device Monitoring**: Checks device connectivity every second
|
||||||
|
- **Event Broadcasting**: Sends `boards_changed` events to frontend
|
||||||
|
- **Status Tracking**: Maintains real-time connection status for each device
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Event Flow
|
||||||
|
1. Backend `UdpRpc` discovers devices via mDNS service discovery
|
||||||
|
2. Backend monitors device connectivity with periodic health checks
|
||||||
|
3. Backend broadcasts `boards_changed` events when device list changes
|
||||||
|
4. Frontend listens for events and updates UI automatically
|
||||||
|
5. Frontend handles device selection logic intelligently
|
||||||
|
|
||||||
|
### Connection Status Types
|
||||||
|
- `Connected`: Device is responding to ping requests
|
||||||
|
- `Connecting`: Device is in retry state (with retry count)
|
||||||
|
- `Disconnected`: Device is not responding
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- Graceful fallback if initial device load fails
|
||||||
|
- Proper cleanup of event listeners
|
||||||
|
- Maintains UI state consistency during device changes
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
1. **Real-time Updates**: No need to manually refresh device list
|
||||||
|
2. **Better UX**: Visual indicators for device status
|
||||||
|
3. **Automatic Recovery**: Handles device disconnections gracefully
|
||||||
|
4. **Type Safety**: Proper TypeScript types prevent runtime errors
|
||||||
|
5. **Performance**: Efficient event-driven updates instead of polling
|
||||||
|
|
||||||
|
## Implementation Status
|
||||||
|
✅ **Completed**: LED Strip Test device dropdown auto-refresh
|
||||||
|
✅ **Already Implemented**: Board Index page auto-refresh (was already working)
|
||||||
|
✅ **Type Safety**: Fixed TypeScript type definitions for BoardInfo
|
||||||
|
✅ **UI Improvements**: Added status indicators and device count display
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
To test the functionality:
|
||||||
|
1. Start the application with `npm run tauri dev`
|
||||||
|
2. Navigate to LED Strip Test page
|
||||||
|
3. Observe device list updates as devices come online/offline
|
||||||
|
4. Verify status indicators show correct connection states:
|
||||||
|
- 🟢 Connected devices
|
||||||
|
- 🟡 Connecting devices (with retry count)
|
||||||
|
- 🔴 Disconnected devices
|
||||||
|
5. Test device selection behavior when devices disconnect
|
||||||
|
6. Check that device count is displayed in the label
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
- ✅ No TypeScript errors
|
||||||
|
- ✅ Proper event listener cleanup
|
||||||
|
- ✅ Type-safe status checking
|
||||||
|
- ✅ Consistent with existing codebase patterns
|
||||||
|
- ✅ Follows SolidJS best practices
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
- Add device refresh button for manual refresh
|
||||||
|
- Show device discovery progress indicator
|
||||||
|
- Add device connection retry controls
|
||||||
|
- Display device ping latency information
|
||||||
|
- Add device connection history/logs
|
105
docs/device-auto-refresh-testing.md
Normal file
105
docs/device-auto-refresh-testing.md
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
# Device Auto-Refresh Testing Guide
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
### 1. Initial Load Test
|
||||||
|
**Expected Behavior**: Device list loads automatically when component mounts
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Start the application: `npm run tauri dev`
|
||||||
|
2. Navigate to LED Strip Test page
|
||||||
|
3. Observe the device dropdown
|
||||||
|
|
||||||
|
**Expected Results**:
|
||||||
|
- Device dropdown shows "Searching..." if no devices found
|
||||||
|
- Device dropdown shows device count if devices are found
|
||||||
|
- First available device is automatically selected
|
||||||
|
- Status icons appear next to device names
|
||||||
|
|
||||||
|
### 2. Device Discovery Test
|
||||||
|
**Expected Behavior**: New devices appear automatically when discovered
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Start with no devices connected
|
||||||
|
2. Connect a device to the network
|
||||||
|
3. Wait for device discovery (should be automatic)
|
||||||
|
|
||||||
|
**Expected Results**:
|
||||||
|
- Device count updates automatically
|
||||||
|
- New device appears in dropdown
|
||||||
|
- If no device was selected, new device gets selected automatically
|
||||||
|
- Status icon shows connection state
|
||||||
|
|
||||||
|
### 3. Device Disconnection Test
|
||||||
|
**Expected Behavior**: Disconnected devices are handled gracefully
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Start with connected devices
|
||||||
|
2. Select a device in the dropdown
|
||||||
|
3. Disconnect the selected device from network
|
||||||
|
4. Wait for connection timeout
|
||||||
|
|
||||||
|
**Expected Results**:
|
||||||
|
- Device status changes to disconnected (🔴)
|
||||||
|
- If device becomes unavailable, another device is selected automatically
|
||||||
|
- Device count updates
|
||||||
|
- UI remains responsive
|
||||||
|
|
||||||
|
### 4. Connection Status Test
|
||||||
|
**Expected Behavior**: Status indicators reflect actual device states
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Observe devices in different connection states
|
||||||
|
2. Check status icons and text
|
||||||
|
|
||||||
|
**Expected Results**:
|
||||||
|
- 🟢 "Connected" for responsive devices
|
||||||
|
- 🟡 "Connecting" for devices in retry state
|
||||||
|
- 🔴 "Disconnected" for unresponsive devices
|
||||||
|
- Status text matches icon state
|
||||||
|
|
||||||
|
### 5. UI Responsiveness Test
|
||||||
|
**Expected Behavior**: Interface remains responsive during device changes
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Rapidly connect/disconnect devices
|
||||||
|
2. Interact with other UI elements during device changes
|
||||||
|
3. Switch between pages and return
|
||||||
|
|
||||||
|
**Expected Results**:
|
||||||
|
- No UI freezing or lag
|
||||||
|
- Event listeners are properly cleaned up
|
||||||
|
- No memory leaks
|
||||||
|
- Smooth transitions
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
|
||||||
|
- [ ] Device dropdown shows correct device count
|
||||||
|
- [ ] Status icons display correctly (🟢🟡🔴)
|
||||||
|
- [ ] Automatic device selection works
|
||||||
|
- [ ] Event listeners are cleaned up on component unmount
|
||||||
|
- [ ] No TypeScript errors in console
|
||||||
|
- [ ] No runtime errors in console
|
||||||
|
- [ ] Performance remains good with multiple devices
|
||||||
|
- [ ] UI updates smoothly without flickering
|
||||||
|
|
||||||
|
## Common Issues to Watch For
|
||||||
|
|
||||||
|
1. **Memory Leaks**: Event listeners not cleaned up
|
||||||
|
2. **Type Errors**: Incorrect BoardInfo type handling
|
||||||
|
3. **Selection Logic**: Device selection not updating correctly
|
||||||
|
4. **Performance**: UI lag during rapid device changes
|
||||||
|
5. **State Consistency**: UI state not matching actual device state
|
||||||
|
|
||||||
|
## Debug Information
|
||||||
|
|
||||||
|
Check browser console for:
|
||||||
|
- `boards_changed` events
|
||||||
|
- Device list updates
|
||||||
|
- Selection changes
|
||||||
|
- Any error messages
|
||||||
|
|
||||||
|
Check Tauri logs for:
|
||||||
|
- Device discovery messages
|
||||||
|
- Connection status changes
|
||||||
|
- mDNS service events
|
378
docs/hardware-protocol.md
Normal file
378
docs/hardware-protocol.md
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
# LED Hardware Communication Protocol
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
UDP-based bidirectional protocol for communication between desktop application and ambient light hardware boards. The protocol supports LED color data transmission, device health monitoring, and remote control capabilities.
|
||||||
|
|
||||||
|
## Connection
|
||||||
|
|
||||||
|
- **Protocol**: UDP
|
||||||
|
- **Port**: 23042
|
||||||
|
- **Discovery**: mDNS (`_ambient_light._udp.local.`)
|
||||||
|
- **Example Board**: `192.168.31.206:23042`
|
||||||
|
|
||||||
|
## mDNS Service Discovery
|
||||||
|
|
||||||
|
### Service Registration (Hardware Side)
|
||||||
|
|
||||||
|
Hardware boards must register the following mDNS service:
|
||||||
|
|
||||||
|
- **Service Type**: `_ambient_light._udp.local.`
|
||||||
|
- **Port**: 23042
|
||||||
|
- **TXT Records**: Optional, can include device information
|
||||||
|
|
||||||
|
### Service Discovery (Desktop Side)
|
||||||
|
|
||||||
|
Desktop application continuously browses for `_ambient_light._udp.local.` services and automatically connects to discovered devices.
|
||||||
|
|
||||||
|
## Protocol Messages
|
||||||
|
|
||||||
|
The protocol uses different message headers to distinguish message types:
|
||||||
|
|
||||||
|
| Header | Direction | Purpose | Format |
|
||||||
|
|--------|-----------|---------|---------|
|
||||||
|
| 0x01 | Desktop → Hardware | Ping (Health Check) | `[0x01]` |
|
||||||
|
| 0x01 | Hardware → Desktop | Pong (Health Response) | `[0x01]` |
|
||||||
|
| 0x02 | Desktop → Hardware | LED Color Data | `[0x02][Offset_H][Offset_L][Color_Data...]` |
|
||||||
|
| 0x03 | Hardware → Desktop | Display Brightness Control | `[0x03][Display_Index][Brightness]` |
|
||||||
|
| 0x04 | Hardware → Desktop | Volume Control | `[0x04][Volume_Percent]` |
|
||||||
|
|
||||||
|
## Health Check Protocol (Ping/Pong)
|
||||||
|
|
||||||
|
### Desktop → Hardware (Ping)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Byte 0: Header (0x01)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hardware → Desktop (Pong)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Byte 0: Header (0x01)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
|
||||||
|
- Desktop sends ping every 1 second to each connected device
|
||||||
|
- Hardware must respond with pong within 1 second
|
||||||
|
- Timeout or incorrect response triggers reconnection logic
|
||||||
|
- After 10 failed attempts, device is marked as disconnected
|
||||||
|
|
||||||
|
## LED Color Data Protocol
|
||||||
|
|
||||||
|
### Packet Format
|
||||||
|
|
||||||
|
```text
|
||||||
|
Byte 0: Header (0x02)
|
||||||
|
Byte 1: Offset High (upper 8 bits of LED start position)
|
||||||
|
Byte 2: Offset Low (lower 8 bits of LED start position)
|
||||||
|
Byte 3+: LED Color Data (variable length)
|
||||||
|
```
|
||||||
|
|
||||||
|
## LED Color Data
|
||||||
|
|
||||||
|
### RGB LEDs (3 bytes per LED)
|
||||||
|
|
||||||
|
```text
|
||||||
|
[R][G][B][R][G][B][R][G][B]...
|
||||||
|
```
|
||||||
|
|
||||||
|
### RGBW LEDs (4 bytes per LED)
|
||||||
|
|
||||||
|
```text
|
||||||
|
[R][G][B][W][R][G][B][W][R][G][B][W]...
|
||||||
|
```
|
||||||
|
|
||||||
|
All values are 0-255.
|
||||||
|
|
||||||
|
## Color Calibration
|
||||||
|
|
||||||
|
Colors are calibrated before transmission:
|
||||||
|
|
||||||
|
**RGB:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
calibrated_r = (original_r * calibration_r) / 255
|
||||||
|
calibrated_g = (original_g * calibration_g) / 255
|
||||||
|
calibrated_b = (original_b * calibration_b) / 255
|
||||||
|
```
|
||||||
|
|
||||||
|
**RGBW:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
calibrated_r = (original_r * calibration_r) / 255
|
||||||
|
calibrated_g = (original_g * calibration_g) / 255
|
||||||
|
calibrated_b = (original_b * calibration_b) / 255
|
||||||
|
calibrated_w = calibration_w // Direct value
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hardware Control Protocol (Hardware → Desktop)
|
||||||
|
|
||||||
|
### Display Brightness Control
|
||||||
|
|
||||||
|
Hardware can send display brightness adjustment commands to the desktop:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Byte 0: Header (0x03)
|
||||||
|
Byte 1: Display Index (0-based display number)
|
||||||
|
Byte 2: Brightness (0-255, where 255 = 100% brightness)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:** Set display 0 to 50% brightness
|
||||||
|
|
||||||
|
```text
|
||||||
|
03 00 80
|
||||||
|
│ │ └─ Brightness (128 = ~50%)
|
||||||
|
│ └─ Display Index (0)
|
||||||
|
└─ Header (0x03)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Volume Control
|
||||||
|
|
||||||
|
Hardware can send system volume adjustment commands to the desktop:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Byte 0: Header (0x04)
|
||||||
|
Byte 1: Volume Percent (0-100)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:** Set system volume to 75%
|
||||||
|
|
||||||
|
```text
|
||||||
|
04 4B
|
||||||
|
│ └─ Volume (75%)
|
||||||
|
└─ Header (0x04)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Connection State Management
|
||||||
|
|
||||||
|
### Connection States
|
||||||
|
|
||||||
|
- **Unknown**: Initial state when device is first discovered
|
||||||
|
- **Connecting**: Device is being tested, includes retry count (1-10)
|
||||||
|
- **Connected**: Device is responding to ping requests normally
|
||||||
|
- **Disconnected**: Device failed to respond after 10 retry attempts
|
||||||
|
|
||||||
|
### State Transitions
|
||||||
|
|
||||||
|
```text
|
||||||
|
Unknown → Connecting(1) → Connected
|
||||||
|
↓ ↓ ↓
|
||||||
|
↓ Connecting(2-10) ↓
|
||||||
|
↓ ↓ ↓
|
||||||
|
└─→ Disconnected ←────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Retry Logic
|
||||||
|
|
||||||
|
1. **Initial Connection**: When device discovered via mDNS
|
||||||
|
2. **Health Check Failure**: If ping timeout or wrong response
|
||||||
|
3. **Retry Attempts**: Up to 10 attempts with 1-second intervals
|
||||||
|
4. **Disconnection**: After 10 failed attempts, mark as disconnected
|
||||||
|
5. **Recovery**: Disconnected devices continue to receive ping attempts
|
||||||
|
|
||||||
|
## Packet Examples
|
||||||
|
|
||||||
|
### RGB Example
|
||||||
|
|
||||||
|
3 RGB LEDs starting at position 0: Red, Green, Blue
|
||||||
|
|
||||||
|
```text
|
||||||
|
02 00 00 FF 00 00 00 FF 00 00 00 FF
|
||||||
|
│ │ │ └─────────────────────────── 9 bytes color data
|
||||||
|
│ │ └─ Offset Low (0)
|
||||||
|
│ └─ Offset High (0)
|
||||||
|
└─ Header (0x02)
|
||||||
|
```
|
||||||
|
|
||||||
|
### RGBW Example
|
||||||
|
|
||||||
|
2 RGBW LEDs starting at position 10: White, Warm White
|
||||||
|
|
||||||
|
```text
|
||||||
|
02 00 0A FF FF FF FF FF C8 96 C8
|
||||||
|
│ │ │ └─────────────────────── 8 bytes color data
|
||||||
|
│ │ └─ Offset Low (10)
|
||||||
|
│ └─ Offset High (0)
|
||||||
|
└─ Header (0x02)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- **Byte Order**: Big-endian for multi-byte values (offset field)
|
||||||
|
- **Delivery**: Fire-and-forget UDP (no acknowledgment required)
|
||||||
|
- **Hardware Role**: Simple UDP-to-WS2812 bridge, no data processing
|
||||||
|
- **LED Type Logic**: Handled entirely on desktop side, not hardware
|
||||||
|
- **Mixed Types**: Same display can have both RGB and RGBW strips
|
||||||
|
- **Data Flow**: Desktop → UDP → Hardware → WS2812 (direct forward)
|
||||||
|
|
||||||
|
## Hardware Implementation
|
||||||
|
|
||||||
|
The hardware board handles multiple protocol functions: UDP-to-WS2812 bridge for LED data, health monitoring, and optional control input capabilities.
|
||||||
|
|
||||||
|
### Required Functions
|
||||||
|
|
||||||
|
1. **mDNS Service Registration**: Advertise `_ambient_light._udp.local.` service
|
||||||
|
2. **UDP Server**: Listen on port 23042 for incoming packets
|
||||||
|
3. **Packet Processing**: Handle different message types based on header
|
||||||
|
4. **Health Monitoring**: Respond to ping requests with pong
|
||||||
|
5. **LED Control**: Forward color data to WS2812 strips
|
||||||
|
6. **Optional Control**: Send brightness/volume commands to desktop
|
||||||
|
|
||||||
|
### Packet Processing Logic
|
||||||
|
|
||||||
|
```c
|
||||||
|
void process_packet(uint8_t* data, size_t len) {
|
||||||
|
if (len < 1) return;
|
||||||
|
|
||||||
|
switch (data[0]) {
|
||||||
|
case 0x01: // Ping request
|
||||||
|
handle_ping(data, len);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 0x02: // LED color data
|
||||||
|
handle_led_data(data, len);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Unknown packet type, ignore
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void handle_ping(uint8_t* data, size_t len) {
|
||||||
|
if (len != 1) return;
|
||||||
|
|
||||||
|
// Respond with pong
|
||||||
|
uint8_t pong = 0x01;
|
||||||
|
udp_send_response(&pong, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void handle_led_data(uint8_t* data, size_t len) {
|
||||||
|
if (len < 3) return;
|
||||||
|
|
||||||
|
uint16_t offset = (data[1] << 8) | data[2];
|
||||||
|
uint8_t* color_data = &data[3];
|
||||||
|
size_t color_len = len - 3;
|
||||||
|
|
||||||
|
// Direct forward to WS2812 - no RGB/RGBW distinction needed
|
||||||
|
ws2812_update(offset, color_data, color_len);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional Control Features
|
||||||
|
|
||||||
|
Hardware can optionally send control commands to desktop:
|
||||||
|
|
||||||
|
```c
|
||||||
|
// Send display brightness control
|
||||||
|
void send_brightness_control(uint8_t display_index, uint8_t brightness) {
|
||||||
|
uint8_t packet[3] = {0x03, display_index, brightness};
|
||||||
|
udp_send_to_desktop(packet, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send volume control
|
||||||
|
void send_volume_control(uint8_t volume_percent) {
|
||||||
|
uint8_t packet[2] = {0x04, volume_percent};
|
||||||
|
udp_send_to_desktop(packet, 2);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Implementation Notes
|
||||||
|
|
||||||
|
- **Ping Response**: Must respond to ping (0x01) within 1 second
|
||||||
|
- **LED Data**: Direct forward to WS2812, no processing required
|
||||||
|
- **Control Commands**: Optional feature for hardware with input capabilities
|
||||||
|
- **mDNS Registration**: Essential for automatic device discovery
|
||||||
|
- **UDP Server**: Must handle concurrent connections from multiple desktops
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Device Discovery Issues
|
||||||
|
|
||||||
|
**Device Not Found**:
|
||||||
|
|
||||||
|
- Verify mDNS service registration on hardware
|
||||||
|
- Check service type: `_ambient_light._udp.local.`
|
||||||
|
- Ensure port 23042 is accessible
|
||||||
|
- Verify network connectivity between desktop and hardware
|
||||||
|
|
||||||
|
**Device Shows as Disconnected**:
|
||||||
|
|
||||||
|
- Check ping/pong response implementation
|
||||||
|
- Verify hardware responds to 0x01 packets within 1 second
|
||||||
|
- Monitor network latency and packet loss
|
||||||
|
- Check UDP server implementation on hardware
|
||||||
|
|
||||||
|
### LED Control Issues
|
||||||
|
|
||||||
|
**No LED Updates**:
|
||||||
|
|
||||||
|
- Verify hardware processes 0x02 packets correctly
|
||||||
|
- Check WS2812 wiring and power supply
|
||||||
|
- Monitor packet reception on hardware side
|
||||||
|
- Verify offset calculations and LED strip configuration
|
||||||
|
|
||||||
|
**Wrong Colors**:
|
||||||
|
|
||||||
|
- Check color calibration settings on desktop
|
||||||
|
- Verify RGB/RGBW data format matches LED strip type
|
||||||
|
- Monitor color data in packets (bytes 3+)
|
||||||
|
- Check WS2812 color order (GRB vs RGB)
|
||||||
|
|
||||||
|
**Flickering or Lag**:
|
||||||
|
|
||||||
|
- Monitor packet rate and network congestion
|
||||||
|
- Check power supply stability for LED strips
|
||||||
|
- Verify WS2812 data signal integrity
|
||||||
|
- Consider reducing update frequency
|
||||||
|
|
||||||
|
### Control Protocol Issues
|
||||||
|
|
||||||
|
**Brightness/Volume Control Not Working**:
|
||||||
|
|
||||||
|
- Verify hardware sends correct packet format (0x03/0x04)
|
||||||
|
- Check desktop receives and processes control packets
|
||||||
|
- Monitor packet transmission from hardware
|
||||||
|
- Verify display index and value ranges
|
||||||
|
|
||||||
|
### Connection State Issues
|
||||||
|
|
||||||
|
**Frequent Disconnections**:
|
||||||
|
|
||||||
|
- Check network stability and latency
|
||||||
|
- Verify ping response timing (< 1 second)
|
||||||
|
- Monitor retry logic and connection state transitions
|
||||||
|
- Check for UDP packet loss
|
||||||
|
|
||||||
|
**Stuck in Connecting State**:
|
||||||
|
|
||||||
|
- Verify ping/pong packet format
|
||||||
|
- Check hardware UDP server implementation
|
||||||
|
- Monitor ping response timing
|
||||||
|
- Verify network firewall settings
|
||||||
|
|
||||||
|
### Network Debugging
|
||||||
|
|
||||||
|
**Packet Monitoring**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Monitor UDP traffic on port 23042
|
||||||
|
tcpdump -i any -X port 23042
|
||||||
|
|
||||||
|
# Check mDNS service discovery
|
||||||
|
dns-sd -B _ambient_light._udp.local.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hardware Debug Output**:
|
||||||
|
|
||||||
|
- Log received packet headers and lengths
|
||||||
|
- Monitor ping/pong timing
|
||||||
|
- Track LED data processing
|
||||||
|
- Log mDNS service registration status
|
||||||
|
|
||||||
|
## Protocol Version
|
||||||
|
|
||||||
|
- **Current**: 1.0
|
||||||
|
- **Headers**: 0x01 (Ping/Pong), 0x02 (LED Data), 0x03 (Brightness), 0x04 (Volume)
|
||||||
|
- **Future**: Additional headers for new features, backward compatibility maintained
|
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@ -4432,6 +4432,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite",
|
||||||
|
"tokio-util",
|
||||||
"toml 0.7.8",
|
"toml 0.7.8",
|
||||||
"url-build-parse",
|
"url-build-parse",
|
||||||
]
|
]
|
||||||
|
@ -23,6 +23,7 @@ core-graphics = "0.22.3"
|
|||||||
display-info = "0.4.1"
|
display-info = "0.4.1"
|
||||||
anyhow = "1.0.69"
|
anyhow = "1.0.69"
|
||||||
tokio = {version = "1.26.0", features = ["full"] }
|
tokio = {version = "1.26.0", features = ["full"] }
|
||||||
|
tokio-util = "0.7"
|
||||||
paris = { version = "1.5", features = ["timestamps", "macros"] }
|
paris = { version = "1.5", features = ["timestamps", "macros"] }
|
||||||
log = "0.4.17"
|
log = "0.4.17"
|
||||||
env_logger = "0.10.0"
|
env_logger = "0.10.0"
|
||||||
|
@ -16,6 +16,18 @@ pub enum Border {
|
|||||||
Right,
|
Right,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Serialize, Deserialize, Debug, PartialEq)]
|
||||||
|
pub enum LedType {
|
||||||
|
RGB,
|
||||||
|
RGBW,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LedType {
|
||||||
|
fn default() -> Self {
|
||||||
|
LedType::RGB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
|
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
|
||||||
pub struct LedStripConfig {
|
pub struct LedStripConfig {
|
||||||
pub index: usize,
|
pub index: usize,
|
||||||
@ -23,6 +35,8 @@ pub struct LedStripConfig {
|
|||||||
pub display_id: u32,
|
pub display_id: u32,
|
||||||
pub start_pos: usize,
|
pub start_pos: usize,
|
||||||
pub len: usize,
|
pub len: usize,
|
||||||
|
#[serde(default)]
|
||||||
|
pub led_type: LedType,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
|
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
|
||||||
@ -30,6 +44,12 @@ pub struct ColorCalibration {
|
|||||||
r: f32,
|
r: f32,
|
||||||
g: f32,
|
g: f32,
|
||||||
b: f32,
|
b: f32,
|
||||||
|
#[serde(default = "default_w_value")]
|
||||||
|
w: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_w_value() -> f32 {
|
||||||
|
1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ColorCalibration {
|
impl ColorCalibration {
|
||||||
@ -40,6 +60,15 @@ impl ColorCalibration {
|
|||||||
(self.b * 255.0) as u8,
|
(self.b * 255.0) as u8,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn to_bytes_rgbw(&self) -> [u8; 4] {
|
||||||
|
[
|
||||||
|
(self.r * 255.0) as u8,
|
||||||
|
(self.g * 255.0) as u8,
|
||||||
|
(self.b * 255.0) as u8,
|
||||||
|
(self.w * 255.0) as u8,
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||||
@ -122,6 +151,7 @@ impl LedStripConfigGroup {
|
|||||||
},
|
},
|
||||||
start_pos: j + i * 4 * 30,
|
start_pos: j + i * 4 * 30,
|
||||||
len: 30,
|
len: 30,
|
||||||
|
led_type: LedType::RGB,
|
||||||
};
|
};
|
||||||
configs.push(item);
|
configs.push(item);
|
||||||
strips.push(item);
|
strips.push(item);
|
||||||
@ -136,6 +166,7 @@ impl LedStripConfigGroup {
|
|||||||
r: 1.0,
|
r: 1.0,
|
||||||
g: 1.0,
|
g: 1.0,
|
||||||
b: 1.0,
|
b: 1.0,
|
||||||
|
w: 1.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
@ -5,7 +5,7 @@ use tokio::{sync::OnceCell, task::yield_now};
|
|||||||
|
|
||||||
use crate::ambient_light::{config, LedStripConfigGroup};
|
use crate::ambient_light::{config, LedStripConfigGroup};
|
||||||
|
|
||||||
use super::{Border, SamplePointMapper, ColorCalibration};
|
use super::{Border, SamplePointMapper, ColorCalibration, LedType};
|
||||||
|
|
||||||
pub struct ConfigManager {
|
pub struct ConfigManager {
|
||||||
config: Arc<RwLock<LedStripConfigGroup>>,
|
config: Arc<RwLock<LedStripConfigGroup>>,
|
||||||
@ -94,6 +94,33 @@ impl ConfigManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn patch_led_strip_type(
|
||||||
|
&self,
|
||||||
|
display_id: u32,
|
||||||
|
border: Border,
|
||||||
|
led_type: LedType,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let mut config = self.config.write().await;
|
||||||
|
|
||||||
|
for strip in config.strips.iter_mut() {
|
||||||
|
if strip.display_id == display_id && strip.border == border {
|
||||||
|
strip.led_type = led_type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cloned_config = config.clone();
|
||||||
|
|
||||||
|
drop(config);
|
||||||
|
|
||||||
|
self.update(&cloned_config).await?;
|
||||||
|
|
||||||
|
self.config_update_sender
|
||||||
|
.send(cloned_config)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to send config update: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn move_strip_part(
|
pub async fn move_strip_part(
|
||||||
&self,
|
&self,
|
||||||
display_id: u32,
|
display_id: u32,
|
||||||
|
@ -18,7 +18,7 @@ use crate::{
|
|||||||
|
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
|
|
||||||
use super::{LedStripConfigGroup, SamplePointMapper};
|
use super::{LedStripConfigGroup, SamplePointMapper, LedStripConfig, ColorCalibration, LedType};
|
||||||
|
|
||||||
pub struct LedColorsPublisher {
|
pub struct LedColorsPublisher {
|
||||||
sorted_colors_rx: Arc<RwLock<watch::Receiver<Vec<u8>>>>,
|
sorted_colors_rx: Arc<RwLock<watch::Receiver<Vec<u8>>>>,
|
||||||
@ -26,6 +26,7 @@ pub struct LedColorsPublisher {
|
|||||||
colors_rx: Arc<RwLock<watch::Receiver<Vec<u8>>>>,
|
colors_rx: Arc<RwLock<watch::Receiver<Vec<u8>>>>,
|
||||||
colors_tx: Arc<RwLock<watch::Sender<Vec<u8>>>>,
|
colors_tx: Arc<RwLock<watch::Sender<Vec<u8>>>>,
|
||||||
inner_tasks_version: Arc<RwLock<usize>>,
|
inner_tasks_version: Arc<RwLock<usize>>,
|
||||||
|
test_mode_active: Arc<RwLock<bool>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LedColorsPublisher {
|
impl LedColorsPublisher {
|
||||||
@ -44,6 +45,7 @@ impl LedColorsPublisher {
|
|||||||
colors_rx: Arc::new(RwLock::new(rx)),
|
colors_rx: Arc::new(RwLock::new(rx)),
|
||||||
colors_tx: Arc::new(RwLock::new(tx)),
|
colors_tx: Arc::new(RwLock::new(tx)),
|
||||||
inner_tasks_version: Arc::new(RwLock::new(0)),
|
inner_tasks_version: Arc::new(RwLock::new(0)),
|
||||||
|
test_mode_active: Arc::new(RwLock::new(false)),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@ -56,6 +58,8 @@ impl LedColorsPublisher {
|
|||||||
bound_scale_factor: f32,
|
bound_scale_factor: f32,
|
||||||
mappers: Vec<SamplePointMapper>,
|
mappers: Vec<SamplePointMapper>,
|
||||||
display_colors_tx: broadcast::Sender<(u32, Vec<u8>)>,
|
display_colors_tx: broadcast::Sender<(u32, Vec<u8>)>,
|
||||||
|
strips: Vec<LedStripConfig>,
|
||||||
|
color_calibration: ColorCalibration,
|
||||||
) {
|
) {
|
||||||
let internal_tasks_version = self.inner_tasks_version.clone();
|
let internal_tasks_version = self.inner_tasks_version.clone();
|
||||||
let screenshot_manager = ScreenshotManager::global().await;
|
let screenshot_manager = ScreenshotManager::global().await;
|
||||||
@ -79,12 +83,20 @@ impl LedColorsPublisher {
|
|||||||
|
|
||||||
let mappers = mappers.clone();
|
let mappers = mappers.clone();
|
||||||
|
|
||||||
match Self::send_colors_by_display(colors, mappers).await {
|
// Check if test mode is active before sending normal colors
|
||||||
Ok(_) => {
|
let test_mode_active = {
|
||||||
// log::info!("sent colors: #{: >15}", display_id);
|
let publisher = LedColorsPublisher::global().await;
|
||||||
}
|
*publisher.test_mode_active.read().await
|
||||||
Err(err) => {
|
};
|
||||||
warn!("Failed to send colors: #{: >15}\t{}", display_id, err);
|
|
||||||
|
if !test_mode_active {
|
||||||
|
match Self::send_colors_by_display(colors, mappers, &strips, &color_calibration).await {
|
||||||
|
Ok(_) => {
|
||||||
|
// log::info!("sent colors: #{: >15}", display_id);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!("Failed to send colors: #{: >15}\t{}", display_id, err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -209,9 +221,9 @@ impl LedColorsPublisher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_config_change(&self, configs: LedStripConfigGroup) {
|
async fn handle_config_change(&self, original_configs: LedStripConfigGroup) {
|
||||||
let inner_tasks_version = self.inner_tasks_version.clone();
|
let inner_tasks_version = self.inner_tasks_version.clone();
|
||||||
let configs = Self::get_colors_configs(&configs).await;
|
let configs = Self::get_colors_configs(&original_configs).await;
|
||||||
|
|
||||||
if let Err(err) = configs {
|
if let Err(err) = configs {
|
||||||
warn!("Failed to get configs: {}", err);
|
warn!("Failed to get configs: {}", err);
|
||||||
@ -231,12 +243,22 @@ impl LedColorsPublisher {
|
|||||||
let display_id = sample_point_group.display_id;
|
let display_id = sample_point_group.display_id;
|
||||||
let sample_points = sample_point_group.points;
|
let sample_points = sample_point_group.points;
|
||||||
let bound_scale_factor = sample_point_group.bound_scale_factor;
|
let bound_scale_factor = sample_point_group.bound_scale_factor;
|
||||||
|
|
||||||
|
// Get strips for this display
|
||||||
|
let display_strips: Vec<LedStripConfig> = original_configs.strips
|
||||||
|
.iter()
|
||||||
|
.filter(|strip| strip.display_id == display_id)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
self.start_one_display_colors_fetcher(
|
self.start_one_display_colors_fetcher(
|
||||||
display_id,
|
display_id,
|
||||||
sample_points,
|
sample_points,
|
||||||
bound_scale_factor,
|
bound_scale_factor,
|
||||||
sample_point_group.mappers,
|
sample_point_group.mappers,
|
||||||
display_colors_tx.clone(),
|
display_colors_tx.clone(),
|
||||||
|
display_strips,
|
||||||
|
original_configs.color_calibration,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@ -266,6 +288,8 @@ impl LedColorsPublisher {
|
|||||||
pub async fn send_colors_by_display(
|
pub async fn send_colors_by_display(
|
||||||
colors: Vec<LedColor>,
|
colors: Vec<LedColor>,
|
||||||
mappers: Vec<SamplePointMapper>,
|
mappers: Vec<SamplePointMapper>,
|
||||||
|
strips: &[LedStripConfig],
|
||||||
|
color_calibration: &ColorCalibration,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
// let color_len = colors.len();
|
// let color_len = colors.len();
|
||||||
let display_led_offset = mappers
|
let display_led_offset = mappers
|
||||||
@ -282,7 +306,7 @@ impl LedColorsPublisher {
|
|||||||
let udp_rpc = udp_rpc.as_ref().unwrap();
|
let udp_rpc = udp_rpc.as_ref().unwrap();
|
||||||
|
|
||||||
// let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
// let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||||
for group in mappers.clone() {
|
for (group_index, group) in mappers.clone().iter().enumerate() {
|
||||||
if (group.start.abs_diff(group.end)) > colors.len() {
|
if (group.start.abs_diff(group.end)) > colors.len() {
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"get_sorted_colors: color_index out of range. color_index: {}, strip len: {}, colors.len(): {}",
|
"get_sorted_colors: color_index out of range. color_index: {}, strip len: {}, colors.len(): {}",
|
||||||
@ -293,7 +317,20 @@ impl LedColorsPublisher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let group_size = group.start.abs_diff(group.end);
|
let group_size = group.start.abs_diff(group.end);
|
||||||
let mut buffer = Vec::<u8>::with_capacity(group_size * 3);
|
|
||||||
|
// Find the corresponding LED strip config to get LED type
|
||||||
|
let led_type = if group_index < strips.len() {
|
||||||
|
strips[group_index].led_type
|
||||||
|
} else {
|
||||||
|
LedType::RGB // fallback to RGB
|
||||||
|
};
|
||||||
|
|
||||||
|
let bytes_per_led = match led_type {
|
||||||
|
LedType::RGB => 3,
|
||||||
|
LedType::RGBW => 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut buffer = Vec::<u8>::with_capacity(group_size * bytes_per_led);
|
||||||
|
|
||||||
if group.end > group.start {
|
if group.end > group.start {
|
||||||
// Prevent integer underflow by using saturating subtraction
|
// Prevent integer underflow by using saturating subtraction
|
||||||
@ -310,12 +347,37 @@ impl LedColorsPublisher {
|
|||||||
|
|
||||||
for i in start_index..end_index {
|
for i in start_index..end_index {
|
||||||
if i < colors.len() {
|
if i < colors.len() {
|
||||||
let bytes = colors[i].as_bytes();
|
let bytes = match led_type {
|
||||||
buffer.append(&mut bytes.to_vec());
|
LedType::RGB => {
|
||||||
|
let calibration_bytes = color_calibration.to_bytes();
|
||||||
|
let color_bytes = colors[i].as_bytes();
|
||||||
|
// Apply calibration to RGB values
|
||||||
|
vec![
|
||||||
|
((color_bytes[0] as f32 * calibration_bytes[0] as f32 / 255.0) as u8),
|
||||||
|
((color_bytes[1] as f32 * calibration_bytes[1] as f32 / 255.0) as u8),
|
||||||
|
((color_bytes[2] as f32 * calibration_bytes[2] as f32 / 255.0) as u8),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
LedType::RGBW => {
|
||||||
|
let calibration_bytes = color_calibration.to_bytes_rgbw();
|
||||||
|
let color_bytes = colors[i].as_bytes();
|
||||||
|
// Apply calibration to RGB values and use calibrated W
|
||||||
|
vec![
|
||||||
|
((color_bytes[0] as f32 * calibration_bytes[0] as f32 / 255.0) as u8),
|
||||||
|
((color_bytes[1] as f32 * calibration_bytes[1] as f32 / 255.0) as u8),
|
||||||
|
((color_bytes[2] as f32 * calibration_bytes[2] as f32 / 255.0) as u8),
|
||||||
|
calibration_bytes[3], // W channel
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
buffer.extend_from_slice(&bytes);
|
||||||
} else {
|
} else {
|
||||||
log::warn!("Index {} out of bounds for colors array of length {}", i, colors.len());
|
log::warn!("Index {} out of bounds for colors array of length {}", i, colors.len());
|
||||||
// Add black color as fallback
|
// Add black color as fallback
|
||||||
buffer.append(&mut vec![0, 0, 0]);
|
match led_type {
|
||||||
|
LedType::RGB => buffer.extend_from_slice(&[0, 0, 0]),
|
||||||
|
LedType::RGBW => buffer.extend_from_slice(&[0, 0, 0, 0]),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -333,12 +395,37 @@ impl LedColorsPublisher {
|
|||||||
|
|
||||||
for i in (start_index..end_index).rev() {
|
for i in (start_index..end_index).rev() {
|
||||||
if i < colors.len() {
|
if i < colors.len() {
|
||||||
let bytes = colors[i].as_bytes();
|
let bytes = match led_type {
|
||||||
buffer.append(&mut bytes.to_vec());
|
LedType::RGB => {
|
||||||
|
let calibration_bytes = color_calibration.to_bytes();
|
||||||
|
let color_bytes = colors[i].as_bytes();
|
||||||
|
// Apply calibration to RGB values
|
||||||
|
vec![
|
||||||
|
((color_bytes[0] as f32 * calibration_bytes[0] as f32 / 255.0) as u8),
|
||||||
|
((color_bytes[1] as f32 * calibration_bytes[1] as f32 / 255.0) as u8),
|
||||||
|
((color_bytes[2] as f32 * calibration_bytes[2] as f32 / 255.0) as u8),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
LedType::RGBW => {
|
||||||
|
let calibration_bytes = color_calibration.to_bytes_rgbw();
|
||||||
|
let color_bytes = colors[i].as_bytes();
|
||||||
|
// Apply calibration to RGB values and use calibrated W
|
||||||
|
vec![
|
||||||
|
((color_bytes[0] as f32 * calibration_bytes[0] as f32 / 255.0) as u8),
|
||||||
|
((color_bytes[1] as f32 * calibration_bytes[1] as f32 / 255.0) as u8),
|
||||||
|
((color_bytes[2] as f32 * calibration_bytes[2] as f32 / 255.0) as u8),
|
||||||
|
calibration_bytes[3], // W channel
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
buffer.extend_from_slice(&bytes);
|
||||||
} else {
|
} else {
|
||||||
log::warn!("Index {} out of bounds for colors array of length {}", i, colors.len());
|
log::warn!("Index {} out of bounds for colors array of length {}", i, colors.len());
|
||||||
// Add black color as fallback
|
// Add black color as fallback
|
||||||
buffer.append(&mut vec![0, 0, 0]);
|
match led_type {
|
||||||
|
LedType::RGB => buffer.extend_from_slice(&[0, 0, 0]),
|
||||||
|
LedType::RGBW => buffer.extend_from_slice(&[0, 0, 0, 0]),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -455,6 +542,35 @@ impl LedColorsPublisher {
|
|||||||
pub async fn clone_colors_receiver(&self) -> watch::Receiver<Vec<u8>> {
|
pub async fn clone_colors_receiver(&self) -> watch::Receiver<Vec<u8>> {
|
||||||
self.colors_rx.read().await.clone()
|
self.colors_rx.read().await.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enable test mode - this will pause normal LED data publishing
|
||||||
|
pub async fn enable_test_mode(&self) {
|
||||||
|
let mut test_mode = self.test_mode_active.write().await;
|
||||||
|
*test_mode = true;
|
||||||
|
log::info!("Test mode enabled - normal LED publishing paused");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disable test mode - this will resume normal LED data publishing
|
||||||
|
pub async fn disable_test_mode(&self) {
|
||||||
|
let mut test_mode = self.test_mode_active.write().await;
|
||||||
|
*test_mode = false;
|
||||||
|
log::info!("Test mode disabled - normal LED publishing resumed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disable test mode with a delay to ensure clean transition
|
||||||
|
pub async fn disable_test_mode_with_delay(&self, delay_ms: u64) {
|
||||||
|
// Wait for the specified delay
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
|
||||||
|
|
||||||
|
let mut test_mode = self.test_mode_active.write().await;
|
||||||
|
*test_mode = false;
|
||||||
|
log::info!("Test mode disabled with delay - normal LED publishing resumed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if test mode is currently active
|
||||||
|
pub async fn is_test_mode_active(&self) -> bool {
|
||||||
|
*self.test_mode_active.read().await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
@ -43,6 +43,10 @@ impl LedColor {
|
|||||||
pub fn as_bytes (&self) -> [u8; 3] {
|
pub fn as_bytes (&self) -> [u8; 3] {
|
||||||
self.0
|
self.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn as_bytes_rgbw(&self, w: u8) -> [u8; 4] {
|
||||||
|
[self.0[0], self.0[1], self.0[2], w]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Serialize for LedColor {
|
impl Serialize for LedColor {
|
||||||
|
239
src-tauri/src/led_test_effects.rs
Normal file
239
src-tauri/src/led_test_effects.rs
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
use std::f64::consts::PI;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum TestEffectType {
|
||||||
|
FlowingRainbow,
|
||||||
|
GroupCounting,
|
||||||
|
SingleScan,
|
||||||
|
Breathing,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TestEffectConfig {
|
||||||
|
pub effect_type: TestEffectType,
|
||||||
|
pub led_count: u32,
|
||||||
|
pub led_type: LedType,
|
||||||
|
pub speed: f64, // Speed multiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum LedType {
|
||||||
|
RGB,
|
||||||
|
RGBW,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LedTestEffects;
|
||||||
|
|
||||||
|
impl LedTestEffects {
|
||||||
|
/// Generate LED colors for a specific test effect at a given time
|
||||||
|
pub fn generate_colors(config: &TestEffectConfig, time_ms: u64) -> Vec<u8> {
|
||||||
|
let time_seconds = time_ms as f64 / 1000.0;
|
||||||
|
|
||||||
|
match config.effect_type {
|
||||||
|
TestEffectType::FlowingRainbow => {
|
||||||
|
Self::flowing_rainbow(config.led_count, config.led_type.clone(), time_seconds, config.speed)
|
||||||
|
}
|
||||||
|
TestEffectType::GroupCounting => {
|
||||||
|
Self::group_counting(config.led_count, config.led_type.clone())
|
||||||
|
}
|
||||||
|
TestEffectType::SingleScan => {
|
||||||
|
Self::single_scan(config.led_count, config.led_type.clone(), time_seconds, config.speed)
|
||||||
|
}
|
||||||
|
TestEffectType::Breathing => {
|
||||||
|
Self::breathing(config.led_count, config.led_type.clone(), time_seconds, config.speed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flowing rainbow effect - smooth rainbow colors flowing along the strip
|
||||||
|
fn flowing_rainbow(led_count: u32, led_type: LedType, time: f64, speed: f64) -> Vec<u8> {
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
let time_offset = (time * speed * 60.0) % 360.0; // 60 degrees per second at speed 1.0
|
||||||
|
|
||||||
|
for i in 0..led_count {
|
||||||
|
// Create longer wavelength for smoother color transitions
|
||||||
|
let hue = ((i as f64 * 720.0 / led_count as f64) + time_offset) % 360.0;
|
||||||
|
let rgb = Self::hsv_to_rgb(hue, 1.0, 1.0);
|
||||||
|
|
||||||
|
buffer.push(rgb.0);
|
||||||
|
buffer.push(rgb.1);
|
||||||
|
buffer.push(rgb.2);
|
||||||
|
|
||||||
|
if matches!(led_type, LedType::RGBW) {
|
||||||
|
buffer.push(0); // White channel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group counting effect - every 10 LEDs have different colors
|
||||||
|
fn group_counting(led_count: u32, led_type: LedType) -> Vec<u8> {
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let group_colors = [
|
||||||
|
(255, 0, 0), // Red (1-10)
|
||||||
|
(0, 255, 0), // Green (11-20)
|
||||||
|
(0, 0, 255), // Blue (21-30)
|
||||||
|
(255, 255, 0), // Yellow (31-40)
|
||||||
|
(255, 0, 255), // Magenta (41-50)
|
||||||
|
(0, 255, 255), // Cyan (51-60)
|
||||||
|
(255, 128, 0), // Orange (61-70)
|
||||||
|
(128, 255, 0), // Lime (71-80)
|
||||||
|
(255, 255, 255), // White (81-90)
|
||||||
|
(128, 128, 128), // Gray (91-100)
|
||||||
|
];
|
||||||
|
|
||||||
|
for i in 0..led_count {
|
||||||
|
let group_index = (i / 10) % group_colors.len() as u32;
|
||||||
|
let color = group_colors[group_index as usize];
|
||||||
|
|
||||||
|
buffer.push(color.0);
|
||||||
|
buffer.push(color.1);
|
||||||
|
buffer.push(color.2);
|
||||||
|
|
||||||
|
if matches!(led_type, LedType::RGBW) {
|
||||||
|
buffer.push(0); // White channel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single LED scan effect - one LED moves along the strip
|
||||||
|
fn single_scan(led_count: u32, led_type: LedType, time: f64, speed: f64) -> Vec<u8> {
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
let scan_period = 2.0 / speed; // 2 seconds per full scan at speed 1.0
|
||||||
|
let active_index = ((time / scan_period * led_count as f64) as u32) % led_count;
|
||||||
|
|
||||||
|
for i in 0..led_count {
|
||||||
|
if i == active_index {
|
||||||
|
// Bright white LED
|
||||||
|
buffer.push(255);
|
||||||
|
buffer.push(255);
|
||||||
|
buffer.push(255);
|
||||||
|
|
||||||
|
if matches!(led_type, LedType::RGBW) {
|
||||||
|
buffer.push(255); // White channel
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Off
|
||||||
|
buffer.push(0);
|
||||||
|
buffer.push(0);
|
||||||
|
buffer.push(0);
|
||||||
|
|
||||||
|
if matches!(led_type, LedType::RGBW) {
|
||||||
|
buffer.push(0); // White channel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Breathing effect - entire strip breathes with white light
|
||||||
|
fn breathing(led_count: u32, led_type: LedType, time: f64, speed: f64) -> Vec<u8> {
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
let breathing_period = 4.0 / speed; // 4 seconds per breath at speed 1.0
|
||||||
|
let brightness = ((time / breathing_period * 2.0 * PI).sin() * 0.5 + 0.5) * 255.0;
|
||||||
|
let brightness = brightness as u8;
|
||||||
|
|
||||||
|
for _i in 0..led_count {
|
||||||
|
buffer.push(brightness);
|
||||||
|
buffer.push(brightness);
|
||||||
|
buffer.push(brightness);
|
||||||
|
|
||||||
|
if matches!(led_type, LedType::RGBW) {
|
||||||
|
buffer.push(brightness); // White channel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert HSV to RGB
|
||||||
|
/// H: 0-360, S: 0-1, V: 0-1
|
||||||
|
/// Returns: (R, G, B) where each component is 0-255
|
||||||
|
fn hsv_to_rgb(h: f64, s: f64, v: f64) -> (u8, u8, u8) {
|
||||||
|
let c = v * s;
|
||||||
|
let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
|
||||||
|
let m = v - c;
|
||||||
|
|
||||||
|
let (r_prime, g_prime, b_prime) = if h < 60.0 {
|
||||||
|
(c, x, 0.0)
|
||||||
|
} else if h < 120.0 {
|
||||||
|
(x, c, 0.0)
|
||||||
|
} else if h < 180.0 {
|
||||||
|
(0.0, c, x)
|
||||||
|
} else if h < 240.0 {
|
||||||
|
(0.0, x, c)
|
||||||
|
} else if h < 300.0 {
|
||||||
|
(x, 0.0, c)
|
||||||
|
} else {
|
||||||
|
(c, 0.0, x)
|
||||||
|
};
|
||||||
|
|
||||||
|
let r = ((r_prime + m) * 255.0).round() as u8;
|
||||||
|
let g = ((g_prime + m) * 255.0).round() as u8;
|
||||||
|
let b = ((b_prime + m) * 255.0).round() as u8;
|
||||||
|
|
||||||
|
(r, g, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hsv_to_rgb() {
|
||||||
|
// Test red
|
||||||
|
let (r, g, b) = LedTestEffects::hsv_to_rgb(0.0, 1.0, 1.0);
|
||||||
|
assert_eq!((r, g, b), (255, 0, 0));
|
||||||
|
|
||||||
|
// Test green
|
||||||
|
let (r, g, b) = LedTestEffects::hsv_to_rgb(120.0, 1.0, 1.0);
|
||||||
|
assert_eq!((r, g, b), (0, 255, 0));
|
||||||
|
|
||||||
|
// Test blue
|
||||||
|
let (r, g, b) = LedTestEffects::hsv_to_rgb(240.0, 1.0, 1.0);
|
||||||
|
assert_eq!((r, g, b), (0, 0, 255));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_flowing_rainbow() {
|
||||||
|
let config = TestEffectConfig {
|
||||||
|
effect_type: TestEffectType::FlowingRainbow,
|
||||||
|
led_count: 10,
|
||||||
|
led_type: LedType::RGB,
|
||||||
|
speed: 1.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let colors = LedTestEffects::generate_colors(&config, 0);
|
||||||
|
assert_eq!(colors.len(), 30); // 10 LEDs * 3 bytes each
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_group_counting() {
|
||||||
|
let config = TestEffectConfig {
|
||||||
|
effect_type: TestEffectType::GroupCounting,
|
||||||
|
led_count: 20,
|
||||||
|
led_type: LedType::RGB,
|
||||||
|
speed: 1.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let colors = LedTestEffects::generate_colors(&config, 0);
|
||||||
|
assert_eq!(colors.len(), 60); // 20 LEDs * 3 bytes each
|
||||||
|
|
||||||
|
// First 10 should be red
|
||||||
|
assert_eq!(colors[0], 255); // R
|
||||||
|
assert_eq!(colors[1], 0); // G
|
||||||
|
assert_eq!(colors[2], 0); // B
|
||||||
|
|
||||||
|
// Next 10 should be green
|
||||||
|
assert_eq!(colors[30], 0); // R
|
||||||
|
assert_eq!(colors[31], 255); // G
|
||||||
|
assert_eq!(colors[32], 0); // B
|
||||||
|
}
|
||||||
|
}
|
@ -4,15 +4,17 @@
|
|||||||
mod ambient_light;
|
mod ambient_light;
|
||||||
mod display;
|
mod display;
|
||||||
mod led_color;
|
mod led_color;
|
||||||
|
mod led_test_effects;
|
||||||
mod rpc;
|
mod rpc;
|
||||||
mod screenshot;
|
mod screenshot;
|
||||||
mod screenshot_manager;
|
mod screenshot_manager;
|
||||||
mod screen_stream;
|
mod screen_stream;
|
||||||
mod volume;
|
mod volume;
|
||||||
|
|
||||||
use ambient_light::{Border, ColorCalibration, LedStripConfig, LedStripConfigGroup};
|
use ambient_light::{Border, ColorCalibration, LedStripConfig, LedStripConfigGroup, LedType};
|
||||||
use display::{DisplayManager, DisplayState};
|
use display::{DisplayManager, DisplayState};
|
||||||
use display_info::DisplayInfo;
|
use display_info::DisplayInfo;
|
||||||
|
use led_test_effects::{LedTestEffects, TestEffectConfig, TestEffectType};
|
||||||
use paris::{error, info, warn};
|
use paris::{error, info, warn};
|
||||||
use rpc::{BoardInfo, UdpRpc};
|
use rpc::{BoardInfo, UdpRpc};
|
||||||
use screenshot::Screenshot;
|
use screenshot::Screenshot;
|
||||||
@ -24,6 +26,14 @@ use tauri::{Manager, Emitter, Runtime};
|
|||||||
use regex;
|
use regex;
|
||||||
use tauri::http::{Request, Response};
|
use tauri::http::{Request, Response};
|
||||||
use volume::VolumeManager;
|
use volume::VolumeManager;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
// Global static variables for LED test effect management
|
||||||
|
static EFFECT_HANDLE: tokio::sync::OnceCell<Arc<RwLock<Option<tokio::task::JoinHandle<()>>>>> =
|
||||||
|
tokio::sync::OnceCell::const_new();
|
||||||
|
static CANCEL_TOKEN: tokio::sync::OnceCell<Arc<RwLock<Option<tokio_util::sync::CancellationToken>>>> =
|
||||||
|
tokio::sync::OnceCell::const_new();
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
#[serde(remote = "DisplayInfo")]
|
#[serde(remote = "DisplayInfo")]
|
||||||
struct DisplayInfoDef {
|
struct DisplayInfoDef {
|
||||||
@ -138,6 +148,20 @@ async fn patch_led_strip_len(display_id: u32, border: Border, delta_len: i8) ->
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn patch_led_strip_type(display_id: u32, border: Border, led_type: LedType) -> Result<(), String> {
|
||||||
|
let config_manager = ambient_light::ConfigManager::global().await;
|
||||||
|
config_manager
|
||||||
|
.patch_led_strip_type(display_id, border, led_type)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
error!("can not patch led strip type: {}", e);
|
||||||
|
e.to_string()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn send_colors(offset: u16, buffer: Vec<u8>) -> Result<(), String> {
|
async fn send_colors(offset: u16, buffer: Vec<u8>) -> Result<(), String> {
|
||||||
ambient_light::LedColorsPublisher::send_colors(offset, buffer)
|
ambient_light::LedColorsPublisher::send_colors(offset, buffer)
|
||||||
@ -148,6 +172,193 @@ async fn send_colors(offset: u16, buffer: Vec<u8>) -> Result<(), String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn send_test_colors_to_board(board_address: String, offset: u16, buffer: Vec<u8>) -> Result<(), String> {
|
||||||
|
use tokio::net::UdpSocket;
|
||||||
|
|
||||||
|
let socket = UdpSocket::bind("0.0.0.0:0").await.map_err(|e| {
|
||||||
|
error!("Failed to bind UDP socket: {}", e);
|
||||||
|
e.to_string()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut packet = vec![0x02]; // Header
|
||||||
|
packet.push((offset >> 8) as u8); // Offset high
|
||||||
|
packet.push((offset & 0xff) as u8); // Offset low
|
||||||
|
packet.extend_from_slice(&buffer); // Color data
|
||||||
|
|
||||||
|
socket.send_to(&packet, &board_address).await.map_err(|e| {
|
||||||
|
error!("Failed to send test colors to board {}: {}", board_address, e);
|
||||||
|
e.to_string()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
info!("Sent test colors to board {} with offset {} and {} bytes", board_address, offset, buffer.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn enable_test_mode() -> Result<(), String> {
|
||||||
|
let publisher = ambient_light::LedColorsPublisher::global().await;
|
||||||
|
publisher.enable_test_mode().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn disable_test_mode() -> Result<(), String> {
|
||||||
|
info!("🔄 disable_test_mode command called from frontend");
|
||||||
|
let publisher = ambient_light::LedColorsPublisher::global().await;
|
||||||
|
publisher.disable_test_mode().await;
|
||||||
|
info!("✅ disable_test_mode command completed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn is_test_mode_active() -> Result<bool, String> {
|
||||||
|
let publisher = ambient_light::LedColorsPublisher::global().await;
|
||||||
|
Ok(publisher.is_test_mode_active().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn start_led_test_effect(
|
||||||
|
board_address: String,
|
||||||
|
effect_config: TestEffectConfig,
|
||||||
|
update_interval_ms: u64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use tokio::time::{interval, Duration};
|
||||||
|
|
||||||
|
// Enable test mode first
|
||||||
|
let publisher = ambient_light::LedColorsPublisher::global().await;
|
||||||
|
publisher.enable_test_mode().await;
|
||||||
|
|
||||||
|
let handle_storage = EFFECT_HANDLE.get_or_init(|| async {
|
||||||
|
Arc::new(RwLock::new(None))
|
||||||
|
}).await;
|
||||||
|
|
||||||
|
let cancel_storage = CANCEL_TOKEN.get_or_init(|| async {
|
||||||
|
Arc::new(RwLock::new(None))
|
||||||
|
}).await;
|
||||||
|
|
||||||
|
// Stop any existing effect
|
||||||
|
{
|
||||||
|
let mut cancel_guard = cancel_storage.write().await;
|
||||||
|
if let Some(token) = cancel_guard.take() {
|
||||||
|
token.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut handle_guard = handle_storage.write().await;
|
||||||
|
if let Some(handle) = handle_guard.take() {
|
||||||
|
let _ = handle.await; // Wait for graceful shutdown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new effect
|
||||||
|
let effect_config = Arc::new(effect_config);
|
||||||
|
let board_address = Arc::new(board_address);
|
||||||
|
let start_time = std::time::Instant::now();
|
||||||
|
|
||||||
|
// Create new cancellation token
|
||||||
|
let cancel_token = tokio_util::sync::CancellationToken::new();
|
||||||
|
let cancel_token_clone = cancel_token.clone();
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
let mut interval = interval(Duration::from_millis(update_interval_ms));
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = interval.tick() => {
|
||||||
|
let elapsed_ms = start_time.elapsed().as_millis() as u64;
|
||||||
|
let colors = LedTestEffects::generate_colors(&effect_config, elapsed_ms);
|
||||||
|
|
||||||
|
// Send to board
|
||||||
|
if let Err(e) = send_test_colors_to_board_internal(&board_address, 0, colors).await {
|
||||||
|
error!("Failed to send test effect colors: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = cancel_token_clone.cancelled() => {
|
||||||
|
info!("LED test effect cancelled gracefully");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info!("LED test effect task ended");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store the handle and cancel token
|
||||||
|
{
|
||||||
|
let mut handle_guard = handle_storage.write().await;
|
||||||
|
*handle_guard = Some(handle);
|
||||||
|
|
||||||
|
let mut cancel_guard = cancel_storage.write().await;
|
||||||
|
*cancel_guard = Some(cancel_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn stop_led_test_effect(board_address: String, led_count: u32, led_type: led_test_effects::LedType) -> Result<(), String> {
|
||||||
|
// Stop the effect task first
|
||||||
|
|
||||||
|
info!("🛑 Stopping LED test effect - board: {}", board_address);
|
||||||
|
|
||||||
|
// Cancel the task gracefully first
|
||||||
|
if let Some(cancel_storage) = CANCEL_TOKEN.get() {
|
||||||
|
let mut cancel_guard = cancel_storage.write().await;
|
||||||
|
if let Some(token) = cancel_guard.take() {
|
||||||
|
info!("🔄 Cancelling test effect task gracefully");
|
||||||
|
token.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the task to finish
|
||||||
|
if let Some(handle_storage) = EFFECT_HANDLE.get() {
|
||||||
|
let mut handle_guard = handle_storage.write().await;
|
||||||
|
if let Some(handle) = handle_guard.take() {
|
||||||
|
info!("⏳ Waiting for test effect task to finish");
|
||||||
|
match handle.await {
|
||||||
|
Ok(_) => info!("✅ Test effect task finished successfully"),
|
||||||
|
Err(e) => warn!("⚠️ Test effect task finished with error: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn off all LEDs
|
||||||
|
let bytes_per_led = match led_type {
|
||||||
|
led_test_effects::LedType::RGB => 3,
|
||||||
|
led_test_effects::LedType::RGBW => 4,
|
||||||
|
};
|
||||||
|
let buffer = vec![0u8; (led_count * bytes_per_led) as usize];
|
||||||
|
|
||||||
|
send_test_colors_to_board_internal(&board_address, 0, buffer).await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
info!("💡 Sent LED off command");
|
||||||
|
|
||||||
|
// Disable test mode to resume normal publishing
|
||||||
|
let publisher = ambient_light::LedColorsPublisher::global().await;
|
||||||
|
publisher.disable_test_mode().await;
|
||||||
|
|
||||||
|
info!("🔄 Test mode disabled, normal publishing resumed");
|
||||||
|
info!("✅ LED test effect stopped completely");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal helper function
|
||||||
|
async fn send_test_colors_to_board_internal(board_address: &str, offset: u16, buffer: Vec<u8>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
use tokio::net::UdpSocket;
|
||||||
|
|
||||||
|
let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||||
|
|
||||||
|
let mut packet = vec![0x02]; // Header
|
||||||
|
packet.push((offset >> 8) as u8); // Offset high
|
||||||
|
packet.push((offset & 0xff) as u8); // Offset low
|
||||||
|
packet.extend_from_slice(&buffer); // Color data
|
||||||
|
|
||||||
|
socket.send_to(&packet, board_address).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn move_strip_part(
|
async fn move_strip_part(
|
||||||
display_id: u32,
|
display_id: u32,
|
||||||
@ -337,20 +548,7 @@ fn handle_ambient_light_protocol<R: Runtime>(
|
|||||||
async fn main() {
|
async fn main() {
|
||||||
env_logger::init();
|
env_logger::init();
|
||||||
|
|
||||||
// Debug: Print available displays
|
// Initialize display info (removed debug output)
|
||||||
match display_info::DisplayInfo::all() {
|
|
||||||
Ok(displays) => {
|
|
||||||
println!("=== AVAILABLE DISPLAYS ===");
|
|
||||||
for (index, display) in displays.iter().enumerate() {
|
|
||||||
println!(" Display {}: ID={}, Scale={}, Width={}, Height={}",
|
|
||||||
index, display.id, display.scale_factor, display.width, display.height);
|
|
||||||
}
|
|
||||||
println!("=== END DISPLAYS ===");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
println!("Error getting display info: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let screenshot_manager = ScreenshotManager::global().await;
|
let screenshot_manager = ScreenshotManager::global().await;
|
||||||
@ -383,7 +581,14 @@ async fn main() {
|
|||||||
get_led_strips_sample_points,
|
get_led_strips_sample_points,
|
||||||
get_one_edge_colors,
|
get_one_edge_colors,
|
||||||
patch_led_strip_len,
|
patch_led_strip_len,
|
||||||
|
patch_led_strip_type,
|
||||||
send_colors,
|
send_colors,
|
||||||
|
send_test_colors_to_board,
|
||||||
|
enable_test_mode,
|
||||||
|
disable_test_mode,
|
||||||
|
is_test_mode_active,
|
||||||
|
start_led_test_effect,
|
||||||
|
stop_led_test_effect,
|
||||||
move_strip_part,
|
move_strip_part,
|
||||||
reverse_led_strip_part,
|
reverse_led_strip_part,
|
||||||
set_color_calibration,
|
set_color_calibration,
|
||||||
|
58
src/App.tsx
58
src/App.tsx
@ -1,7 +1,8 @@
|
|||||||
import { Routes, Route } from '@solidjs/router';
|
import { Routes, Route, useLocation, A } from '@solidjs/router';
|
||||||
import { LedStripConfiguration } from './components/led-strip-configuration/led-strip-configuration';
|
import { LedStripConfiguration } from './components/led-strip-configuration/led-strip-configuration';
|
||||||
import { WhiteBalance } from './components/white-balance/white-balance';
|
import { WhiteBalance } from './components/white-balance/white-balance';
|
||||||
import { createEffect } from 'solid-js';
|
import { LedStripTest } from './components/led-strip-test/led-strip-test';
|
||||||
|
import { createEffect, createSignal } from 'solid-js';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { setLedStripStore } from './stores/led-strip.store';
|
import { setLedStripStore } from './stores/led-strip.store';
|
||||||
import { LedStripConfigContainer } from './models/led-strip-config';
|
import { LedStripConfigContainer } from './models/led-strip-config';
|
||||||
@ -9,23 +10,45 @@ import { InfoIndex } from './components/info/info-index';
|
|||||||
import { DisplayStateIndex } from './components/displays/display-state-index';
|
import { DisplayStateIndex } from './components/displays/display-state-index';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const location = useLocation();
|
||||||
|
const [previousPath, setPreviousPath] = createSignal<string>('');
|
||||||
|
|
||||||
|
// Monitor route changes and cleanup LED tests when leaving the test page
|
||||||
|
createEffect(() => {
|
||||||
|
const currentPath = location.pathname;
|
||||||
|
const prevPath = previousPath();
|
||||||
|
|
||||||
|
// Check if we're leaving the LED test page
|
||||||
|
const isLeavingTestPage = prevPath === '/led-strip-test' && currentPath !== '/led-strip-test';
|
||||||
|
|
||||||
|
if (isLeavingTestPage) {
|
||||||
|
// The LED test component will handle stopping the test effect via onCleanup
|
||||||
|
// We just need to ensure test mode is disabled to resume normal LED publishing
|
||||||
|
invoke('disable_test_mode').catch((error) => {
|
||||||
|
console.error('Failed to disable test mode:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update previousPath after the condition check
|
||||||
|
setPreviousPath(currentPath);
|
||||||
|
});
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
invoke<LedStripConfigContainer>('read_config').then((config) => {
|
invoke<LedStripConfigContainer>('read_config').then((config) => {
|
||||||
console.log('App: read config', config);
|
|
||||||
setLedStripStore({
|
setLedStripStore({
|
||||||
strips: config.strips,
|
strips: config.strips,
|
||||||
mappers: config.mappers,
|
mappers: config.mappers,
|
||||||
colorCalibration: config.color_calibration,
|
colorCalibration: config.color_calibration,
|
||||||
});
|
});
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error('App: Failed to read config:', error);
|
console.error('Failed to read config:', error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="min-h-screen bg-base-100" data-theme="dark">
|
<div class="min-h-screen bg-base-100" data-theme="dark">
|
||||||
{/* Navigation */}
|
{/* Fixed Navigation */}
|
||||||
<div class="navbar bg-base-200 shadow-lg">
|
<div class="navbar bg-base-200 shadow-lg fixed top-0 left-0 right-0 z-50">
|
||||||
<div class="navbar-start">
|
<div class="navbar-start">
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
|
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
|
||||||
@ -34,20 +57,22 @@ function App() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
|
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
|
||||||
<li><a href="/info" class="text-base-content">基本信息</a></li>
|
<li><A href="/info" class="text-base-content">基本信息</A></li>
|
||||||
<li><a href="/displays" class="text-base-content">显示器信息</a></li>
|
<li><A href="/displays" class="text-base-content">显示器信息</A></li>
|
||||||
<li><a href="/led-strips-configuration" class="text-base-content">灯条配置</a></li>
|
<li><A href="/led-strips-configuration" class="text-base-content">灯条配置</A></li>
|
||||||
<li><a href="/white-balance" class="text-base-content">白平衡</a></li>
|
<li><A href="/white-balance" class="text-base-content">白平衡</A></li>
|
||||||
|
<li><A href="/led-strip-test" class="text-base-content">灯带测试</A></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-ghost text-xl text-primary font-bold">环境光控制</a>
|
<a class="btn btn-ghost text-xl text-primary font-bold">环境光控制</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="navbar-center hidden lg:flex">
|
<div class="navbar-center hidden lg:flex">
|
||||||
<ul class="menu menu-horizontal px-1">
|
<ul class="menu menu-horizontal px-1">
|
||||||
<li><a href="/info" class="btn btn-ghost text-base-content hover:text-primary">基本信息</a></li>
|
<li><A href="/info" class="btn btn-ghost text-base-content hover:text-primary">基本信息</A></li>
|
||||||
<li><a href="/displays" class="btn btn-ghost text-base-content hover:text-primary">显示器信息</a></li>
|
<li><A href="/displays" class="btn btn-ghost text-base-content hover:text-primary">显示器信息</A></li>
|
||||||
<li><a href="/led-strips-configuration" class="btn btn-ghost text-base-content hover:text-primary">灯条配置</a></li>
|
<li><A href="/led-strips-configuration" class="btn btn-ghost text-base-content hover:text-primary">灯条配置</A></li>
|
||||||
<li><a href="/white-balance" class="btn btn-ghost text-base-content hover:text-primary">白平衡</a></li>
|
<li><A href="/white-balance" class="btn btn-ghost text-base-content hover:text-primary">白平衡</A></li>
|
||||||
|
<li><A href="/led-strip-test" class="btn btn-ghost text-base-content hover:text-primary">灯带测试</A></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="navbar-end">
|
<div class="navbar-end">
|
||||||
@ -55,13 +80,14 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content with top padding to account for fixed navbar */}
|
||||||
<main class="container mx-auto p-4">
|
<main class="container mx-auto p-4 pt-20">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/info" component={InfoIndex} />
|
<Route path="/info" component={InfoIndex} />
|
||||||
<Route path="/displays" component={DisplayStateIndex} />
|
<Route path="/displays" component={DisplayStateIndex} />
|
||||||
<Route path="/led-strips-configuration" component={LedStripConfiguration} />
|
<Route path="/led-strips-configuration" component={LedStripConfiguration} />
|
||||||
<Route path="/white-balance" component={WhiteBalance} />
|
<Route path="/white-balance" component={WhiteBalance} />
|
||||||
|
<Route path="/led-strip-test" element={<LedStripTest />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { Component, createMemo, For, JSX, splitProps } from 'solid-js';
|
import { Component, createMemo, For, JSX, splitProps, useContext } from 'solid-js';
|
||||||
import { DisplayInfo } from '../../models/display-info.model';
|
import { DisplayInfo } from '../../models/display-info.model';
|
||||||
import { ledStripStore } from '../../stores/led-strip.store';
|
import { ledStripStore } from '../../stores/led-strip.store';
|
||||||
import { Borders } from '../../constants/border';
|
import { Borders } from '../../constants/border';
|
||||||
|
import { LedType } from '../../models/led-strip-config';
|
||||||
|
import { LedStripConfigurationContext } from '../../contexts/led-strip-configuration.context';
|
||||||
|
|
||||||
type LedCountControlItemProps = {
|
type LedCountControlItemProps = {
|
||||||
displayId: number;
|
displayId: number;
|
||||||
@ -11,6 +13,8 @@ type LedCountControlItemProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const LedCountControlItem: Component<LedCountControlItemProps> = (props) => {
|
const LedCountControlItem: Component<LedCountControlItemProps> = (props) => {
|
||||||
|
const [stripConfiguration, { setHoveredStripPart }] = useContext(LedStripConfigurationContext);
|
||||||
|
|
||||||
const config = createMemo(() => {
|
const config = createMemo(() => {
|
||||||
return ledStripStore.strips.find(
|
return ledStripStore.strips.find(
|
||||||
(s) => s.display_id === props.displayId && s.border === props.border
|
(s) => s.display_id === props.displayId && s.border === props.border
|
||||||
@ -45,7 +49,7 @@ const LedCountControlItem: Component<LedCountControlItemProps> = (props) => {
|
|||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
const newValue = parseInt(target.value);
|
const newValue = parseInt(target.value);
|
||||||
const currentLen = config()?.len || 0;
|
const currentLen = config()?.len || 0;
|
||||||
|
|
||||||
if (!isNaN(newValue) && newValue >= 0 && newValue <= 1000) {
|
if (!isNaN(newValue) && newValue >= 0 && newValue <= 1000) {
|
||||||
const deltaLen = newValue - currentLen;
|
const deltaLen = newValue - currentLen;
|
||||||
if (deltaLen !== 0) {
|
if (deltaLen !== 0) {
|
||||||
@ -65,8 +69,41 @@ const LedCountControlItem: Component<LedCountControlItemProps> = (props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLedTypeChange = (e: Event) => {
|
||||||
|
const target = e.target as HTMLSelectElement;
|
||||||
|
const newType = target.value as LedType;
|
||||||
|
|
||||||
|
invoke('patch_led_strip_type', {
|
||||||
|
displayId: props.displayId,
|
||||||
|
border: props.border,
|
||||||
|
ledType: newType,
|
||||||
|
}).catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseEnter = () => {
|
||||||
|
setHoveredStripPart({
|
||||||
|
displayId: props.displayId,
|
||||||
|
border: props.border,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseLeave = () => {
|
||||||
|
setHoveredStripPart(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="card bg-base-100 border border-base-300/50 p-2">
|
<div
|
||||||
|
class="card bg-base-100 border border-base-300/50 p-2 transition-all duration-200 cursor-pointer"
|
||||||
|
classList={{
|
||||||
|
'ring-2 ring-primary bg-primary/20 border-primary':
|
||||||
|
stripConfiguration.hoveredStripPart?.border === props.border &&
|
||||||
|
stripConfiguration.hoveredStripPart?.displayId === props.displayId,
|
||||||
|
}}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span class="text-xs font-medium text-base-content">
|
<span class="text-xs font-medium text-base-content">
|
||||||
@ -107,6 +144,18 @@ const LedCountControlItem: Component<LedCountControlItemProps> = (props) => {
|
|||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-1">
|
||||||
|
<select
|
||||||
|
class="select select-xs w-full text-xs"
|
||||||
|
value={config()?.led_type || LedType.RGB}
|
||||||
|
onChange={handleLedTypeChange}
|
||||||
|
title="LED类型"
|
||||||
|
>
|
||||||
|
<option value={LedType.RGB}>RGB</option>
|
||||||
|
<option value={LedType.RGBW}>RGBW</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -114,7 +163,7 @@ const LedCountControlItem: Component<LedCountControlItemProps> = (props) => {
|
|||||||
|
|
||||||
type LedCountControlPanelProps = {
|
type LedCountControlPanelProps = {
|
||||||
display: DisplayInfo;
|
display: DisplayInfo;
|
||||||
} & JSX.HTMLAttributes<HTMLElement>;
|
} & JSX.HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
export const LedCountControlPanel: Component<LedCountControlPanelProps> = (props) => {
|
export const LedCountControlPanel: Component<LedCountControlPanelProps> = (props) => {
|
||||||
const [localProps, rootProps] = splitProps(props, ['display']);
|
const [localProps, rootProps] = splitProps(props, ['display']);
|
||||||
|
@ -19,19 +19,17 @@ export const LedStripConfiguration = () => {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
invoke<string>('list_display_info').then((displays) => {
|
invoke<string>('list_display_info').then((displays) => {
|
||||||
const parsedDisplays = JSON.parse(displays);
|
const parsedDisplays = JSON.parse(displays);
|
||||||
console.log('LedStripConfiguration: Loaded displays:', parsedDisplays);
|
|
||||||
setDisplayStore({
|
setDisplayStore({
|
||||||
displays: parsedDisplays,
|
displays: parsedDisplays,
|
||||||
});
|
});
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error('LedStripConfiguration: Failed to load displays:', error);
|
console.error('Failed to load displays:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
invoke<LedStripConfigContainer>('read_led_strip_configs').then((configs) => {
|
invoke<LedStripConfigContainer>('read_led_strip_configs').then((configs) => {
|
||||||
console.log('LedStripConfiguration: Loaded LED strip configs:', configs);
|
|
||||||
setLedStripStore(configs);
|
setLedStripStore(configs);
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error('LedStripConfiguration: Failed to load LED strip configs:', error);
|
console.error('Failed to load LED strip configs:', error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -86,6 +84,7 @@ export const LedStripConfiguration = () => {
|
|||||||
LedStripConfigurationContextType[0]
|
LedStripConfigurationContextType[0]
|
||||||
>({
|
>({
|
||||||
selectedStripPart: null,
|
selectedStripPart: null,
|
||||||
|
hoveredStripPart: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ledStripConfigurationContextValue: LedStripConfigurationContextType = [
|
const ledStripConfigurationContextValue: LedStripConfigurationContextType = [
|
||||||
@ -96,6 +95,11 @@ export const LedStripConfiguration = () => {
|
|||||||
selectedStripPart: v,
|
selectedStripPart: v,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
setHoveredStripPart: (v) => {
|
||||||
|
setLedStripConfiguration({
|
||||||
|
hoveredStripPart: v,
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -135,10 +139,9 @@ export const LedStripConfiguration = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="h-96 mb-4">
|
<div class="h-96 mb-4">
|
||||||
<DisplayListContainer>
|
<DisplayListContainer>
|
||||||
{displayStore.displays.map((display) => {
|
{displayStore.displays.map((display) => (
|
||||||
console.log('LedStripConfiguration: Rendering DisplayView for display:', display);
|
<DisplayView display={display} />
|
||||||
return <DisplayView display={display} />;
|
))}
|
||||||
})}
|
|
||||||
</DisplayListContainer>
|
</DisplayListContainer>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-base-content/50">
|
<div class="text-xs text-base-content/50">
|
||||||
|
@ -60,32 +60,16 @@ export const LedStripPart: Component<LedStripPartProps> = (props) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
console.log('🔍 LED: Strip config not found', {
|
|
||||||
displayId: localProps.config.display_id,
|
|
||||||
border: localProps.config.border,
|
|
||||||
availableStrips: ledStripStore.strips.length
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapper = ledStripStore.mappers[index];
|
const mapper = ledStripStore.mappers[index];
|
||||||
if (!mapper) {
|
if (!mapper) {
|
||||||
console.log('🔍 LED: Mapper not found', { index, mappersCount: ledStripStore.mappers.length });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const offset = mapper.start * 3;
|
const offset = mapper.start * 3;
|
||||||
|
|
||||||
console.log('🎨 LED: Updating colors', {
|
|
||||||
displayId: localProps.config.display_id,
|
|
||||||
border: localProps.config.border,
|
|
||||||
stripLength: localProps.config.len,
|
|
||||||
mapperPos: mapper.pos,
|
|
||||||
offset,
|
|
||||||
colorsArrayLength: ledStripStore.colors.length,
|
|
||||||
firstFewColors: Array.from(ledStripStore.colors.slice(offset, offset + 9))
|
|
||||||
});
|
|
||||||
|
|
||||||
const colors = new Array(localProps.config.len).fill(null).map((_, i) => {
|
const colors = new Array(localProps.config.len).fill(null).map((_, i) => {
|
||||||
const index = offset + i * 3;
|
const index = offset + i * 3;
|
||||||
const r = ledStripStore.colors[index] || 0;
|
const r = ledStripStore.colors[index] || 0;
|
||||||
@ -94,12 +78,6 @@ export const LedStripPart: Component<LedStripPartProps> = (props) => {
|
|||||||
return `rgb(${r}, ${g}, ${b})`;
|
return `rgb(${r}, ${g}, ${b})`;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('🎨 LED: Generated colors', {
|
|
||||||
border: localProps.config.border,
|
|
||||||
colorsCount: colors.length,
|
|
||||||
sampleColors: colors.slice(0, 3)
|
|
||||||
});
|
|
||||||
|
|
||||||
setColors(colors);
|
setColors(colors);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -124,7 +102,19 @@ export const LedStripPart: Component<LedStripPartProps> = (props) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
if (localProps.config) {
|
||||||
|
invoke('patch_led_strip_len', {
|
||||||
|
displayId: localProps.config.display_id,
|
||||||
|
border: localProps.config.border,
|
||||||
|
deltaLen: e.deltaY > 0 ? 1 : -1,
|
||||||
|
})
|
||||||
|
.then(() => {})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@ -140,7 +130,7 @@ export const LedStripPart: Component<LedStripPartProps> = (props) => {
|
|||||||
stripConfiguration.selectedStripPart?.displayId ===
|
stripConfiguration.selectedStripPart?.displayId ===
|
||||||
localProps.config?.display_id,
|
localProps.config?.display_id,
|
||||||
}}
|
}}
|
||||||
|
onWheel={onWheel}
|
||||||
>
|
>
|
||||||
<For each={colors()}>{(item) => <Pixel color={item} />}</For>
|
<For each={colors()}>{(item) => <Pixel color={item} />}</For>
|
||||||
</section>
|
</section>
|
||||||
|
@ -29,7 +29,7 @@ const SorterItem: Component<{ strip: LedStripConfig; mapper: LedStripPixelMapper
|
|||||||
const [dragCurr, setDragCurr] = createSignal<{ x: number; y: number } | null>(null);
|
const [dragCurr, setDragCurr] = createSignal<{ x: number; y: number } | null>(null);
|
||||||
const [dragStartIndex, setDragStartIndex] = createSignal<number>(0);
|
const [dragStartIndex, setDragStartIndex] = createSignal<number>(0);
|
||||||
const [cellWidth, setCellWidth] = createSignal<number>(0);
|
const [cellWidth, setCellWidth] = createSignal<number>(0);
|
||||||
const [, { setSelectedStripPart }] = useContext(LedStripConfigurationContext);
|
const [stripConfiguration, { setSelectedStripPart, setHoveredStripPart }] = useContext(LedStripConfigurationContext);
|
||||||
const [rootWidth, setRootWidth] = createSignal<number>(0);
|
const [rootWidth, setRootWidth] = createSignal<number>(0);
|
||||||
|
|
||||||
let root: HTMLDivElement;
|
let root: HTMLDivElement;
|
||||||
@ -38,9 +38,6 @@ const SorterItem: Component<{ strip: LedStripConfig; mapper: LedStripPixelMapper
|
|||||||
if (targetStart === props.mapper.start) {
|
if (targetStart === props.mapper.start) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(
|
|
||||||
`moving strip part ${props.strip.display_id} ${props.strip.border} from ${props.mapper.start} to ${targetStart}`,
|
|
||||||
);
|
|
||||||
invoke('move_strip_part', {
|
invoke('move_strip_part', {
|
||||||
displayId: props.strip.display_id,
|
displayId: props.strip.display_id,
|
||||||
border: props.strip.border,
|
border: props.strip.border,
|
||||||
@ -151,6 +148,17 @@ const SorterItem: Component<{ strip: LedStripConfig; mapper: LedStripPixelMapper
|
|||||||
}).catch((err) => console.error(err));
|
}).catch((err) => console.error(err));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onMouseEnter = () => {
|
||||||
|
setHoveredStripPart({
|
||||||
|
displayId: props.strip.display_id,
|
||||||
|
border: props.strip.border,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseLeave = () => {
|
||||||
|
setHoveredStripPart(null);
|
||||||
|
};
|
||||||
|
|
||||||
const setColor = (fullIndex: number, colorsIndex: number, fullLeds: string[]) => {
|
const setColor = (fullIndex: number, colorsIndex: number, fullLeds: string[]) => {
|
||||||
const colors = ledStripStore.colors;
|
const colors = ledStripStore.colors;
|
||||||
let c1 = `rgb(${Math.floor(colors[colorsIndex * 3] * 0.8)}, ${Math.floor(
|
let c1 = `rgb(${Math.floor(colors[colorsIndex * 3] * 0.8)}, ${Math.floor(
|
||||||
@ -162,7 +170,6 @@ const SorterItem: Component<{ strip: LedStripConfig; mapper: LedStripPixelMapper
|
|||||||
)}, ${Math.min(Math.floor(colors[colorsIndex * 3 + 2] * 1.2), 255)})`;
|
)}, ${Math.min(Math.floor(colors[colorsIndex * 3 + 2] * 1.2), 255)})`;
|
||||||
|
|
||||||
if (fullLeds.length <= fullIndex) {
|
if (fullLeds.length <= fullIndex) {
|
||||||
console.error('out of range', fullIndex, fullLeds.length);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -221,9 +228,16 @@ const SorterItem: Component<{ strip: LedStripConfig; mapper: LedStripPixelMapper
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="flex mx-2 select-none cursor-ew-resize focus:cursor-ew-resize"
|
class="flex mx-2 select-none cursor-ew-resize focus:cursor-ew-resize transition-colors duration-200"
|
||||||
|
classList={{
|
||||||
|
'bg-primary/20 rounded-lg':
|
||||||
|
stripConfiguration.hoveredStripPart?.border === props.strip.border &&
|
||||||
|
stripConfiguration.hoveredStripPart?.displayId === props.strip.display_id,
|
||||||
|
}}
|
||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
ondblclick={reverse}
|
ondblclick={reverse}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
ref={root!}
|
ref={root!}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
@ -43,50 +43,35 @@ export const ScreenViewWebSocket: Component<ScreenViewWebSocketProps> = (props)
|
|||||||
|
|
||||||
const connectWebSocket = () => {
|
const connectWebSocket = () => {
|
||||||
if (!isMounted) {
|
if (!isMounted) {
|
||||||
console.log('Component not mounted, skipping WebSocket connection');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const wsUrl = `ws://127.0.0.1:8765`;
|
const wsUrl = `ws://127.0.0.1:8765`;
|
||||||
console.log('Connecting to WebSocket:', wsUrl, 'with displayId:', localProps.displayId);
|
|
||||||
|
|
||||||
setConnectionStatus('connecting');
|
setConnectionStatus('connecting');
|
||||||
websocket = new WebSocket(wsUrl);
|
websocket = new WebSocket(wsUrl);
|
||||||
websocket.binaryType = 'arraybuffer';
|
websocket.binaryType = 'arraybuffer';
|
||||||
console.log('WebSocket object created:', websocket);
|
|
||||||
|
|
||||||
websocket.onopen = () => {
|
websocket.onopen = () => {
|
||||||
console.log('WebSocket connected successfully!');
|
|
||||||
setConnectionStatus('connected');
|
setConnectionStatus('connected');
|
||||||
|
|
||||||
// Send initial configuration
|
// Send initial configuration
|
||||||
const config = {
|
const config = {
|
||||||
display_id: localProps.displayId,
|
display_id: localProps.displayId,
|
||||||
width: localProps.width || 320, // Reduced from 400 for better performance
|
width: localProps.width || 320,
|
||||||
height: localProps.height || 180, // Reduced from 225 for better performance
|
height: localProps.height || 180,
|
||||||
quality: localProps.quality || 50 // Reduced from 75 for faster compression
|
quality: localProps.quality || 50
|
||||||
};
|
};
|
||||||
console.log('Sending WebSocket configuration:', config);
|
|
||||||
websocket?.send(JSON.stringify(config));
|
websocket?.send(JSON.stringify(config));
|
||||||
};
|
};
|
||||||
|
|
||||||
websocket.onmessage = (event) => {
|
websocket.onmessage = (event) => {
|
||||||
console.log('🔍 WebSocket message received:', {
|
|
||||||
type: typeof event.data,
|
|
||||||
isArrayBuffer: event.data instanceof ArrayBuffer,
|
|
||||||
size: event.data instanceof ArrayBuffer ? event.data.byteLength : 'N/A'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (event.data instanceof ArrayBuffer) {
|
if (event.data instanceof ArrayBuffer) {
|
||||||
console.log('📦 Processing ArrayBuffer frame, size:', event.data.byteLength);
|
|
||||||
handleJpegFrame(new Uint8Array(event.data));
|
handleJpegFrame(new Uint8Array(event.data));
|
||||||
} else {
|
|
||||||
console.log('⚠️ Received non-ArrayBuffer data:', event.data);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
websocket.onclose = (event) => {
|
websocket.onclose = (event) => {
|
||||||
console.log('WebSocket closed:', event.code, event.reason);
|
|
||||||
setConnectionStatus('disconnected');
|
setConnectionStatus('disconnected');
|
||||||
websocket = null;
|
websocket = null;
|
||||||
|
|
||||||
@ -100,7 +85,6 @@ export const ScreenViewWebSocket: Component<ScreenViewWebSocketProps> = (props)
|
|||||||
};
|
};
|
||||||
|
|
||||||
websocket.onerror = (error) => {
|
websocket.onerror = (error) => {
|
||||||
console.error('WebSocket error:', error);
|
|
||||||
setConnectionStatus('error');
|
setConnectionStatus('error');
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -204,7 +188,6 @@ export const ScreenViewWebSocket: Component<ScreenViewWebSocketProps> = (props)
|
|||||||
|
|
||||||
// Initialize canvas and resize observer
|
// Initialize canvas and resize observer
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
console.log('ScreenViewWebSocket mounted with displayId:', localProps.displayId);
|
|
||||||
const context = canvas.getContext('2d');
|
const context = canvas.getContext('2d');
|
||||||
setCtx(context);
|
setCtx(context);
|
||||||
|
|
||||||
@ -217,7 +200,6 @@ export const ScreenViewWebSocket: Component<ScreenViewWebSocketProps> = (props)
|
|||||||
resizeObserver.observe(root);
|
resizeObserver.observe(root);
|
||||||
|
|
||||||
// Connect WebSocket
|
// Connect WebSocket
|
||||||
console.log('About to connect WebSocket...');
|
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
@ -227,17 +209,7 @@ export const ScreenViewWebSocket: Component<ScreenViewWebSocketProps> = (props)
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Debug function to list displays
|
|
||||||
const debugDisplays = async () => {
|
|
||||||
try {
|
|
||||||
const result = await invoke('list_display_info');
|
|
||||||
console.log('Available displays:', result);
|
|
||||||
alert(`Available displays: ${result}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to get display info:', error);
|
|
||||||
alert(`Error: ${error}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Status indicator
|
// Status indicator
|
||||||
const getStatusColor = () => {
|
const getStatusColor = () => {
|
||||||
@ -275,13 +247,6 @@ export const ScreenViewWebSocket: Component<ScreenViewWebSocketProps> = (props)
|
|||||||
{connectionStatus() === 'connected' && (
|
{connectionStatus() === 'connected' && (
|
||||||
<span>| {fps()} FPS | {frameCount()} frames</span>
|
<span>| {fps()} FPS | {frameCount()} frames</span>
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
onClick={debugDisplays}
|
|
||||||
class="ml-2 px-1 py-0.5 bg-blue-600 hover:bg-blue-700 rounded text-xs"
|
|
||||||
title="Debug: Show available displays"
|
|
||||||
>
|
|
||||||
Debug
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{rootProps.children}
|
{rootProps.children}
|
||||||
|
343
src/components/led-strip-test/led-strip-test.tsx
Normal file
343
src/components/led-strip-test/led-strip-test.tsx
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
import { createSignal, createEffect, For, Show, onCleanup } from 'solid-js';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
|
interface BoardInfo {
|
||||||
|
fullname: string;
|
||||||
|
host: string;
|
||||||
|
address: string;
|
||||||
|
port: number;
|
||||||
|
connect_status: 'Connected' | 'Disconnected' | { Connecting: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestPattern {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
effect_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestEffectConfig {
|
||||||
|
effect_type: string;
|
||||||
|
led_count: number;
|
||||||
|
led_type: string;
|
||||||
|
speed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LedStripTest = () => {
|
||||||
|
const [boards, setBoards] = createSignal<BoardInfo[]>([]);
|
||||||
|
const [selectedBoard, setSelectedBoard] = createSignal<BoardInfo | null>(null);
|
||||||
|
const [ledCount, setLedCount] = createSignal(60);
|
||||||
|
const [ledType, setLedType] = createSignal<'RGB' | 'RGBW'>('RGB');
|
||||||
|
const [isRunning, setIsRunning] = createSignal(false);
|
||||||
|
const [currentPattern, setCurrentPattern] = createSignal<TestPattern | null>(null);
|
||||||
|
const [animationSpeed, setAnimationSpeed] = createSignal(33); // ~30fps
|
||||||
|
|
||||||
|
// Load available boards and listen for changes
|
||||||
|
createEffect(() => {
|
||||||
|
// Initial load
|
||||||
|
invoke<BoardInfo[]>('get_boards').then((boardList) => {
|
||||||
|
setBoards(boardList);
|
||||||
|
if (boardList.length > 0 && !selectedBoard()) {
|
||||||
|
setSelectedBoard(boardList[0]);
|
||||||
|
}
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Failed to load boards:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for board changes
|
||||||
|
const unlisten = listen<BoardInfo[]>('boards_changed', (event) => {
|
||||||
|
const boardList = event.payload;
|
||||||
|
setBoards(boardList);
|
||||||
|
|
||||||
|
// If currently selected board is no longer available, select the first available one
|
||||||
|
const currentBoard = selectedBoard();
|
||||||
|
if (currentBoard) {
|
||||||
|
const stillExists = boardList.find(board =>
|
||||||
|
board.host === currentBoard.host &&
|
||||||
|
board.address === currentBoard.address &&
|
||||||
|
board.port === currentBoard.port
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!stillExists) {
|
||||||
|
// Current board is no longer available, select first available or null
|
||||||
|
setSelectedBoard(boardList.length > 0 ? boardList[0] : null);
|
||||||
|
}
|
||||||
|
} else if (boardList.length > 0) {
|
||||||
|
// No board was selected, select the first one
|
||||||
|
setSelectedBoard(boardList[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup listener when effect is disposed
|
||||||
|
onCleanup(() => {
|
||||||
|
unlisten.then((unlistenFn) => unlistenFn());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup when component is unmounted
|
||||||
|
onCleanup(() => {
|
||||||
|
if (isRunning() && selectedBoard()) {
|
||||||
|
// Stop the test effect in backend
|
||||||
|
invoke('stop_led_test_effect', {
|
||||||
|
boardAddress: `${selectedBoard()!.address}:${selectedBoard()!.port}`,
|
||||||
|
ledCount: ledCount(),
|
||||||
|
ledType: ledType()
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Failed to stop test during cleanup:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update local state immediately
|
||||||
|
setIsRunning(false);
|
||||||
|
setCurrentPattern(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Test patterns
|
||||||
|
const testPatterns: TestPattern[] = [
|
||||||
|
{
|
||||||
|
name: '流光效果',
|
||||||
|
description: '彩虹色流光,用于测试灯带方向',
|
||||||
|
effect_type: 'FlowingRainbow'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '十个一组计数',
|
||||||
|
description: '每十个LED一组不同颜色,用于快速计算灯珠数量',
|
||||||
|
effect_type: 'GroupCounting'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '单色扫描',
|
||||||
|
description: '单个LED依次点亮,用于精确测试每个LED位置',
|
||||||
|
effect_type: 'SingleScan'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '呼吸灯',
|
||||||
|
description: '整条灯带呼吸效果,用于测试整体亮度',
|
||||||
|
effect_type: 'Breathing'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Test effect management - now handled by Rust backend
|
||||||
|
|
||||||
|
const startTest = async (pattern: TestPattern) => {
|
||||||
|
if (isRunning()) {
|
||||||
|
await stopTest();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedBoard()) {
|
||||||
|
console.error('No board selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const effectConfig: TestEffectConfig = {
|
||||||
|
effect_type: pattern.effect_type,
|
||||||
|
led_count: ledCount(),
|
||||||
|
led_type: ledType(),
|
||||||
|
speed: 1.0 / (animationSpeed() / 50) // Convert animation speed to effect speed
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start the test effect in Rust backend
|
||||||
|
await invoke('start_led_test_effect', {
|
||||||
|
boardAddress: `${selectedBoard()!.address}:${selectedBoard()!.port}`,
|
||||||
|
effectConfig: effectConfig,
|
||||||
|
updateIntervalMs: animationSpeed()
|
||||||
|
});
|
||||||
|
|
||||||
|
setCurrentPattern(pattern);
|
||||||
|
setIsRunning(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start test effect:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopTest = async () => {
|
||||||
|
if (!selectedBoard()) {
|
||||||
|
setIsRunning(false);
|
||||||
|
setCurrentPattern(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Stop the test effect in Rust backend
|
||||||
|
await invoke('stop_led_test_effect', {
|
||||||
|
boardAddress: `${selectedBoard()!.address}:${selectedBoard()!.port}`,
|
||||||
|
ledCount: ledCount(),
|
||||||
|
ledType: ledType()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only update UI state after successful backend call
|
||||||
|
setIsRunning(false);
|
||||||
|
setCurrentPattern(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to stop test effect:', error);
|
||||||
|
// Still update UI state even if backend call fails
|
||||||
|
setIsRunning(false);
|
||||||
|
setCurrentPattern(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="container mx-auto p-6 space-y-6">
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl mb-4">LED Strip Testing</h2>
|
||||||
|
|
||||||
|
{/* Hardware Selection */}
|
||||||
|
<div class="form-control w-full max-w-xs">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Select Hardware Board</span>
|
||||||
|
<span class="label-text-alt">
|
||||||
|
{boards().length > 0 ? `${boards().length} device(s) found` : 'Searching...'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
class="select select-bordered w-full max-w-xs"
|
||||||
|
value={selectedBoard()?.host || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const board = boards().find(b => b.host === e.target.value);
|
||||||
|
setSelectedBoard(board || null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option disabled value="">
|
||||||
|
{boards().length > 0 ? 'Choose a board' : 'No boards found'}
|
||||||
|
</option>
|
||||||
|
<For each={boards()}>
|
||||||
|
{(board) => {
|
||||||
|
const getStatusIcon = (status: BoardInfo['connect_status']) => {
|
||||||
|
if (status === 'Connected') return '🟢';
|
||||||
|
if (typeof status === 'object' && 'Connecting' in status) return '🟡';
|
||||||
|
return '🔴';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status: BoardInfo['connect_status']) => {
|
||||||
|
if (status === 'Connected') return 'Connected';
|
||||||
|
if (typeof status === 'object' && 'Connecting' in status) return 'Connecting';
|
||||||
|
return 'Disconnected';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<option value={board.host}>
|
||||||
|
{getStatusIcon(board.connect_status)} {board.host} ({board.address}:{board.port}) - {getStatusText(board.connect_status)}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* LED Configuration */}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">LED Count</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="input input-bordered w-full text-center text-lg"
|
||||||
|
value={ledCount()}
|
||||||
|
min="1"
|
||||||
|
max="1000"
|
||||||
|
onInput={(e) => setLedCount(parseInt(e.target.value) || 60)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">LED Type</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
class="select select-bordered w-full"
|
||||||
|
value={ledType()}
|
||||||
|
onChange={(e) => setLedType(e.target.value as 'RGB' | 'RGBW')}
|
||||||
|
>
|
||||||
|
<option value="RGB">RGB</option>
|
||||||
|
<option value="RGBW">RGBW</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Animation Speed (ms)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="input input-bordered w-full text-center"
|
||||||
|
value={animationSpeed()}
|
||||||
|
min="16"
|
||||||
|
max="200"
|
||||||
|
step="1"
|
||||||
|
onInput={(e) => setAnimationSpeed(parseInt(e.target.value) || 33)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test Patterns */}
|
||||||
|
<div class="card bg-base-200 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-xl mb-4">Test Patterns</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<For each={testPatterns}>
|
||||||
|
{(pattern) => (
|
||||||
|
<div class="card bg-base-100 shadow-md">
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="card-title text-lg">{pattern.name}</h4>
|
||||||
|
<p class="text-sm opacity-70 mb-4">{pattern.description}</p>
|
||||||
|
|
||||||
|
<div class="card-actions justify-end">
|
||||||
|
<Show
|
||||||
|
when={currentPattern() === pattern && isRunning()}
|
||||||
|
fallback={
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
onClick={() => startTest(pattern)}
|
||||||
|
disabled={!selectedBoard()}
|
||||||
|
>
|
||||||
|
Start Test
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-error"
|
||||||
|
onClick={() => stopTest()}
|
||||||
|
>
|
||||||
|
Stop Test
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={isRunning()}>
|
||||||
|
<div class="alert alert-info mt-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Test pattern "{currentPattern()?.name}" is running on {selectedBoard()?.host}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!selectedBoard()}>
|
||||||
|
<div class="alert alert-warning mt-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.728-.833-2.498 0L3.732 16c-.77.833.192 2.5 1.732 2.5z" />
|
||||||
|
</svg>
|
||||||
|
<span>Please select a hardware board to start testing</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -38,7 +38,7 @@ export const WhiteBalance = () => {
|
|||||||
setIsFullscreen(true);
|
setIsFullscreen(true);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to auto enter fullscreen:', error);
|
// Silently handle fullscreen error
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -101,7 +101,6 @@ export const WhiteBalance = () => {
|
|||||||
const unlisten = listen('config_changed', (event) => {
|
const unlisten = listen('config_changed', (event) => {
|
||||||
const { strips, mappers, color_calibration } =
|
const { strips, mappers, color_calibration } =
|
||||||
event.payload as LedStripConfigContainer;
|
event.payload as LedStripConfigContainer;
|
||||||
console.log(event.payload);
|
|
||||||
setLedStripStore({
|
setLedStripStore({
|
||||||
strips,
|
strips,
|
||||||
mappers,
|
mappers,
|
||||||
@ -121,9 +120,9 @@ export const WhiteBalance = () => {
|
|||||||
const calibration = { ...ledStripStore.colorCalibration };
|
const calibration = { ...ledStripStore.colorCalibration };
|
||||||
calibration[key] = value;
|
calibration[key] = value;
|
||||||
setLedStripStore('colorCalibration', calibration);
|
setLedStripStore('colorCalibration', calibration);
|
||||||
invoke('set_color_calibration', { calibration }).catch((error) =>
|
invoke('set_color_calibration', { calibration }).catch(() => {
|
||||||
console.log(error),
|
// Silently handle error
|
||||||
);
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleFullscreen = async () => {
|
const toggleFullscreen = async () => {
|
||||||
@ -138,7 +137,7 @@ export const WhiteBalance = () => {
|
|||||||
setPanelPosition({ x: 0, y: 0 });
|
setPanelPosition({ x: 0, y: 0 });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to toggle fullscreen:', error);
|
// Silently handle fullscreen error
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -156,7 +155,9 @@ export const WhiteBalance = () => {
|
|||||||
const reset = () => {
|
const reset = () => {
|
||||||
invoke('set_color_calibration', {
|
invoke('set_color_calibration', {
|
||||||
calibration: new ColorCalibration(),
|
calibration: new ColorCalibration(),
|
||||||
}).catch((error) => console.log(error));
|
}).catch(() => {
|
||||||
|
// Silently handle error
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -266,10 +267,19 @@ export const WhiteBalance = () => {
|
|||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text font-semibold text-base-content/70">白色 (W)</span>
|
<span class="label-text font-semibold text-amber-500">白色 (W)</span>
|
||||||
<div class="badge badge-outline badge-sm">暂未启用</div>
|
<Value value={ledStripStore.colorCalibration.w} />
|
||||||
</label>
|
</label>
|
||||||
<ColorSlider class="from-yellow-50 to-cyan-50" disabled />
|
<ColorSlider
|
||||||
|
class="from-amber-100 to-amber-50"
|
||||||
|
value={ledStripStore.colorCalibration.w}
|
||||||
|
onInput={(ev) =>
|
||||||
|
updateColorCalibration(
|
||||||
|
'w',
|
||||||
|
(ev.target as HTMLInputElement).valueAsNumber ?? 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -400,6 +410,23 @@ export const WhiteBalance = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold text-amber-500">白色 (W)</span>
|
||||||
|
<Value value={ledStripStore.colorCalibration.w} />
|
||||||
|
</label>
|
||||||
|
<ColorSlider
|
||||||
|
class="from-amber-100 to-amber-50"
|
||||||
|
value={ledStripStore.colorCalibration.w}
|
||||||
|
onInput={(ev) =>
|
||||||
|
updateColorCalibration(
|
||||||
|
'w',
|
||||||
|
(ev.target as HTMLInputElement).valueAsNumber ?? 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text font-semibold text-base-content/70">白色 (W)</span>
|
<span class="label-text font-semibold text-base-content/70">白色 (W)</span>
|
||||||
|
@ -7,9 +7,14 @@ export type LedStripConfigurationContextType = [
|
|||||||
displayId: number;
|
displayId: number;
|
||||||
border: Borders;
|
border: Borders;
|
||||||
} | null;
|
} | null;
|
||||||
|
hoveredStripPart: {
|
||||||
|
displayId: number;
|
||||||
|
border: Borders;
|
||||||
|
} | null;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
setSelectedStripPart: (v: { displayId: number; border: Borders } | null) => void;
|
setSelectedStripPart: (v: { displayId: number; border: Borders } | null) => void;
|
||||||
|
setHoveredStripPart: (v: { displayId: number; border: Borders } | null) => void;
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -17,8 +22,10 @@ export const LedStripConfigurationContext =
|
|||||||
createContext<LedStripConfigurationContextType>([
|
createContext<LedStripConfigurationContextType>([
|
||||||
{
|
{
|
||||||
selectedStripPart: null,
|
selectedStripPart: null,
|
||||||
|
hoveredStripPart: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
setSelectedStripPart: () => {},
|
setSelectedStripPart: () => {},
|
||||||
|
setHoveredStripPart: () => { },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
import { Borders } from '../constants/border';
|
import { Borders } from '../constants/border';
|
||||||
|
|
||||||
|
export enum LedType {
|
||||||
|
RGB = 'RGB',
|
||||||
|
RGBW = 'RGBW',
|
||||||
|
}
|
||||||
|
|
||||||
export type LedStripPixelMapper = {
|
export type LedStripPixelMapper = {
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
@ -10,6 +15,7 @@ export class ColorCalibration {
|
|||||||
r: number = 1;
|
r: number = 1;
|
||||||
g: number = 1;
|
g: number = 1;
|
||||||
b: number = 1;
|
b: number = 1;
|
||||||
|
w: number = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LedStripConfigContainer = {
|
export type LedStripConfigContainer = {
|
||||||
@ -23,5 +29,6 @@ export class LedStripConfig {
|
|||||||
public readonly display_id: number,
|
public readonly display_id: number,
|
||||||
public readonly border: Borders,
|
public readonly border: Borders,
|
||||||
public len: number,
|
public len: number,
|
||||||
|
public led_type: LedType = LedType.RGB,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user