├── .codespellignore ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── build_and_release.yml │ └── pre-commit.yml ├── .gitignore ├── .pre-commit-config.yaml ├── 99-litra.rules ├── Cargo.lock ├── Cargo.toml ├── LICENSE.md ├── README.md ├── rust-toolchain.toml └── src ├── lib.rs └── main.rs /.codespellignore: -------------------------------------------------------------------------------- 1 | crate 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: timrogers 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "cargo" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/workflows/build_and_release.yml: -------------------------------------------------------------------------------- 1 | name: Build, test and release 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | name: Build and test 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | job: 12 | - { 13 | target: x86_64-unknown-linux-gnu, 14 | binary_name: linux-amd64, 15 | runs_on: ubuntu-latest, 16 | } 17 | - { 18 | target: aarch64-unknown-linux-gnu, 19 | binary_name: linux-aarch64, 20 | runs_on: self-hosted, 21 | } 22 | - { 23 | target: x86_64-apple-darwin, 24 | binary_name: darwin-amd64, 25 | runs_on: macos-latest, 26 | } 27 | - { 28 | target: aarch64-apple-darwin, 29 | binary_name: darwin-aarch64, 30 | runs_on: macos-latest, 31 | } 32 | - { 33 | target: x86_64-pc-windows-msvc, 34 | binary_name: windows-amd64.exe, 35 | runs_on: windows-latest, 36 | } 37 | runs-on: ${{ matrix.job.runs_on }} 38 | steps: 39 | - name: Install rustup (self-hosted runners only) 40 | run: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 41 | if: matrix.job.runs_on == 'self-hosted' 42 | - name: Add $HOME/.cargo/bin to PATH (self-hosted runners only) 43 | run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH 44 | if: matrix.job.runs_on == 'self-hosted' 45 | - name: Install libudev-dev 46 | run: sudo apt-get update && sudo apt-get install -y libudev-dev 47 | if: runner.os == 'Linux' 48 | - uses: actions/checkout@v4 49 | - name: Use Rust 1.85.1 with target ${{ matrix.job.target }} 50 | run: rustup override set 1.85.1-${{ matrix.job.target }} 51 | - uses: Swatinem/rust-cache@v2 52 | - name: Build in release mode 53 | run: cargo build --release --target=${{ matrix.job.target }} 54 | - name: Sanitise Git ref for use in filenames 55 | id: sanitise_ref 56 | run: echo "::set-output name=value::$(echo "${{ github.ref_name }}" | tr '/' '_')" 57 | - name: Rename Windows binary to use structured filename 58 | run: | 59 | cp target/${{ matrix.job.target }}/release/litra.exe litra_${{ steps.sanitise_ref.outputs.value }}_${{ matrix.job.binary_name }} 60 | if: runner.os == 'Windows' 61 | - name: Rename Unix binary to use structured filename 62 | run: | 63 | rm target/${{ matrix.job.target }}/release/litra.d 64 | cp target/${{ matrix.job.target }}/release/litra* litra_${{ steps.sanitise_ref.outputs.value }}_${{ matrix.job.binary_name }} 65 | if: runner.os != 'Windows' 66 | - name: Write Apple signing key to a file (macOS only) 67 | env: 68 | APPLE_SIGNING_KEY_P12: ${{ secrets.APPLE_SIGNING_KEY_P12 }} 69 | run: echo "$APPLE_SIGNING_KEY_P12" | base64 -d -o key.p12 70 | if: runner.os == 'macOS' 71 | - name: Write App Store Connect API key to a file (macOS only) 72 | env: 73 | APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }} 74 | run: echo "$APP_STORE_CONNECT_API_KEY" > app_store_connect_api_key.json 75 | if: runner.os == 'macOS' 76 | - name: Sign macOS binary (macOS only) 77 | uses: indygreg/apple-code-sign-action@v1 78 | with: 79 | input_path: litra_${{ steps.sanitise_ref.outputs.value }}_${{ matrix.job.binary_name }} 80 | p12_file: key.p12 81 | p12_password: ${{ secrets.APPLE_SIGNING_KEY_PASSWORD }} 82 | sign: true 83 | sign_args: "--code-signature-flags=runtime" 84 | if: runner.os == 'macOS' 85 | - name: Upload binary as artifact 86 | uses: actions/upload-artifact@v4 87 | with: 88 | path: litra_${{ steps.sanitise_ref.outputs.value }}_${{ matrix.job.binary_name }} 89 | name: litra_${{ steps.sanitise_ref.outputs.value }}_${{ matrix.job.binary_name }} 90 | - name: Archive macOS binary for notarisation (macOS only) 91 | run: zip litra_${{ steps.sanitise_ref.outputs.value }}_${{ matrix.job.binary_name }}.zip litra_${{ steps.sanitise_ref.outputs.value }}_${{ matrix.job.binary_name }} 92 | if: runner.os == 'macOS' 93 | - name: Notarise signed macOS binary (macOS only) 94 | uses: indygreg/apple-code-sign-action@v1 95 | with: 96 | input_path: litra_${{ steps.sanitise_ref.outputs.value }}_${{ matrix.job.binary_name }}.zip 97 | sign: false 98 | notarize: true 99 | app_store_connect_api_key_json_file: app_store_connect_api_key.json 100 | if: runner.os == 'macOS' 101 | create_and_sign_macos_universal_binary: 102 | name: Create and sign macOS universal binary (macOS only) 103 | runs-on: macos-latest 104 | needs: build 105 | steps: 106 | - name: Sanitise Git ref for use in filenames 107 | id: sanitise_ref 108 | run: echo "::set-output name=value::$(echo "${{ github.ref_name }}" | tr '/' '_')" 109 | - name: Download macOS amd64 binary 110 | uses: actions/download-artifact@v4 111 | with: 112 | name: litra_${{ steps.sanitise_ref.outputs.value }}_darwin-amd64 113 | - name: Download macOS arm64 binary 114 | uses: actions/download-artifact@v4 115 | with: 116 | name: litra_${{ steps.sanitise_ref.outputs.value }}_darwin-aarch64 117 | - name: Create universal macOS binary 118 | run: lipo -create -output litra_${{ steps.sanitise_ref.outputs.value }}_darwin-universal litra_${{ steps.sanitise_ref.outputs.value }}_darwin-amd64 litra_${{ steps.sanitise_ref.outputs.value }}_darwin-aarch64 119 | - name: Write Apple signing key to a file (macOS only) 120 | env: 121 | APPLE_SIGNING_KEY_P12: ${{ secrets.APPLE_SIGNING_KEY_P12 }} 122 | run: echo "$APPLE_SIGNING_KEY_P12" | base64 -d -o key.p12 123 | - name: Write App Store Connect API key to a file (macOS only) 124 | env: 125 | APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }} 126 | run: echo "$APP_STORE_CONNECT_API_KEY" > app_store_connect_api_key.json 127 | - name: Sign macOS binary (macOS only) 128 | uses: indygreg/apple-code-sign-action@v1 129 | with: 130 | input_path: litra_${{ steps.sanitise_ref.outputs.value }}_darwin-universal 131 | p12_file: key.p12 132 | p12_password: ${{ secrets.APPLE_SIGNING_KEY_PASSWORD }} 133 | sign: true 134 | sign_args: "--code-signature-flags=runtime" 135 | - name: Upload binary as artifact 136 | uses: actions/upload-artifact@v4 137 | with: 138 | path: litra_${{ steps.sanitise_ref.outputs.value }}_darwin-universal 139 | name: litra_${{ steps.sanitise_ref.outputs.value }}_darwin-universal 140 | - name: Archive macOS binary for notarisation (macOS only) 141 | run: zip litra_${{ steps.sanitise_ref.outputs.value }}_darwin-universal.zip litra_${{ steps.sanitise_ref.outputs.value }}_darwin-universal 142 | - name: Notarise signed macOS binary (macOS only) 143 | uses: indygreg/apple-code-sign-action@v1 144 | with: 145 | input_path: litra_${{ steps.sanitise_ref.outputs.value }}_darwin-universal.zip 146 | sign: false 147 | notarize: true 148 | app_store_connect_api_key_json_file: app_store_connect_api_key.json 149 | 150 | cargo_publish_dry_run: 151 | name: Publish with Cargo in dry-run mode 152 | runs-on: ubuntu-latest 153 | needs: build 154 | steps: 155 | - uses: actions/checkout@v4 156 | - name: Install libudev-dev 157 | run: sudo apt-get update && sudo apt-get install -y libudev-dev 158 | - name: Use Rust 1.85.1 159 | run: rustup override set 1.85.1 160 | - uses: Swatinem/rust-cache@v2 161 | - name: Install cargo-edit 162 | run: cargo install cargo-edit 163 | - name: Set the version to a dummy version to allow publishing 164 | run: cargo set-version 9.9.9 165 | - name: Publish to Crates.io 166 | run: cargo publish --dry-run --allow-dirty 167 | create_github_release: 168 | name: Create release with binary assets 169 | runs-on: ubuntu-latest 170 | needs: 171 | - build 172 | - create_and_sign_macos_universal_binary 173 | if: startsWith(github.event.ref, 'refs/tags/v') 174 | steps: 175 | - name: Sanitise Git ref for use in filenames 176 | id: sanitise_ref 177 | run: echo "::set-output name=value::$(echo "${{ github.ref_name }}" | tr '/' '_')" 178 | - uses: actions/download-artifact@v4 179 | with: 180 | name: litra_${{ steps.sanitise_ref.outputs.value }}_linux-amd64 181 | - uses: actions/download-artifact@v4 182 | with: 183 | name: litra_${{ steps.sanitise_ref.outputs.value }}_linux-aarch64 184 | - uses: actions/download-artifact@v4 185 | with: 186 | name: litra_${{ steps.sanitise_ref.outputs.value }}_darwin-amd64 187 | - uses: actions/download-artifact@v4 188 | with: 189 | name: litra_${{ steps.sanitise_ref.outputs.value }}_darwin-aarch64 190 | - uses: actions/download-artifact@v4 191 | with: 192 | name: litra_${{ steps.sanitise_ref.outputs.value }}_darwin-universal 193 | - uses: actions/download-artifact@v4 194 | with: 195 | name: litra_${{ steps.sanitise_ref.outputs.value }}_windows-amd64.exe 196 | - name: Create release 197 | uses: softprops/action-gh-release@v2 198 | with: 199 | files: | 200 | litra_${{ steps.sanitise_ref.outputs.value }}_windows-amd64.exe 201 | litra_${{ steps.sanitise_ref.outputs.value }}_darwin-amd64 202 | litra_${{ steps.sanitise_ref.outputs.value }}_darwin-aarch64 203 | litra_${{ steps.sanitise_ref.outputs.value }}_linux-amd64 204 | litra_${{ steps.sanitise_ref.outputs.value }}_linux-aarch64 205 | litra_${{ steps.sanitise_ref.outputs.value }}_darwin-universal 206 | cargo_publish: 207 | name: Publish with Cargo to Crates.io 208 | runs-on: ubuntu-latest 209 | needs: 210 | - create_github_release 211 | - cargo_publish_dry_run 212 | if: startsWith(github.event.ref, 'refs/tags/v') 213 | steps: 214 | - uses: actions/checkout@v4 215 | - name: Install libudev-dev 216 | run: sudo apt-get update && sudo apt-get install -y libudev-dev 217 | - name: Use Rust 1.85.1 with target ${{ matrix.job.target }} 218 | run: rustup override set 1.85.1-${{ matrix.job.target }} 219 | - uses: Swatinem/rust-cache@v2 220 | - name: Publish to Crates.io 221 | run: cargo publish --token ${{ secrets.CRATES_IO_API_TOKEN }} 222 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | pre-commit: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Install libudev-dev 12 | run: sudo apt-get update && sudo apt-get install -y libudev-dev 13 | 14 | - name: Check out repository 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 2 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v5 21 | 22 | - name: Use Rust 1.85.1 23 | run: rustup override set 1.85.1 24 | 25 | - run: rustup component add clippy rustfmt 26 | 27 | - uses: Swatinem/rust-cache@v2 28 | 29 | - name: Detect code style issues 30 | uses: pre-commit/action@v3.0.1 31 | env: 32 | SKIP: no-commit-to-branch 33 | 34 | - name: Generate patch file 35 | if: failure() 36 | run: | 37 | git diff-index -p HEAD > "${PATCH_FILE}" 38 | [ -s "${PATCH_FILE}" ] && echo "UPLOAD_PATCH_FILE=${PATCH_FILE}" >> "${GITHUB_ENV}" 39 | env: 40 | PATCH_FILE: pre-commit.patch 41 | 42 | - name: Upload patch artifact 43 | if: failure() && env.UPLOAD_PATCH_FILE != null 44 | uses: actions/upload-artifact@v4 45 | with: 46 | name: ${{ env.UPLOAD_PATCH_FILE }} 47 | path: ${{ env.UPLOAD_PATCH_FILE }} 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.5.0 6 | hooks: 7 | - id: check-case-conflict 8 | - id: check-json 9 | - id: check-merge-conflict 10 | - id: check-symlinks 11 | - id: check-toml 12 | - id: check-xml 13 | - id: check-yaml 14 | - id: destroyed-symlinks 15 | - id: detect-private-key 16 | - id: end-of-file-fixer 17 | - id: fix-byte-order-marker 18 | - id: forbid-new-submodules 19 | - id: mixed-line-ending 20 | - id: trailing-whitespace 21 | - repo: https://github.com/codespell-project/codespell 22 | rev: v2.2.6 23 | hooks: 24 | - id: codespell 25 | args: [ 26 | --ignore-words=.codespellignore 27 | ] 28 | - repo: https://github.com/doublify/pre-commit-rust 29 | rev: v1.0 30 | hooks: 31 | - id: fmt 32 | args: [ 33 | --all, 34 | --, 35 | ] 36 | - id: cargo-check 37 | args: [ 38 | --locked, 39 | --workspace, 40 | --all-features, 41 | --all-targets, 42 | ] 43 | - id: clippy 44 | args: [ 45 | --locked, 46 | --workspace, 47 | --all-features, 48 | --all-targets, 49 | --, 50 | -D, 51 | warnings, 52 | ] 53 | -------------------------------------------------------------------------------- /99-litra.rules: -------------------------------------------------------------------------------- 1 | SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c900", GROUP="video", MODE="0660" 2 | SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c901", GROUP="video", MODE="0660" 3 | SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="b901", GROUP="video", MODE="0660" 4 | SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c903", GROUP="video", MODE="0660" 5 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "anstream" 7 | version = "0.6.15" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" 10 | dependencies = [ 11 | "anstyle", 12 | "anstyle-parse", 13 | "anstyle-query", 14 | "anstyle-wincon", 15 | "colorchoice", 16 | "is_terminal_polyfill", 17 | "utf8parse", 18 | ] 19 | 20 | [[package]] 21 | name = "anstyle" 22 | version = "1.0.8" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" 25 | 26 | [[package]] 27 | name = "anstyle-parse" 28 | version = "0.2.5" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" 31 | dependencies = [ 32 | "utf8parse", 33 | ] 34 | 35 | [[package]] 36 | name = "anstyle-query" 37 | version = "1.1.1" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" 40 | dependencies = [ 41 | "windows-sys 0.52.0", 42 | ] 43 | 44 | [[package]] 45 | name = "anstyle-wincon" 46 | version = "3.0.4" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" 49 | dependencies = [ 50 | "anstyle", 51 | "windows-sys 0.52.0", 52 | ] 53 | 54 | [[package]] 55 | name = "cc" 56 | version = "1.1.24" 57 | source = "registry+https://github.com/rust-lang/crates.io-index" 58 | checksum = "812acba72f0a070b003d3697490d2b55b837230ae7c6c6497f05cc2ddbb8d938" 59 | dependencies = [ 60 | "shlex", 61 | ] 62 | 63 | [[package]] 64 | name = "cfg-if" 65 | version = "1.0.0" 66 | source = "registry+https://github.com/rust-lang/crates.io-index" 67 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 68 | 69 | [[package]] 70 | name = "clap" 71 | version = "4.5.32" 72 | source = "registry+https://github.com/rust-lang/crates.io-index" 73 | checksum = "6088f3ae8c3608d19260cd7445411865a485688711b78b5be70d78cd96136f83" 74 | dependencies = [ 75 | "clap_builder", 76 | "clap_derive", 77 | ] 78 | 79 | [[package]] 80 | name = "clap_builder" 81 | version = "4.5.32" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "22a7ef7f676155edfb82daa97f99441f3ebf4a58d5e32f295a56259f1b6facc8" 84 | dependencies = [ 85 | "anstream", 86 | "anstyle", 87 | "clap_lex", 88 | "strsim", 89 | ] 90 | 91 | [[package]] 92 | name = "clap_derive" 93 | version = "4.5.32" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" 96 | dependencies = [ 97 | "heck", 98 | "proc-macro2", 99 | "quote", 100 | "syn", 101 | ] 102 | 103 | [[package]] 104 | name = "clap_lex" 105 | version = "0.7.4" 106 | source = "registry+https://github.com/rust-lang/crates.io-index" 107 | checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" 108 | 109 | [[package]] 110 | name = "colorchoice" 111 | version = "1.0.2" 112 | source = "registry+https://github.com/rust-lang/crates.io-index" 113 | checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" 114 | 115 | [[package]] 116 | name = "heck" 117 | version = "0.5.0" 118 | source = "registry+https://github.com/rust-lang/crates.io-index" 119 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 120 | 121 | [[package]] 122 | name = "hidapi" 123 | version = "2.6.3" 124 | source = "registry+https://github.com/rust-lang/crates.io-index" 125 | checksum = "03b876ecf37e86b359573c16c8366bc3eba52b689884a0fc42ba3f67203d2a8b" 126 | dependencies = [ 127 | "cc", 128 | "cfg-if", 129 | "libc", 130 | "pkg-config", 131 | "windows-sys 0.48.0", 132 | ] 133 | 134 | [[package]] 135 | name = "is_terminal_polyfill" 136 | version = "1.70.1" 137 | source = "registry+https://github.com/rust-lang/crates.io-index" 138 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 139 | 140 | [[package]] 141 | name = "itoa" 142 | version = "1.0.11" 143 | source = "registry+https://github.com/rust-lang/crates.io-index" 144 | checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" 145 | 146 | [[package]] 147 | name = "libc" 148 | version = "0.2.159" 149 | source = "registry+https://github.com/rust-lang/crates.io-index" 150 | checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" 151 | 152 | [[package]] 153 | name = "litra" 154 | version = "2.2.0" 155 | dependencies = [ 156 | "clap", 157 | "hidapi", 158 | "serde", 159 | "serde_json", 160 | ] 161 | 162 | [[package]] 163 | name = "memchr" 164 | version = "2.7.4" 165 | source = "registry+https://github.com/rust-lang/crates.io-index" 166 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 167 | 168 | [[package]] 169 | name = "pkg-config" 170 | version = "0.3.31" 171 | source = "registry+https://github.com/rust-lang/crates.io-index" 172 | checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" 173 | 174 | [[package]] 175 | name = "proc-macro2" 176 | version = "1.0.86" 177 | source = "registry+https://github.com/rust-lang/crates.io-index" 178 | checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" 179 | dependencies = [ 180 | "unicode-ident", 181 | ] 182 | 183 | [[package]] 184 | name = "quote" 185 | version = "1.0.37" 186 | source = "registry+https://github.com/rust-lang/crates.io-index" 187 | checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" 188 | dependencies = [ 189 | "proc-macro2", 190 | ] 191 | 192 | [[package]] 193 | name = "ryu" 194 | version = "1.0.18" 195 | source = "registry+https://github.com/rust-lang/crates.io-index" 196 | checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" 197 | 198 | [[package]] 199 | name = "serde" 200 | version = "1.0.219" 201 | source = "registry+https://github.com/rust-lang/crates.io-index" 202 | checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 203 | dependencies = [ 204 | "serde_derive", 205 | ] 206 | 207 | [[package]] 208 | name = "serde_derive" 209 | version = "1.0.219" 210 | source = "registry+https://github.com/rust-lang/crates.io-index" 211 | checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 212 | dependencies = [ 213 | "proc-macro2", 214 | "quote", 215 | "syn", 216 | ] 217 | 218 | [[package]] 219 | name = "serde_json" 220 | version = "1.0.140" 221 | source = "registry+https://github.com/rust-lang/crates.io-index" 222 | checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" 223 | dependencies = [ 224 | "itoa", 225 | "memchr", 226 | "ryu", 227 | "serde", 228 | ] 229 | 230 | [[package]] 231 | name = "shlex" 232 | version = "1.3.0" 233 | source = "registry+https://github.com/rust-lang/crates.io-index" 234 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 235 | 236 | [[package]] 237 | name = "strsim" 238 | version = "0.11.1" 239 | source = "registry+https://github.com/rust-lang/crates.io-index" 240 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 241 | 242 | [[package]] 243 | name = "syn" 244 | version = "2.0.85" 245 | source = "registry+https://github.com/rust-lang/crates.io-index" 246 | checksum = "5023162dfcd14ef8f32034d8bcd4cc5ddc61ef7a247c024a33e24e1f24d21b56" 247 | dependencies = [ 248 | "proc-macro2", 249 | "quote", 250 | "unicode-ident", 251 | ] 252 | 253 | [[package]] 254 | name = "unicode-ident" 255 | version = "1.0.13" 256 | source = "registry+https://github.com/rust-lang/crates.io-index" 257 | checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" 258 | 259 | [[package]] 260 | name = "utf8parse" 261 | version = "0.2.2" 262 | source = "registry+https://github.com/rust-lang/crates.io-index" 263 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 264 | 265 | [[package]] 266 | name = "windows-sys" 267 | version = "0.48.0" 268 | source = "registry+https://github.com/rust-lang/crates.io-index" 269 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 270 | dependencies = [ 271 | "windows-targets 0.48.5", 272 | ] 273 | 274 | [[package]] 275 | name = "windows-sys" 276 | version = "0.52.0" 277 | source = "registry+https://github.com/rust-lang/crates.io-index" 278 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 279 | dependencies = [ 280 | "windows-targets 0.52.6", 281 | ] 282 | 283 | [[package]] 284 | name = "windows-targets" 285 | version = "0.48.5" 286 | source = "registry+https://github.com/rust-lang/crates.io-index" 287 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 288 | dependencies = [ 289 | "windows_aarch64_gnullvm 0.48.5", 290 | "windows_aarch64_msvc 0.48.5", 291 | "windows_i686_gnu 0.48.5", 292 | "windows_i686_msvc 0.48.5", 293 | "windows_x86_64_gnu 0.48.5", 294 | "windows_x86_64_gnullvm 0.48.5", 295 | "windows_x86_64_msvc 0.48.5", 296 | ] 297 | 298 | [[package]] 299 | name = "windows-targets" 300 | version = "0.52.6" 301 | source = "registry+https://github.com/rust-lang/crates.io-index" 302 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 303 | dependencies = [ 304 | "windows_aarch64_gnullvm 0.52.6", 305 | "windows_aarch64_msvc 0.52.6", 306 | "windows_i686_gnu 0.52.6", 307 | "windows_i686_gnullvm", 308 | "windows_i686_msvc 0.52.6", 309 | "windows_x86_64_gnu 0.52.6", 310 | "windows_x86_64_gnullvm 0.52.6", 311 | "windows_x86_64_msvc 0.52.6", 312 | ] 313 | 314 | [[package]] 315 | name = "windows_aarch64_gnullvm" 316 | version = "0.48.5" 317 | source = "registry+https://github.com/rust-lang/crates.io-index" 318 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 319 | 320 | [[package]] 321 | name = "windows_aarch64_gnullvm" 322 | version = "0.52.6" 323 | source = "registry+https://github.com/rust-lang/crates.io-index" 324 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 325 | 326 | [[package]] 327 | name = "windows_aarch64_msvc" 328 | version = "0.48.5" 329 | source = "registry+https://github.com/rust-lang/crates.io-index" 330 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 331 | 332 | [[package]] 333 | name = "windows_aarch64_msvc" 334 | version = "0.52.6" 335 | source = "registry+https://github.com/rust-lang/crates.io-index" 336 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 337 | 338 | [[package]] 339 | name = "windows_i686_gnu" 340 | version = "0.48.5" 341 | source = "registry+https://github.com/rust-lang/crates.io-index" 342 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 343 | 344 | [[package]] 345 | name = "windows_i686_gnu" 346 | version = "0.52.6" 347 | source = "registry+https://github.com/rust-lang/crates.io-index" 348 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 349 | 350 | [[package]] 351 | name = "windows_i686_gnullvm" 352 | version = "0.52.6" 353 | source = "registry+https://github.com/rust-lang/crates.io-index" 354 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 355 | 356 | [[package]] 357 | name = "windows_i686_msvc" 358 | version = "0.48.5" 359 | source = "registry+https://github.com/rust-lang/crates.io-index" 360 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 361 | 362 | [[package]] 363 | name = "windows_i686_msvc" 364 | version = "0.52.6" 365 | source = "registry+https://github.com/rust-lang/crates.io-index" 366 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 367 | 368 | [[package]] 369 | name = "windows_x86_64_gnu" 370 | version = "0.48.5" 371 | source = "registry+https://github.com/rust-lang/crates.io-index" 372 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 373 | 374 | [[package]] 375 | name = "windows_x86_64_gnu" 376 | version = "0.52.6" 377 | source = "registry+https://github.com/rust-lang/crates.io-index" 378 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 379 | 380 | [[package]] 381 | name = "windows_x86_64_gnullvm" 382 | version = "0.48.5" 383 | source = "registry+https://github.com/rust-lang/crates.io-index" 384 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 385 | 386 | [[package]] 387 | name = "windows_x86_64_gnullvm" 388 | version = "0.52.6" 389 | source = "registry+https://github.com/rust-lang/crates.io-index" 390 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 391 | 392 | [[package]] 393 | name = "windows_x86_64_msvc" 394 | version = "0.48.5" 395 | source = "registry+https://github.com/rust-lang/crates.io-index" 396 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 397 | 398 | [[package]] 399 | name = "windows_x86_64_msvc" 400 | version = "0.52.6" 401 | source = "registry+https://github.com/rust-lang/crates.io-index" 402 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 403 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "litra" 3 | version = "2.2.0" 4 | edition = "2021" 5 | authors = ["Tim Rogers "] 6 | description = "Control your Logitech Litra light from the command line" 7 | repository = "https://github.com/timrogers/litra-rs" 8 | license = "MIT" 9 | readme = "README.md" 10 | categories = ["hardware-support", "command-line-utilities"] 11 | keywords = ["logitech", "litra", "glow", "beam", "light"] 12 | 13 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 14 | 15 | [dependencies] 16 | hidapi = "2.6.3" 17 | clap = { version = "4.5.32", features = ["derive"], optional = true } 18 | serde = { version = "1.0.219", features = ["derive"], optional = true } 19 | serde_json = { version = "1.0.140", optional = true } 20 | 21 | [features] 22 | default = ["cli"] 23 | cli = ["dep:clap", "dep:serde", "dep:serde_json"] 24 | 25 | # TODO: Remove this once we're on a newer tokio version that doesn't trip this up 26 | # https://github.com/tokio-rs/tokio/pull/6874 27 | [lints.clippy] 28 | needless_return = "allow" 29 | 30 | [[bin]] 31 | name = "litra" 32 | required-features = ["cli"] 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Tim Rogers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `litra-rs` 2 | 3 | 💡 Control your Logitech Litra light from the command line 4 | 5 | --- 6 | 7 | ## Features 8 | 9 | With this tool, you can: 10 | 11 | - Turn your light on and off 12 | - Check if the light is on or off 13 | - Set, get, increase and decrease the brightness of your light 14 | - Set, get, increase and decrease the temperature of your light 15 | 16 | > [!TIP] 17 | > 🖲️ Want to automatically turn your Litra on and off when your webcam turns on and off? Check out [`litra-autotoggle`](https://github.com/timrogers/litra-autotoggle)! 18 | 19 | ## Supported devices 20 | 21 | The following Logitech Litra devices, __connected via USB__, are supported: 22 | 23 | * [Logitech Litra Glow](https://www.logitech.com/en-gb/products/lighting/litra-glow.946-000002.html) 24 | * [Logitech Litra Beam](https://www.logitech.com/en-gb/products/lighting/litra-beam.946-000007.html) 25 | * [Logitech Litra Beam LX](https://www.logitechg.com/en-gb/products/cameras-lighting/litra-beam-lx-led-light.946-000015.html) 26 | 27 | ## Installation 28 | 29 | ### macOS or Linux via [Homebrew](https://brew.sh/) 30 | 31 | 1. Install the latest version by running `brew tap timrogers/tap && brew install litra`. 32 | 1. Run `litra --help` to check that everything is working and see the available commands. 33 | 34 | ### macOS, Linux or Windows via [Cargo](https://doc.rust-lang.org/cargo/), Rust's package manager 35 | 36 | 1. Install [Rust](https://www.rust-lang.org/tools/install) on your machine, if it isn't already installed. 37 | 1. Install the `litra` crate by running `cargo install litra`. 38 | 1. Run `litra --help` to check that everything is working and see the available commands. 39 | 40 | ### macOS, Linux or Windows via direct binary download 41 | 42 | 1. Download the [latest release](https://github.com/timrogers/litra-rs/releases/latest) for your platform. macOS, Linux and Windows devices are supported. 43 | 2. Add the binary to `$PATH`, so you can execute it from your shell. For the best experience, call it `litra` on macOS and Linux, and `litra.exe` on Windows. 44 | 3. Run `litra --help` to check that everything is working and see the available commands. 45 | 46 | ## Configuring `udev` permissions on Linux 47 | 48 | On most Linux operating systems, you will need to manually configure permissions using [`udev`](https://www.man7.org/linux/man-pages/man7/udev.7.html) to allow non-`root` users to access and manage Litra devices. 49 | 50 | To allow all users that are part of the `video` group to access the Litra devices, copy the [`99-litra.rules`](99-litra.rules) file into `/etc/udev/rules.d`. 51 | Next, reboot your computer or run the following commands as `root`: 52 | 53 | # udevadm control --reload-rules 54 | # udevadm trigger 55 | 56 | ## Usage 57 | 58 | ### From the command line 59 | 60 | The following commands are available for controlling your devices: 61 | 62 | - `litra on`: Turn your Logitech Litra device on 63 | - `litra off`: Turn your Logitech Litra device off 64 | - `litra toggle`: Toggles your Logitech Litra device on or off 65 | - `litra brightness`: Sets the brightness of your Logitech Litra device, using either `--value` (measured in lumens) or `--percentage` (as a percentage of the device's maximum brightness). The brightness can be set to any value between the minimum and maximum for the device returned by the `devices` command. 66 | - `litra brightness-up`: Increases the brightness of your Logitech Litra device, using either `--value` (measured in lumens) or `--percentage` (with a number of percentage points to add to the device's brightness) 67 | - `litra brightness-down`: Decreases the brightness of your Logitech Litra device, using either `--value` (measured in lumens) or `--percentage` (with a number of percentage points to subtract from the device's brightness) 68 | - `litra temperature`: Sets the temperature of your Logitech Litra device, using a `--value` measured in kelvin (K). The temperature be set to any multiple of 100 between the minimum and maximum for the device returned by the `devices` command. 69 | - `litra temperature-up`: Increases the temperature of your Logitech Litra device, using a `--value` measured in kelvin (K). The value must be a multiple of 100. 70 | - `litra temperature-down`: Decreases the temperature of your Logitech Litra device, using a `--value` measured in kelvin (K). The value must be a multiple of 100. 71 | 72 | All of the these commands support a `--serial-number`/`-s` argument to specify the serial number of the device you want to target. If you only have one Litra device, you can omit this argument. If you have multiple devices, we recommend specifying it. If it isn't specified, the "first" device will be picked, but this isn't guaranteed to be stable between command runs. 73 | 74 | The following commands are also included: 75 | 76 | - `litra devices`: List Logitech Litra devices connected to your computer. This will be returned in human-readable format by default, or you can get JSON output with the `--json` flag. 77 | 78 | Each CLI command can also be called with `--help` for more detailed documentation. 79 | 80 | ### From a Rust application 81 | 82 | The `litra` crate includes functions for interacting with Litra devices from your Rust applications. 83 | 84 | To see the full API, check out the documentation on [Docs.rs](https://docs.rs/litra/) or read through [`src/lib.rs`](src/lib.rs). 85 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.85.1" 3 | components = [ "rustc", "rustfmt", "clippy" ] 4 | targets = [ "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "aarch64-apple-darwin", "x86_64-pc-windows-msvc" ] 5 | profile = "minimal" 6 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Library to query and control your Logitech Litra lights. 2 | //! 3 | //! # Usage 4 | //! 5 | //! ``` 6 | //! use litra::Litra; 7 | //! 8 | //! let context = Litra::new().expect("Failed to initialize litra."); 9 | //! for device in context.get_connected_devices() { 10 | //! println!("Device {:?}", device.device_type()); 11 | //! if let Ok(handle) = device.open(&context) { 12 | //! println!("| - Is on: {}", handle.is_on() 13 | //! .map(|on| if on { "yes" } else { "no" }) 14 | //! .unwrap_or("unknown")); 15 | //! } 16 | //! } 17 | //! ``` 18 | 19 | #![warn(unsafe_code)] 20 | #![warn(missing_docs)] 21 | #![cfg_attr(not(debug_assertions), deny(warnings))] 22 | #![deny(rust_2018_idioms)] 23 | #![deny(rust_2021_compatibility)] 24 | #![deny(missing_debug_implementations)] 25 | #![deny(rustdoc::broken_intra_doc_links)] 26 | #![deny(clippy::all)] 27 | #![deny(clippy::explicit_deref_methods)] 28 | #![deny(clippy::explicit_into_iter_loop)] 29 | #![deny(clippy::explicit_iter_loop)] 30 | #![deny(clippy::must_use_candidate)] 31 | #![cfg_attr(not(test), deny(clippy::panic_in_result_fn))] 32 | #![cfg_attr(not(debug_assertions), deny(clippy::used_underscore_binding))] 33 | 34 | use hidapi::{DeviceInfo, HidApi, HidDevice, HidError}; 35 | use std::error::Error; 36 | use std::fmt; 37 | 38 | /// Litra context. 39 | /// 40 | /// This can be used to list available devices. 41 | pub struct Litra(HidApi); 42 | 43 | impl fmt::Debug for Litra { 44 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 45 | f.debug_tuple("Litra").finish() 46 | } 47 | } 48 | 49 | impl Litra { 50 | /// Initialize a new Litra context. 51 | pub fn new() -> DeviceResult { 52 | let hidapi = HidApi::new()?; 53 | #[cfg(target_os = "macos")] 54 | hidapi.set_open_exclusive(false); 55 | Ok(Litra(hidapi)) 56 | } 57 | 58 | /// Returns an [`Iterator`] of cached connected devices supported by this library. To refresh the list of connected devices, use [`Litra::refresh_connected_devices`]. 59 | pub fn get_connected_devices(&self) -> impl Iterator> { 60 | self.0 61 | .device_list() 62 | .filter_map(|device_info| Device::try_from(device_info).ok()) 63 | } 64 | 65 | /// Refreshes the list of connected devices, returned by [`Litra::get_connected_devices`]. 66 | pub fn refresh_connected_devices(&mut self) -> DeviceResult<()> { 67 | self.0.refresh_devices()?; 68 | Ok(()) 69 | } 70 | 71 | /// Retrieve the underlying hidapi context. 72 | #[must_use] 73 | pub fn hidapi(&self) -> &HidApi { 74 | &self.0 75 | } 76 | } 77 | 78 | /// The model of the device. 79 | #[derive(Debug, Clone, Copy, PartialEq)] 80 | pub enum DeviceType { 81 | /// Logitech [Litra Glow][glow] streaming light with TrueSoft. 82 | /// 83 | /// [glow]: https://www.logitech.com/products/lighting/litra-glow.html 84 | LitraGlow, 85 | /// Logitech [Litra Beam][beam] LED streaming key light with TrueSoft. 86 | /// 87 | /// [beam]: https://www.logitechg.com/products/cameras-lighting/litra-beam-streaming-light.html 88 | LitraBeam, 89 | /// Logitech [Litra Beam LX][beamlx] dual-sided RGB streaming key light. 90 | /// 91 | /// [beamlx]: https://www.logitechg.com/products/cameras-lighting/litra-beam-lx-led-light.html 92 | LitraBeamLX, 93 | } 94 | 95 | impl fmt::Display for DeviceType { 96 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 97 | match self { 98 | DeviceType::LitraGlow => write!(f, "Litra Glow"), 99 | DeviceType::LitraBeam => write!(f, "Litra Beam"), 100 | DeviceType::LitraBeamLX => write!(f, "Litra Beam LX"), 101 | } 102 | } 103 | } 104 | 105 | /// A device-relatred error. 106 | #[derive(Debug)] 107 | pub enum DeviceError { 108 | /// Tried to use a device that is not supported. 109 | Unsupported, 110 | /// Tried to set an invalid brightness value. 111 | InvalidBrightness(u16), 112 | /// Tried to set an invalid temperature value. 113 | InvalidTemperature(u16), 114 | /// A [`hidapi`] operation failed. 115 | HidError(HidError), 116 | } 117 | 118 | impl fmt::Display for DeviceError { 119 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 120 | match self { 121 | DeviceError::Unsupported => write!(f, "Device is not supported"), 122 | DeviceError::InvalidBrightness(value) => { 123 | write!(f, "Brightness {} lm is not supported", value) 124 | } 125 | DeviceError::InvalidTemperature(value) => { 126 | write!(f, "Temperature {} K is not supported", value) 127 | } 128 | DeviceError::HidError(error) => write!(f, "HID error occurred: {}", error), 129 | } 130 | } 131 | } 132 | 133 | impl Error for DeviceError { 134 | fn source(&self) -> Option<&(dyn Error + 'static)> { 135 | if let DeviceError::HidError(error) = self { 136 | Some(error) 137 | } else { 138 | None 139 | } 140 | } 141 | } 142 | 143 | impl From for DeviceError { 144 | fn from(error: HidError) -> Self { 145 | DeviceError::HidError(error) 146 | } 147 | } 148 | 149 | /// The [`Result`] of a Litra device operation. 150 | pub type DeviceResult = Result; 151 | 152 | /// A device that can be used. 153 | #[derive(Debug)] 154 | pub struct Device<'a> { 155 | device_info: &'a DeviceInfo, 156 | device_type: DeviceType, 157 | } 158 | 159 | impl<'a> TryFrom<&'a DeviceInfo> for Device<'a> { 160 | type Error = DeviceError; 161 | 162 | fn try_from(device_info: &'a DeviceInfo) -> Result { 163 | if device_info.vendor_id() != VENDOR_ID || device_info.usage_page() != USAGE_PAGE { 164 | return Err(DeviceError::Unsupported); 165 | } 166 | device_type_from_product_id(device_info.product_id()) 167 | .map(|device_type| Device { 168 | device_info, 169 | device_type, 170 | }) 171 | .ok_or(DeviceError::Unsupported) 172 | } 173 | } 174 | 175 | impl Device<'_> { 176 | /// The model of the device. 177 | #[must_use] 178 | pub fn device_info(&self) -> &DeviceInfo { 179 | self.device_info 180 | } 181 | 182 | /// The model of the device. 183 | #[must_use] 184 | pub fn device_type(&self) -> DeviceType { 185 | self.device_type 186 | } 187 | 188 | /// Opens the device and returns a [`DeviceHandle`] that can be used for getting and setting the 189 | /// device status. On macOS, this will open the device in non-exclusive mode. 190 | pub fn open(&self, context: &Litra) -> DeviceResult { 191 | let hid_device = self.device_info.open_device(context.hidapi())?; 192 | Ok(DeviceHandle { 193 | hid_device, 194 | device_type: self.device_type, 195 | }) 196 | } 197 | } 198 | 199 | /// The handle of an opened device that can be used for getting and setting the device status. 200 | #[derive(Debug)] 201 | pub struct DeviceHandle { 202 | hid_device: HidDevice, 203 | device_type: DeviceType, 204 | } 205 | 206 | impl DeviceHandle { 207 | /// The model of the device. 208 | #[must_use] 209 | pub fn device_type(&self) -> DeviceType { 210 | self.device_type 211 | } 212 | 213 | /// The [`HidDevice`] for the device. 214 | #[must_use] 215 | pub fn hid_device(&self) -> &HidDevice { 216 | &self.hid_device 217 | } 218 | 219 | /// Returns the serial number of the device. 220 | pub fn serial_number(&self) -> DeviceResult> { 221 | match self.hid_device.get_device_info() { 222 | Ok(device_info) => Ok(device_info.serial_number().map(String::from)), 223 | Err(error) => Err(DeviceError::HidError(error)), 224 | } 225 | } 226 | 227 | /// Queries the current power status of the device. Returns `true` if the device is currently on. 228 | pub fn is_on(&self) -> DeviceResult { 229 | let message = generate_is_on_bytes(&self.device_type); 230 | 231 | self.hid_device.write(&message)?; 232 | 233 | let mut response_buffer = [0x00; 20]; 234 | let response = self.hid_device.read(&mut response_buffer[..])?; 235 | 236 | Ok(response_buffer[..response][4] == 1) 237 | } 238 | 239 | /// Sets the power status of the device. Turns the device on if `true` is passed and turns it 240 | /// of on `false`. 241 | pub fn set_on(&self, on: bool) -> DeviceResult<()> { 242 | let message = generate_set_on_bytes(&self.device_type, on); 243 | 244 | self.hid_device.write(&message)?; 245 | Ok(()) 246 | } 247 | 248 | /// Queries the device's current brightness in Lumen. 249 | pub fn brightness_in_lumen(&self) -> DeviceResult { 250 | let message = generate_get_brightness_in_lumen_bytes(&self.device_type); 251 | 252 | self.hid_device.write(&message)?; 253 | 254 | let mut response_buffer = [0x00; 20]; 255 | let response = self.hid_device.read(&mut response_buffer[..])?; 256 | 257 | Ok(u16::from(response_buffer[..response][4]) * 256 258 | + u16::from(response_buffer[..response][5])) 259 | } 260 | 261 | /// Sets the device's brightness in Lumen. 262 | pub fn set_brightness_in_lumen(&self, brightness_in_lumen: u16) -> DeviceResult<()> { 263 | if brightness_in_lumen < self.minimum_brightness_in_lumen() 264 | || brightness_in_lumen > self.maximum_brightness_in_lumen() 265 | { 266 | return Err(DeviceError::InvalidBrightness(brightness_in_lumen)); 267 | } 268 | 269 | let message = 270 | generate_set_brightness_in_lumen_bytes(&self.device_type, brightness_in_lumen); 271 | 272 | self.hid_device.write(&message)?; 273 | Ok(()) 274 | } 275 | 276 | /// Returns the minimum brightness supported by the device in Lumen. 277 | #[must_use] 278 | pub fn minimum_brightness_in_lumen(&self) -> u16 { 279 | match self.device_type { 280 | DeviceType::LitraGlow => 20, 281 | DeviceType::LitraBeam | DeviceType::LitraBeamLX => 30, 282 | } 283 | } 284 | 285 | /// Returns the maximum brightness supported by the device in Lumen. 286 | #[must_use] 287 | pub fn maximum_brightness_in_lumen(&self) -> u16 { 288 | match self.device_type { 289 | DeviceType::LitraGlow => 250, 290 | DeviceType::LitraBeam | DeviceType::LitraBeamLX => 400, 291 | } 292 | } 293 | 294 | /// Queries the device's current color temperature in Kelvin. 295 | pub fn temperature_in_kelvin(&self) -> DeviceResult { 296 | let message = generate_get_temperature_in_kelvin_bytes(&self.device_type); 297 | 298 | self.hid_device.write(&message)?; 299 | 300 | let mut response_buffer = [0x00; 20]; 301 | let response = self.hid_device.read(&mut response_buffer[..])?; 302 | Ok(u16::from(response_buffer[..response][4]) * 256 303 | + u16::from(response_buffer[..response][5])) 304 | } 305 | 306 | /// Sets the device's color temperature in Kelvin. 307 | pub fn set_temperature_in_kelvin(&self, temperature_in_kelvin: u16) -> DeviceResult<()> { 308 | if temperature_in_kelvin < self.minimum_temperature_in_kelvin() 309 | || temperature_in_kelvin > self.maximum_temperature_in_kelvin() 310 | || (temperature_in_kelvin % 100) != 0 311 | { 312 | return Err(DeviceError::InvalidTemperature(temperature_in_kelvin)); 313 | } 314 | 315 | let message = 316 | generate_set_temperature_in_kelvin_bytes(&self.device_type, temperature_in_kelvin); 317 | 318 | self.hid_device.write(&message)?; 319 | Ok(()) 320 | } 321 | 322 | /// Returns the minimum color temperature supported by the device in Kelvin. 323 | #[must_use] 324 | pub fn minimum_temperature_in_kelvin(&self) -> u16 { 325 | MINIMUM_TEMPERATURE_IN_KELVIN 326 | } 327 | 328 | /// Returns the maximum color temperature supported by the device in Kelvin. 329 | #[must_use] 330 | pub fn maximum_temperature_in_kelvin(&self) -> u16 { 331 | MAXIMUM_TEMPERATURE_IN_KELVIN 332 | } 333 | } 334 | 335 | const VENDOR_ID: u16 = 0x046d; 336 | const USAGE_PAGE: u16 = 0xff43; 337 | 338 | fn device_type_from_product_id(product_id: u16) -> Option { 339 | match product_id { 340 | 0xc900 => DeviceType::LitraGlow.into(), 341 | 0xc901 => DeviceType::LitraBeam.into(), 342 | 0xb901 => DeviceType::LitraBeam.into(), 343 | 0xc903 => DeviceType::LitraBeamLX.into(), 344 | _ => None, 345 | } 346 | } 347 | 348 | const MINIMUM_TEMPERATURE_IN_KELVIN: u16 = 2700; 349 | const MAXIMUM_TEMPERATURE_IN_KELVIN: u16 = 6500; 350 | 351 | fn generate_is_on_bytes(device_type: &DeviceType) -> [u8; 20] { 352 | match device_type { 353 | DeviceType::LitraGlow | DeviceType::LitraBeam => [ 354 | 0x11, 0xff, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 355 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 356 | ], 357 | DeviceType::LitraBeamLX => [ 358 | 0x11, 0xff, 0x06, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 359 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 360 | ], 361 | } 362 | } 363 | 364 | fn generate_get_brightness_in_lumen_bytes(device_type: &DeviceType) -> [u8; 20] { 365 | match device_type { 366 | DeviceType::LitraGlow | DeviceType::LitraBeam => [ 367 | 0x11, 0xff, 0x04, 0x31, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 368 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 369 | ], 370 | DeviceType::LitraBeamLX => [ 371 | 0x11, 0xff, 0x06, 0x31, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 372 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 373 | ], 374 | } 375 | } 376 | 377 | fn generate_get_temperature_in_kelvin_bytes(device_type: &DeviceType) -> [u8; 20] { 378 | match device_type { 379 | DeviceType::LitraGlow | DeviceType::LitraBeam => [ 380 | 0x11, 0xff, 0x04, 0x81, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 381 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 382 | ], 383 | DeviceType::LitraBeamLX => [ 384 | 0x11, 0xff, 0x06, 0x81, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 385 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 386 | ], 387 | } 388 | } 389 | 390 | fn generate_set_on_bytes(device_type: &DeviceType, on: bool) -> [u8; 20] { 391 | let on_byte = if on { 0x01 } else { 0x00 }; 392 | match device_type { 393 | DeviceType::LitraGlow | DeviceType::LitraBeam => [ 394 | 0x11, 0xff, 0x04, 0x1c, on_byte, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 395 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 396 | ], 397 | DeviceType::LitraBeamLX => [ 398 | 0x11, 0xff, 0x06, 0x1c, on_byte, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 399 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 400 | ], 401 | } 402 | } 403 | 404 | fn generate_set_brightness_in_lumen_bytes( 405 | device_type: &DeviceType, 406 | brightness_in_lumen: u16, 407 | ) -> [u8; 20] { 408 | let brightness_bytes = brightness_in_lumen.to_be_bytes(); 409 | 410 | match device_type { 411 | DeviceType::LitraGlow | DeviceType::LitraBeam => [ 412 | 0x11, 413 | 0xff, 414 | 0x04, 415 | 0x4c, 416 | brightness_bytes[0], 417 | brightness_bytes[1], 418 | 0x00, 419 | 0x00, 420 | 0x00, 421 | 0x00, 422 | 0x00, 423 | 0x00, 424 | 0x00, 425 | 0x00, 426 | 0x00, 427 | 0x00, 428 | 0x00, 429 | 0x00, 430 | 0x00, 431 | 0x00, 432 | ], 433 | DeviceType::LitraBeamLX => [ 434 | 0x11, 435 | 0xff, 436 | 0x06, 437 | 0x4c, 438 | brightness_bytes[0], 439 | brightness_bytes[1], 440 | 0x00, 441 | 0x00, 442 | 0x00, 443 | 0x00, 444 | 0x00, 445 | 0x00, 446 | 0x00, 447 | 0x00, 448 | 0x00, 449 | 0x00, 450 | 0x00, 451 | 0x00, 452 | 0x00, 453 | 0x00, 454 | ], 455 | } 456 | } 457 | 458 | fn generate_set_temperature_in_kelvin_bytes( 459 | device_type: &DeviceType, 460 | temperature_in_kelvin: u16, 461 | ) -> [u8; 20] { 462 | let temperature_bytes = temperature_in_kelvin.to_be_bytes(); 463 | 464 | match device_type { 465 | DeviceType::LitraGlow | DeviceType::LitraBeam => [ 466 | 0x11, 467 | 0xff, 468 | 0x04, 469 | 0x9c, 470 | temperature_bytes[0], 471 | temperature_bytes[1], 472 | 0x00, 473 | 0x00, 474 | 0x00, 475 | 0x00, 476 | 0x00, 477 | 0x00, 478 | 0x00, 479 | 0x00, 480 | 0x00, 481 | 0x00, 482 | 0x00, 483 | 0x00, 484 | 0x00, 485 | 0x00, 486 | ], 487 | DeviceType::LitraBeamLX => [ 488 | 0x11, 489 | 0xff, 490 | 0x06, 491 | 0x9c, 492 | temperature_bytes[0], 493 | temperature_bytes[1], 494 | 0x00, 495 | 0x00, 496 | 0x00, 497 | 0x00, 498 | 0x00, 499 | 0x00, 500 | 0x00, 501 | 0x00, 502 | 0x00, 503 | 0x00, 504 | 0x00, 505 | 0x00, 506 | 0x00, 507 | 0x00, 508 | ], 509 | } 510 | } 511 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use clap::{ArgGroup, Parser, Subcommand}; 2 | use litra::{Device, DeviceError, DeviceHandle, Litra}; 3 | use serde::Serialize; 4 | use std::fmt; 5 | use std::num::TryFromIntError; 6 | use std::process::ExitCode; 7 | 8 | /// Control your USB-connected Logitech Litra lights from the command line 9 | #[derive(Debug, Parser)] 10 | #[clap(name = "litra", version)] 11 | struct Cli { 12 | // Test 13 | #[clap(subcommand)] 14 | command: Commands, 15 | } 16 | 17 | #[derive(Debug, Subcommand)] 18 | enum Commands { 19 | /// Turn your Logitech Litra device on 20 | On { 21 | #[clap(long, short, help = "The serial number of the Logitech Litra device")] 22 | serial_number: Option, 23 | }, 24 | /// Turn your Logitech Litra device off 25 | Off { 26 | #[clap(long, short, help = "The serial number of the Logitech Litra device")] 27 | serial_number: Option, 28 | }, 29 | /// Toggles your Logitech Litra device on or off 30 | Toggle { 31 | #[clap(long, short, help = "The serial number of the Logitech Litra device")] 32 | serial_number: Option, 33 | }, 34 | /// Sets the brightness of your Logitech Litra device 35 | #[clap(group = ArgGroup::new("brightness").required(true).multiple(false))] 36 | Brightness { 37 | #[clap(long, short, help = "The serial number of the Logitech Litra device")] 38 | serial_number: Option, 39 | #[clap( 40 | long, 41 | short, 42 | help = "The brightness to set, measured in lumens. This can be set to any value between the minimum and maximum for the device returned by the `devices` command.", 43 | group = "brightness" 44 | )] 45 | value: Option, 46 | #[clap( 47 | long, 48 | short, 49 | help = "The brightness to set, as a percentage of the maximum brightness", 50 | group = "brightness" 51 | )] 52 | percentage: Option, 53 | }, 54 | /// Increases the brightness of your Logitech Litra device. The command will error if trying to increase the brightness beyond the device's maximum. 55 | #[clap(group = ArgGroup::new("brightness-up").required(true).multiple(false))] 56 | BrightnessUp { 57 | #[clap(long, short, help = "The serial number of the Logitech Litra device")] 58 | serial_number: Option, 59 | #[clap( 60 | long, 61 | short, 62 | help = "The amount to increase the brightness by, measured in lumens.", 63 | group = "brightness-up" 64 | )] 65 | value: Option, 66 | #[clap( 67 | long, 68 | short, 69 | help = "The number of percentage points to increase the brightness by", 70 | group = "brightness-up" 71 | )] 72 | percentage: Option, 73 | }, 74 | /// Decreases the brightness of your Logitech Litra device. The command will error if trying to decrease the brightness below the device's minimum. 75 | #[clap(group = ArgGroup::new("brightness-down").required(true).multiple(false))] 76 | BrightnessDown { 77 | #[clap(long, short, help = "The serial number of the Logitech Litra device")] 78 | serial_number: Option, 79 | #[clap( 80 | long, 81 | short, 82 | help = "The amount to decrease the brightness by, measured in lumens.", 83 | group = "brightness-down" 84 | )] 85 | value: Option, 86 | #[clap( 87 | long, 88 | short, 89 | help = "The number of percentage points to reduce the brightness by", 90 | group = "brightness-down" 91 | )] 92 | percentage: Option, 93 | }, 94 | /// Sets the temperature of your Logitech Litra device 95 | Temperature { 96 | #[clap(long, short, help = "The serial number of the Logitech Litra device")] 97 | serial_number: Option, 98 | #[clap( 99 | long, 100 | short, 101 | help = "The temperature to set, measured in Kelvin. This can be set to any multiple of 100 between the minimum and maximum for the device returned by the `devices` command." 102 | )] 103 | value: u16, 104 | }, 105 | /// Increases the temperature of your Logitech Litra device. The command will error if trying to increase the temperature beyond the device's maximum. 106 | TemperatureUp { 107 | #[clap(long, short, help = "The serial number of the Logitech Litra device")] 108 | serial_number: Option, 109 | #[clap( 110 | long, 111 | short, 112 | help = "The amount to increase the temperature by, measured in Kelvin. This must be a multiple of 100." 113 | )] 114 | value: u16, 115 | }, 116 | /// Decreases the temperature of your Logitech Litra device. The command will error if trying to decrease the temperature below the device's minimum. 117 | TemperatureDown { 118 | #[clap(long, short, help = "The serial number of the Logitech Litra device")] 119 | serial_number: Option, 120 | #[clap( 121 | long, 122 | short, 123 | help = "The amount to decrease the temperature by, measured in Kelvin. This must be a multiple of 100." 124 | )] 125 | value: u16, 126 | }, 127 | /// List Logitech Litra devices connected to your computer 128 | Devices { 129 | #[clap(long, short, action, help = "Return the results in JSON format")] 130 | json: bool, 131 | }, 132 | } 133 | 134 | fn percentage_within_range(percentage: u32, start_range: u32, end_range: u32) -> u32 { 135 | let range = end_range as f64 - start_range as f64; 136 | let result = (percentage as f64 / 100.0) * range + start_range as f64; 137 | result.round() as u32 138 | } 139 | 140 | fn get_is_on_text(is_on: bool) -> &'static str { 141 | if is_on { 142 | "On" 143 | } else { 144 | "Off" 145 | } 146 | } 147 | 148 | fn get_is_on_emoji(is_on: bool) -> &'static str { 149 | if is_on { 150 | "💡" 151 | } else { 152 | "🌑" 153 | } 154 | } 155 | 156 | fn check_serial_number_if_some(serial_number: Option<&str>) -> impl Fn(&Device) -> bool + '_ { 157 | move |device| { 158 | serial_number.as_ref().is_none_or(|expected| { 159 | device 160 | .device_info() 161 | .serial_number() 162 | .is_some_and(|actual| &actual == expected) 163 | }) 164 | } 165 | } 166 | 167 | #[derive(Debug)] 168 | enum CliError { 169 | DeviceError(DeviceError), 170 | SerializationFailed(serde_json::Error), 171 | BrightnessPercentageCalculationFailed(TryFromIntError), 172 | InvalidBrightness(i16), 173 | DeviceNotFound, 174 | } 175 | 176 | impl fmt::Display for CliError { 177 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 178 | match self { 179 | CliError::DeviceError(error) => error.fmt(f), 180 | CliError::SerializationFailed(error) => error.fmt(f), 181 | CliError::BrightnessPercentageCalculationFailed(error) => { 182 | write!(f, "Failed to calculate brightness: {}", error) 183 | } 184 | CliError::InvalidBrightness(brightness) => { 185 | write!(f, "Brightness {} lm is not supported", brightness) 186 | } 187 | CliError::DeviceNotFound => write!(f, "Device not found."), 188 | } 189 | } 190 | } 191 | 192 | impl From for CliError { 193 | fn from(error: DeviceError) -> Self { 194 | CliError::DeviceError(error) 195 | } 196 | } 197 | 198 | type CliResult = Result<(), CliError>; 199 | 200 | fn get_first_supported_device( 201 | context: &Litra, 202 | serial_number: Option<&str>, 203 | ) -> Result { 204 | context 205 | .get_connected_devices() 206 | .find(check_serial_number_if_some(serial_number)) 207 | .ok_or(CliError::DeviceNotFound) 208 | .and_then(|dev| dev.open(context).map_err(CliError::DeviceError)) 209 | } 210 | 211 | #[derive(Serialize, Debug)] 212 | struct DeviceInfo { 213 | pub serial_number: String, 214 | pub device_type: String, 215 | pub is_on: bool, 216 | pub brightness_in_lumen: u16, 217 | pub temperature_in_kelvin: u16, 218 | pub minimum_brightness_in_lumen: u16, 219 | pub maximum_brightness_in_lumen: u16, 220 | pub minimum_temperature_in_kelvin: u16, 221 | pub maximum_temperature_in_kelvin: u16, 222 | } 223 | 224 | fn handle_devices_command(json: bool) -> CliResult { 225 | let context = Litra::new()?; 226 | let litra_devices: Vec = context 227 | .get_connected_devices() 228 | .filter_map(|device| { 229 | let device_handle = device.open(&context).ok()?; 230 | Some(DeviceInfo { 231 | serial_number: device 232 | .device_info() 233 | .serial_number() 234 | .unwrap_or("") 235 | .to_string(), 236 | device_type: device.device_type().to_string(), 237 | is_on: device_handle.is_on().ok()?, 238 | brightness_in_lumen: device_handle.brightness_in_lumen().ok()?, 239 | temperature_in_kelvin: device_handle.temperature_in_kelvin().ok()?, 240 | minimum_brightness_in_lumen: device_handle.minimum_brightness_in_lumen(), 241 | maximum_brightness_in_lumen: device_handle.maximum_brightness_in_lumen(), 242 | minimum_temperature_in_kelvin: device_handle.minimum_temperature_in_kelvin(), 243 | maximum_temperature_in_kelvin: device_handle.maximum_temperature_in_kelvin(), 244 | }) 245 | }) 246 | .collect(); 247 | 248 | if json { 249 | println!( 250 | "{}", 251 | serde_json::to_string(&litra_devices).map_err(CliError::SerializationFailed)? 252 | ); 253 | Ok(()) 254 | } else { 255 | if litra_devices.is_empty() { 256 | println!("No Logitech Litra devices found"); 257 | } else { 258 | for device_info in &litra_devices { 259 | println!( 260 | "- {} ({}): {} {}", 261 | device_info.device_type, 262 | device_info.serial_number, 263 | get_is_on_text(device_info.is_on), 264 | get_is_on_emoji(device_info.is_on) 265 | ); 266 | 267 | println!(" - Brightness: {} lm", device_info.brightness_in_lumen); 268 | println!( 269 | " - Minimum: {} lm", 270 | device_info.minimum_brightness_in_lumen 271 | ); 272 | println!( 273 | " - Maximum: {} lm", 274 | device_info.maximum_brightness_in_lumen 275 | ); 276 | println!(" - Temperature: {} K", device_info.temperature_in_kelvin); 277 | println!( 278 | " - Minimum: {} K", 279 | device_info.minimum_temperature_in_kelvin 280 | ); 281 | println!( 282 | " - Maximum: {} K", 283 | device_info.maximum_temperature_in_kelvin 284 | ); 285 | } 286 | } 287 | 288 | Ok(()) 289 | } 290 | } 291 | 292 | fn handle_on_command(serial_number: Option<&str>) -> CliResult { 293 | let context = Litra::new()?; 294 | let device_handle = get_first_supported_device(&context, serial_number)?; 295 | device_handle.set_on(true)?; 296 | Ok(()) 297 | } 298 | 299 | fn handle_off_command(serial_number: Option<&str>) -> CliResult { 300 | let context = Litra::new()?; 301 | let device_handle = get_first_supported_device(&context, serial_number)?; 302 | device_handle.set_on(false)?; 303 | Ok(()) 304 | } 305 | 306 | fn handle_toggle_command(serial_number: Option<&str>) -> CliResult { 307 | let context = Litra::new()?; 308 | let device_handle = get_first_supported_device(&context, serial_number)?; 309 | let is_on = device_handle.is_on()?; 310 | device_handle.set_on(!is_on)?; 311 | Ok(()) 312 | } 313 | 314 | fn handle_brightness_command( 315 | serial_number: Option<&str>, 316 | value: Option, 317 | percentage: Option, 318 | ) -> CliResult { 319 | let context = Litra::new()?; 320 | let device_handle = get_first_supported_device(&context, serial_number)?; 321 | 322 | match (value, percentage) { 323 | (Some(_), None) => { 324 | let brightness_in_lumen = value.unwrap(); 325 | device_handle.set_brightness_in_lumen(brightness_in_lumen)?; 326 | } 327 | (None, Some(_)) => { 328 | let brightness_in_lumen = percentage_within_range( 329 | percentage.unwrap().into(), 330 | device_handle.minimum_brightness_in_lumen().into(), 331 | device_handle.maximum_brightness_in_lumen().into(), 332 | ) 333 | .try_into() 334 | .map_err(CliError::BrightnessPercentageCalculationFailed)?; 335 | 336 | device_handle.set_brightness_in_lumen(brightness_in_lumen)?; 337 | } 338 | _ => unreachable!(), 339 | } 340 | Ok(()) 341 | } 342 | 343 | fn handle_brightness_up_command( 344 | serial_number: Option<&str>, 345 | value: Option, 346 | percentage: Option, 347 | ) -> CliResult { 348 | let context = Litra::new()?; 349 | let device_handle = get_first_supported_device(&context, serial_number)?; 350 | let current_brightness = device_handle.brightness_in_lumen()?; 351 | 352 | match (value, percentage) { 353 | (Some(_), None) => { 354 | let brightness_to_add = value.unwrap(); 355 | let new_brightness = current_brightness + brightness_to_add; 356 | device_handle.set_brightness_in_lumen(new_brightness)?; 357 | } 358 | (None, Some(_)) => { 359 | let brightness_to_add = percentage_within_range( 360 | percentage.unwrap().into(), 361 | device_handle.minimum_brightness_in_lumen().into(), 362 | device_handle.maximum_brightness_in_lumen().into(), 363 | ) as u16 364 | - device_handle.minimum_brightness_in_lumen(); 365 | 366 | let new_brightness = current_brightness + brightness_to_add; 367 | 368 | device_handle.set_brightness_in_lumen(new_brightness)?; 369 | } 370 | _ => unreachable!(), 371 | } 372 | Ok(()) 373 | } 374 | 375 | fn handle_brightness_down_command( 376 | serial_number: Option<&str>, 377 | value: Option, 378 | percentage: Option, 379 | ) -> CliResult { 380 | let context = Litra::new()?; 381 | let device_handle = get_first_supported_device(&context, serial_number)?; 382 | let current_brightness = device_handle.brightness_in_lumen()?; 383 | 384 | match (value, percentage) { 385 | (Some(_), None) => { 386 | let brightness_to_subtract = value.unwrap(); 387 | let new_brightness = current_brightness - brightness_to_subtract; 388 | device_handle.set_brightness_in_lumen(new_brightness)?; 389 | } 390 | (None, Some(_)) => { 391 | let brightness_to_subtract = percentage_within_range( 392 | percentage.unwrap().into(), 393 | device_handle.minimum_brightness_in_lumen().into(), 394 | device_handle.maximum_brightness_in_lumen().into(), 395 | ) as u16 396 | - device_handle.minimum_brightness_in_lumen(); 397 | 398 | let new_brightness = current_brightness as i16 - brightness_to_subtract as i16; 399 | 400 | if new_brightness < 0 { 401 | Err(CliError::InvalidBrightness(new_brightness))?; 402 | } 403 | 404 | device_handle.set_brightness_in_lumen(new_brightness as u16)?; 405 | } 406 | _ => unreachable!(), 407 | } 408 | Ok(()) 409 | } 410 | 411 | fn handle_temperature_command(serial_number: Option<&str>, value: u16) -> CliResult { 412 | let context = Litra::new()?; 413 | let device_handle = get_first_supported_device(&context, serial_number)?; 414 | 415 | device_handle.set_temperature_in_kelvin(value)?; 416 | Ok(()) 417 | } 418 | 419 | fn handle_temperature_up_command(serial_number: Option<&str>, value: u16) -> CliResult { 420 | let context = Litra::new()?; 421 | let device_handle = get_first_supported_device(&context, serial_number)?; 422 | let current_temperature = device_handle.temperature_in_kelvin()?; 423 | let new_temperature = current_temperature + value; 424 | 425 | device_handle.set_temperature_in_kelvin(new_temperature)?; 426 | Ok(()) 427 | } 428 | 429 | fn handle_temperature_down_command(serial_number: Option<&str>, value: u16) -> CliResult { 430 | let context = Litra::new()?; 431 | let device_handle = get_first_supported_device(&context, serial_number)?; 432 | let current_temperature = device_handle.temperature_in_kelvin()?; 433 | let new_temperature = current_temperature - value; 434 | 435 | device_handle.set_temperature_in_kelvin(new_temperature)?; 436 | Ok(()) 437 | } 438 | 439 | fn main() -> ExitCode { 440 | let args = Cli::parse(); 441 | 442 | let result = match &args.command { 443 | Commands::Devices { json } => handle_devices_command(*json), 444 | Commands::On { serial_number } => handle_on_command(serial_number.as_deref()), 445 | Commands::Off { serial_number } => handle_off_command(serial_number.as_deref()), 446 | Commands::Toggle { serial_number } => handle_toggle_command(serial_number.as_deref()), 447 | Commands::Brightness { 448 | serial_number, 449 | value, 450 | percentage, 451 | } => handle_brightness_command(serial_number.as_deref(), *value, *percentage), 452 | Commands::BrightnessUp { 453 | serial_number, 454 | value, 455 | percentage, 456 | } => handle_brightness_up_command(serial_number.as_deref(), *value, *percentage), 457 | Commands::BrightnessDown { 458 | serial_number, 459 | value, 460 | percentage, 461 | } => handle_brightness_down_command(serial_number.as_deref(), *value, *percentage), 462 | Commands::Temperature { 463 | serial_number, 464 | value, 465 | } => handle_temperature_command(serial_number.as_deref(), *value), 466 | Commands::TemperatureUp { 467 | serial_number, 468 | value, 469 | } => handle_temperature_up_command(serial_number.as_deref(), *value), 470 | Commands::TemperatureDown { 471 | serial_number, 472 | value, 473 | } => handle_temperature_down_command(serial_number.as_deref(), *value), 474 | }; 475 | 476 | if let Err(error) = result { 477 | eprintln!("{}", error); 478 | ExitCode::FAILURE 479 | } else { 480 | ExitCode::SUCCESS 481 | } 482 | } 483 | --------------------------------------------------------------------------------