diff --git a/.github/workflows/maven-build-installer-windows.yml b/.github/workflows/maven-build-installer-windows.yml index ab59e726..6834b68e 100644 --- a/.github/workflows/maven-build-installer-windows.yml +++ b/.github/workflows/maven-build-installer-windows.yml @@ -1,9 +1,9 @@ -# This workflow will build a Java project with Maven -# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven +# Build native executables with GraalVM and publish a pre-release +# Replaces the old Spring Boot/JavaFX MSI pipeline now that the app is a Quarkus native image. -name: Build Windows Installer +name: Build Native Image env: - JAVA_VERSION: 25 + JAVA_VERSION: '25' # GraalVM CE 25 — Java 25 source/target and runtime; Quarkus 3.34.1 supports Java 25 on: push: @@ -11,58 +11,51 @@ on: - main - releases/** workflow_dispatch: + jobs: buildWindows: runs-on: windows-latest steps: - - name: Download Wix - uses: i3h/download-release-asset@v1 - with: - owner: wixtoolset - repo: wix3 - tag: wix3112rtm - file: wix311-binaries.zip - token: ${{ secrets.GITHUB_TOKEN }} - - name: Decompress Wix - run: 7z x wix311-binaries.zip "-o./target/wix" - - name: Add Wix to Path - run: echo "$HOME/target/wix" >> $GITHUB_PATH - - uses: actions/checkout@v6 - - name: Set up JDK - uses: actions/setup-java@v5 + - uses: actions/checkout@v4 + + - name: Set up GraalVM CE + uses: graalvm/setup-graalvm@v1 with: java-version: ${{ env.JAVA_VERSION }} - distribution: 'liberica' - java-package: jdk+fx + distribution: 'graalvm-community' + native-image-job-reports: 'true' cache: 'maven' - - name: Build with Maven - run: mvn -B clean install --file pom.xml + + - name: Build native image (Windows) + run: mvn -B package -Pnative --file pom.xml + - name: Store artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v4 with: - name: windows-installer - path: ./target/*.msi + name: windows-native + path: ./target/*-runner.exe buildLinux: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - name: Set up JDK - uses: actions/setup-java@v5 + - uses: actions/checkout@v4 + + - name: Set up GraalVM CE + uses: graalvm/setup-graalvm@v1 with: java-version: ${{ env.JAVA_VERSION }} - distribution: 'liberica' - java-package: jdk+fx + distribution: 'graalvm-community' + native-image-job-reports: 'true' cache: 'maven' - - name: Build with Maven - run: mvn -B clean install --file pom.xml + + - name: Build native image (Linux) + run: mvn -B package -Pnative --file pom.xml + - name: Store artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v4 with: - name: linux-installer - path: | - ./target/*.deb - ./target/*.jar + name: linux-native + path: ./target/*-runner preRelease: name: Pre-release @@ -71,23 +64,19 @@ jobs: - buildLinux runs-on: ubuntu-latest steps: - # Check out current repository - name: Fetch Sources - uses: actions/checkout@v6 + uses: actions/checkout@v4 - name: Extract Maven project version run: echo "version=$(mvn -q -Dexec.executable=echo -Dexec.args='${project.version}' --non-recursive exec:exec)" >> $GITHUB_OUTPUT id: project - name: Compute tag name - # Compute a branch-specific, filesystem-friendly tag name and export to job env run: | - # Strip refs/heads/ prefix and replace non-alphanumeric characters with '-' branch="${GITHUB_REF#refs/heads/}" safe_branch=$(echo "$branch" | sed 's/[^A-Za-z0-9._-]/-/g') - echo "TAG_NAME=latest-windows-$safe_branch" >> $GITHUB_ENV + echo "TAG_NAME=latest-$safe_branch" >> $GITHUB_ENV - # Remove old pre-releases - name: Remove Old Pre-release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -95,6 +84,7 @@ jobs: gh api repos/{owner}/{repo}/releases \ --jq '.[] | select(.prerelease == true) | .id' \ | xargs -I '{}' gh api -X DELETE repos/{owner}/{repo}/releases/{} + - name: Remove previous tag env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -106,7 +96,6 @@ jobs: echo "Tag $TAG_NAME does not exist; skipping delete" fi - # Create new pre-release - name: Create Pre-release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -117,17 +106,16 @@ jobs: --title "v${{ steps.project.outputs.version }} Pre-Release (${{ env.GITHUB_RUN_NUMBER }})" \ --notes "$(sed '/##/Q' CHANGELOG.md)" - # Only now download the artifacts so that there is a delay between creating the pre-release and adding the artifacts - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@v4 with: - name: windows-installer + name: windows-native path: target - - uses: actions/download-artifact@v7 + + - uses: actions/download-artifact@v4 with: - name: linux-installer + name: linux-native path: target - # Upload artifact as a release asset - name: Upload Release Asset env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 941e3d25..249a9661 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ target/ # Application files /SndCtrl.dll /src/main/resources/application-default.properties +/.idea/JunieProjectTechnologies.xml diff --git a/.idea/misc.xml b/.idea/misc.xml index d3abdc03..96068102 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -15,6 +15,12 @@ + + + + + + @@ -75,7 +81,7 @@ - + - \ No newline at end of file + diff --git a/.mvn/jvm.config b/.mvn/jvm.config new file mode 100644 index 00000000..a8014d42 --- /dev/null +++ b/.mvn/jvm.config @@ -0,0 +1,10 @@ +--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED +--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED +--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED +--add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED +--add-opens=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED +--add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED +--add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED +--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED +--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED +--add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1e9f5aaa..f5ac6210 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,3 +55,10 @@ The SndCtrlTest project is there because Access Violations within JNI just close Running it with the Test code might actually show the error. An `EnableFullDump.reg` registry file is included to enable full dumps when the application crashes. This can be used to debug the native code. + +## Native build stuff + +```shell +java.exe -agentlib:native-image-agent=config-output-dir=native-image + -jar target/pcpanel-1.8-SNAPSHOT-native-image-source-jar/pcpanel-1.8-SNAPSHOT-runner.jar +``` diff --git a/README.md b/README.md index 936a9005..facb222a 100644 --- a/README.md +++ b/README.md @@ -45,5 +45,13 @@ manually copy the settings file: to `%userprofile%\.pcpanel\profiles.json` +# Generate reachability metadata + +```shell +mvn test "-DargLine=-agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image/ -Djava.awt.headless=false" +mvn test "-DargLine=-Dnative -Dquarkus.native.agent-configuration-apply" -Dnative -Dquarkus.native.agent-configuration-apply +``` + + --- Build template from [wiverson](https://github.com/wiverson/maven-jpackage-template) diff --git a/generate-native-configs.cmd b/generate-native-configs.cmd new file mode 100644 index 00000000..de645b9a --- /dev/null +++ b/generate-native-configs.cmd @@ -0,0 +1,154 @@ +@echo off +setlocal enabledelayedexpansion + +:: ============================================================================ +:: generate-native-configs.cmd +:: +:: Runs the native-image coverage tests under the GraalVM tracing agent and +:: merges the resulting JSON config files into the project's hand-maintained +:: native-image directory so they can be committed alongside source code. +:: +:: Prerequisites +:: ------------- +:: * GraalVM JDK installed and GRAALVM_HOME set (or JAVA_HOME pointing to it) +:: * Maven wrapper (mvnw.cmd) present at the project root +:: * SndCtrl.dll available on PATH or in target/ (for the JNI tests) +:: +:: Usage +:: ----- +:: generate-native-configs.cmd +:: +:: Output +:: ------ +:: Generated JSON files are written to: +:: target\native-agent-output\ +:: +:: Then MERGED into (existing entries preserved, new ones appended): +:: src\main\resources\META-INF\native-image\com.getpcpanel\pcpanel\ +:: ============================================================================ + +:: ── Locate project root (directory that contains this script) ──────────────── +set "PROJECT_ROOT=%~dp0" +if "%PROJECT_ROOT:~-1%" == "\" set "PROJECT_ROOT=%PROJECT_ROOT:~0,-1%" + +:: ── Resolve GraalVM home ───────────────────────────────────────────────────── +if not defined GRAALVM_HOME ( + if defined JAVA_HOME ( + set "GRAALVM_HOME=%JAVA_HOME%" + echo INFO: GRAALVM_HOME not set; falling back to JAVA_HOME=%JAVA_HOME% + ) else ( + echo ERROR: Neither GRAALVM_HOME nor JAVA_HOME is set. + echo Install GraalVM and set GRAALVM_HOME, or set JAVA_HOME to the GraalVM JDK. + exit /b 1 + ) +) + +:: Ensure Maven uses the GraalVM JDK (not whatever JAVA_HOME the shell inherited) +set "JAVA_HOME=%GRAALVM_HOME%" +set "PATH=%JAVA_HOME%\bin;%PATH%" + +:: Verify the tracing agent JAR / native-image-agent library is available +if not exist "%GRAALVM_HOME%\lib\svm\bin\native-image.cmd" ( + if not exist "%GRAALVM_HOME%\bin\native-image.cmd" ( + echo WARNING: native-image executable not found under GRAALVM_HOME=%GRAALVM_HOME% + echo Make sure GraalVM Native Image component is installed: + echo gu install native-image + echo Continuing anyway – the agent JVM flag may still work. + ) +) + +:: ── Paths ──────────────────────────────────────────────────────────────────── +set "AGENT_OUTPUT_DIR=%PROJECT_ROOT%\target\native-agent-output" +set "CONFIG_DEST=%PROJECT_ROOT%\src\main\resources\META-INF\native-image\com.getpcpanel\pcpanel" +set "MVNW=%PROJECT_ROOT%\mvnw.cmd" + +:: ── Clean previous agent output ────────────────────────────────────────────── +echo. +echo [1/4] Cleaning previous agent output directory... +if exist "%AGENT_OUTPUT_DIR%" ( + rmdir /s /q "%AGENT_OUTPUT_DIR%" +) +mkdir "%AGENT_OUTPUT_DIR%" + +:: ── Compile the project (tests + main) ────────────────────────────────────── +echo. +echo [2/4] Compiling project (test-compile)... +call "%MVNW%" test-compile -q +if %ERRORLEVEL% neq 0 ( + echo ERROR: Compilation failed. Fix build errors before regenerating configs. + exit /b %ERRORLEVEL% +) + +:: ── Run coverage tests with tracing agent ──────────────────────────────────── +echo. +echo [3/4] Running native-image coverage tests with tracing agent... +echo Agent output : %AGENT_OUTPUT_DIR% +echo Tests : SndCtrlNativeConfigTest, VolumeOverlayNativeTest +echo. + +:: -Dtest selects only the four coverage tests so unrelated tests don't pollute +:: the config. maven.test.failure.ignore=true lets the script continue even if +:: a test fails (e.g. on a headless CI machine without a display). +call "%MVNW%" test "-DargLine=-agentlib:native-image-agent=config-output-dir=%AGENT_OUTPUT_DIR% -Djava.awt.headless=false" + +echo. +echo [3/4] Tests finished (failures are non-fatal for config generation). + +:: ── Merge generated files into the hand-maintained config directory ─────────── +echo. +echo [4/4] Merging generated configs into %CONFIG_DEST% +echo. + +if not exist "%CONFIG_DEST%" mkdir "%CONFIG_DEST%" + +set FILES_UPDATED=0 + +:: GraalVM 23+ generates a unified reachability-metadata.json that replaces all of the +:: old individual files (jni-config.json, reflect-config.json, proxy-config.json, etc.). +:: +:: The following files are intentionally hand-maintained and must NOT be overwritten: +:: jni-config.json -- 4 project classes SndCtrl.dll calls back into via JNI (C->Java). +:: Cannot be auto-generated because unit tests never load the native DLL. +:: proxy-config.json -- JNA dynamic proxy interfaces (Shell32Extra, VoicemeeterInstance, …). +:: Cannot be auto-generated without loading the native libraries. +:: +:: Only reachability-metadata.json (and future agent-only files) are copied here. + +call "%MVNW%" verify "-DargLine=-Dnative -Dquarkus.native.agent-configuration-apply" -Dnative -Dquarkus.native.agent-configuration-apply + + +@REM for %%F in (reachability-metadata.json) do ( +@REM if exist "%AGENT_OUTPUT_DIR%\%%F" ( +@REM copy /y "%AGENT_OUTPUT_DIR%\%%F" "%CONFIG_DEST%\%%F" > nul +@REM echo Updated : %%F +@REM set /a FILES_UPDATED=FILES_UPDATED+1 +@REM ) else ( +@REM echo Skipped : %%F ^(not generated^) +@REM ) +@REM ) + +:: ── Summary ────────────────────────────────────────────────────────────────── +echo. +if %FILES_UPDATED% gtr 0 ( + echo SUCCESS: %FILES_UPDATED% config file^(s^) updated. + echo. + echo Next steps: + echo 1. Review the changes in %CONFIG_DEST% + echo 2. Diff against the previous version to understand what changed + echo 3. Remove spurious entries captured from test infrastructure + echo ^(look for class names ending in Test, or JUnit internals^) + echo 4. Commit the updated files +) else ( + echo WARNING: No config files were generated. + echo Check that: + echo * The tests compiled and ran ^(see target\surefire-reports\^) + echo * GRAALVM_HOME points to a GraalVM JDK ^(not a regular JDK^) + echo * The native-image-agent is installed: gu install native-image +) + +echo. +echo Raw agent output is preserved in: +echo %AGENT_OUTPUT_DIR% +echo. + +endlocal diff --git a/org/hid4java/HidDevice.class b/org/hid4java/HidDevice.class new file mode 100644 index 00000000..fd45764f Binary files /dev/null and b/org/hid4java/HidDevice.class differ diff --git a/plan.md b/plan.md new file mode 100644 index 00000000..5988b343 --- /dev/null +++ b/plan.md @@ -0,0 +1,196 @@ +# Audio Session Volume Restore – Priority Coordinator + +## Problem Statement + +When a new OS audio session starts (e.g. a browser begins playing sound), the software attempts to restore the preset volume that is configured for that process on the panel. + +**Three problems exist in the current implementation:** + +1. **Missing focus-volume restore** – `SetNewSessionVolumeService` only handles `CommandVolumeProcess`. If a dial is configured with `CommandVolumeFocus` and the focus application starts a new audio session, nothing is restored. + +2. **No priority / conflict resolution** – If an application is *both* the current OS focus app *and* has a dedicated `CommandVolumeProcess` mapping, both code-paths could fire. The explicit per-process mapping should always win. + +3. **WaveLink routing conflict** – If the focus application's audio is routed through the WaveLink mixer, calling `ISndCtrl.setFocusVolume()` targets the raw OS session and bypasses WaveLink entirely. In that case the correct action is to trigger the matching `CommandWaveLinkChangeLevel` dial. + +--- + +## Solution + +Replace `SetNewSessionVolumeService` with a new `NewSessionVolumeCoordinatorService` that evaluates the three restore strategies in priority order, stopping at the first level that produces a match. + +### Priority chain + +``` +Priority 1 → Direct process control (CommandVolumeProcess matches session exe) +Priority 2 → WaveLink focus control (focus app is in a WaveLink channel → trigger CommandWaveLinkChangeLevel) +Priority 3 → OS focus control (CommandVolumeFocus — only when not WaveLink-managed) +``` + +A new `WaveLinkFocusState` bean tracks the channel-to-process mapping so the coordinator can perform the Priority-2 check without querying WaveLink at runtime. + +--- + +## New Beans + +### `NewSessionVolumeCoordinatorService` + +| Attribute | Value | +|------------|-------| +| Package | `com.getpcpanel.cpp` | +| Stereotype | `@ApplicationScoped` | +| Superclass | `AbstractNewXVolumeService` | + +**Responsibilities:** +- Observes `AudioSessionEvent` (replaces `SetNewSessionVolumeService`). +- Injects `ISndCtrl` (for `getFocusApplication()`). +- Injects `jakarta.enterprise.inject.Instance` (optional — satisfied only on Windows when WaveLink is present). +- On `ADDED` event (or `CHANGED` when `forceVolume` is set), runs the priority chain described below. + +**Priority-chain pseudocode:** + +``` +onNewAudioSession(event): + exe = event.session().executable().getName() // e.g. "chrome.exe" + + // Priority 1 – Direct process mapping + if hasCommandsOf(CommandVolumeProcess, c -> c.getProcessName().contains(exe) && deviceMatches(event, c)): + triggerCommandsOf(CommandVolumeProcess, s -> s.filterValues(c -> isProcessAndDevice(event, c))) + return + + focusApp = sndCtrl.getFocusApplication() // e.g. "chrome.exe" + if !equalsIgnoreCase(exe, focusApp): + return // session is not the focus app → nothing to do for P2/P3 + + // Priority 2 – WaveLink focus channel + waveLinkFocusState.ifPresent(wlfs -> + wlfs.getChannelForProcess(exe).ifPresent(channelId -> + triggerCommandsOf(CommandWaveLinkChangeLevel, + s -> s.filterValues(c -> channelMatchesId(c, channelId))) + // mark as handled so P3 is skipped + handled = true + ) + ) + if handled: return + + // Priority 3 – OS focus volume + if hasCommandsOf(CommandVolumeFocus, _ -> true): + triggerCommandsOf(CommandVolumeFocus, Function.identity()) +``` + +--- + +### `WaveLinkFocusState` + +| Attribute | Value | +|------------|-------| +| Package | `com.getpcpanel.wavelink` | +| Stereotype | `@ApplicationScoped`, `@WindowsBuild` | +| Purpose | Tracks which WaveLink channel IDs currently have the focus application assigned | + +**State:** `Map channelIdToExe` (channelId → process exe name, lower-cased). + +**Methods:** + +| Signature | Behaviour | +|-----------|-----------| +| `addFocusToChannel(String channelId)` | Calls `sndCtrl.getFocusApplication()`, stores `channelId → exe.toLowerCase()`. | +| `Optional getChannelForProcess(String exe)` | Returns the channelId mapped to `exe` (case-insensitive), or `Optional.empty()`. | +| `boolean isProcessInWaveLinkChannel(String exe)` | Convenience: `getChannelForProcess(exe).isPresent()`. | + +**Dependencies injected:** `ISndCtrl`. + +--- + +## Modified Classes + +### `AbstractNewXVolumeService` + +1. **Extract `buildCommandStream(Class)`** – move the `StreamEx` pipeline from `triggerCommandsOf` into a private helper `buildCommandStream(Class clazz)` that returns `EntryStream`. `triggerCommandsOf` delegates to it. + +2. **Add `hasCommandsOf(Class, Predicate)`** – + ``` + protected boolean hasCommandsOf(Class clazz, Predicate filter): + return buildCommandStream(clazz).values().anyMatch(filter) + ``` + This lets the coordinator check for the existence of a command type *before* firing events. + +--- + +### `CommandWaveLinkAddFocusToChannel.execute()` + +After the existing `getWaveLinkService().addCurrentToChannel(id)` call, also update the in-process state tracker: + +``` +CdiHelper.getBean(WaveLinkFocusState.class).addFocusToChannel(id); +``` + +This keeps `WaveLinkFocusState` in sync whenever a user presses a button that assigns the focus app to a WaveLink channel. + +> **Note:** `WaveLinkFocusState` is `@WindowsBuild`-scoped. The call is guarded with an `Instance<>` check inside `CommandWaveLinkAddFocusToChannel`, consistent with how the coordinator calls it optionally. + +--- + +### `SetNewSessionVolumeService` + +- This class becomes redundant once `NewSessionVolumeCoordinatorService` is in place. +- **Delete** `SetNewSessionVolumeService.java`. + +--- + +## Priority-Logic Detail + +``` +Given: AudioSessionEvent event, String exe = session.executable().getName() + +STEP 1 – Process mapping check + matched = hasCommandsOf(CommandVolumeProcess, + c -> c.getProcessName().contains(exe) && deviceMatches(event, c)) + if matched: + triggerCommandsOf(CommandVolumeProcess, + s -> s.filterValues(c -> isProcessAndDevice(event, c))) + STOP + +STEP 2 – Is this the focus application? + focusApp = sndCtrl.getFocusApplication() + if focusApp == null || !exe.equalsIgnoreCase(new File(focusApp).getName()): STOP + +STEP 3 – WaveLink channel check (Windows/WaveLink only) + if waveLinkFocusStateInstance is present: + channelId = waveLinkFocusState.getChannelForProcess(exe) + if channelId.isPresent(): + triggerCommandsOf(CommandWaveLinkChangeLevel, + s -> s.filterValues(c -> c.getCommandType() in {Input, Channel} + && channelId.get().equals(c.getId1()))) + STOP + +STEP 4 – OS focus volume + triggerCommandsOf(CommandVolumeFocus, Function.identity()) + STOP +``` + +--- + +## Implementation Steps + +1. **Modify** `src/main/java/com/getpcpanel/commands/AbstractNewXVolumeService.java` + — Extract `buildCommandStream(Class)` helper; add `hasCommandsOf(Class, Predicate)` method. + +2. **Create** `src/main/java/com/getpcpanel/wavelink/WaveLinkFocusState.java` + — `@ApplicationScoped`, `@WindowsBuild`; inject `ISndCtrl`; implement `addFocusToChannel`, `getChannelForProcess`, `isProcessInWaveLinkChannel`. + +3. **Modify** `src/main/java/com/getpcpanel/wavelink/command/CommandWaveLinkAddFocusToChannel.java` + — In `execute()`, after `addCurrentToChannel(id)`, call `WaveLinkFocusState.addFocusToChannel(id)` via an `Instance` guard using `CdiHelper`. + +4. **Create** `src/main/java/com/getpcpanel/cpp/NewSessionVolumeCoordinatorService.java` + — `@ApplicationScoped`; extends `AbstractNewXVolumeService`; inject `ISndCtrl`, `SaveService`, and `Instance`; implement `onNewAudioSession(@Observes AudioSessionEvent)` with the full priority chain. + +5. **Delete** `src/main/java/com/getpcpanel/cpp/SetNewSessionVolumeService.java` + — Remove file; confirm no remaining imports or references exist. + +--- + +## Further Considerations + +- **`WaveLinkFocusState` persistence** – The map is in-memory only and is lost on restart. Consider whether assignments should be persisted in the save file (`WaveLinkSettings`) or re-populated from WaveLink's own state on reconnect. +- **Multi-device interaction** – `triggerCommandsOf(CommandVolumeFocus, …)` fires for *every* dial configured as focus volume across all devices. This is consistent with the existing `CommandVolumeProcess` behaviour. +- **Null-guard for `getFocusApplication()`** – On Linux, `ISndCtrl.noOp()` returns `null` for `getFocusApplication()`. The coordinator null-checks this before using it. diff --git a/pom.xml b/pom.xml index ba74235c..1642c957 100644 --- a/pom.xml +++ b/pom.xml @@ -3,13 +3,6 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - - org.springframework.boot - spring-boot-starter-parent - 3.5.9 - - - com.getpcpanel pcpanel ${project.baseversion}${project.snapshot} @@ -25,51 +18,132 @@ ${env.GITHUB_REPOSITORY} 25 - 9.4.49.v20220914 ${java.version} ${java.version} + ${java.version} com.getpcpanel.Main + quarkus-bom + io.quarkus.platform + 3.34.1 - PCPanel yy.ww.WWkkmm ${project.baseversion}.${github.build} - javafx.controls,javafx.fxml,javafx.swing,java.desktop,java.management,java.logging,jdk.crypto.cryptoki,java.net.http 9421bff0-3840-414c-8563-407fbcd1d04d PCPanel UTF-8 - 18.0.2 5.13.0 - 9.5 + 9.9 5.2.0 + 1.18.44 + 4.0.0 - ALL-UNNAMED + + + true + ${env.GRAALVM_HOME} + + true + + SndCtrl.dll,**/*.dll,**/*.so,**/*.dylib + + + + --exclude-config pcpanel-1.8-SNAPSHOT-runner /META-INF/native-image/com\.getpcpanel/pcpanel/jni-config\.json, + -J-XX:-UseCompressedOops,--initialize-at-run-time=com.sun.jna,--initialize-at-run-time=org.hid4java,--initialize-at-run-time=com.github.kwhat.jnativehook,--initialize-at-run-time=com.getpcpanel.commands.KeyMacro,--initialize-at-run-time=com.getpcpanel.iconextract.Shell32Extra,--initialize-at-run-time=org.freedesktop.dbus,--initialize-at-run-time=sun.font.FontManagerFactory,--initialize-at-run-time=sun.font.FontFamily,--initialize-at-run-time=sun.java2d.Disposer,--initialize-at-run-time=com.hivemq.client.internal.mqtt,--initialize-at-run-time=com.getpcpanel.voicemeeter.VoicemeeterInstance,--initialize-at-run-time=com.getpcpanel.voicemeeter.VoicemeeterInstance$tagVBVMR_AUDIOINFO,--initialize-at-run-time=com.getpcpanel.voicemeeter.VoicemeeterInstance$tagVBVMR_AUDIOBUFFER,--initialize-at-run-time=com.getpcpanel.voicemeeter.VoicemeeterInstance$tagVBVMR_INTERFACE,--initialize-at-run-time=sun.awt.windows,--initialize-at-run-time=sun.java2d.d3d,--initialize-at-run-time=sun.java2d.windows,--initialize-at-run-time=javax.swing.plaf.metal,--initialize-at-build-time=javax.swing.plaf.ColorUIResource,--initialize-at-build-time=java.awt.font.FontRenderContext,--initialize-at-build-time=sun.awt.SunHints,--initialize-at-build-time=sun.awt.SunHints$Value,--initialize-at-build-time=sun.awt.SunHints$LCDContrastKey,--initialize-at-build-time=sun.awt.SunHints$Key,--initialize-at-build-time=java.awt.Dimension,--initialize-at-build-time=java.awt.geom.Dimension2D + + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-rest + + + io.quarkus + quarkus-rest-jackson + + + io.quarkus + quarkus-scheduler + + + io.quarkus + quarkus-cache + + + org.apache.logging.log4j + log4j-api + + + org.jboss.logmanager + log4j2-jboss-logmanager + - org.springframework.boot - spring-boot-starter + io.quarkus + quarkus-websockets-next - org.springframework.boot - spring-boot-starter-json + io.quarkus + quarkus-smallrye-health + + io.quarkus + quarkus-vertx + + + io.quarkus + quarkus-awt + + + + + io.quarkiverse.quinoa + quarkus-quinoa + 2.8.0 + + + com.fasterxml.jackson.core jackson-databind + + org.projectlombok lombok + ${lombok.version} provided + + com.google.code.findbugs jsr305 3.0.2 + + org.apache.commons commons-lang3 @@ -79,7 +153,13 @@ commons-collections4 4.4 + + commons-io + commons-io + 2.18.0 + + net.java.dev.jna jna @@ -90,36 +170,22 @@ jna-platform ${jna.version} - - org.openjfx - javafx-controls - ${javafx.version} - - - org.openjfx - javafx-swing - ${javafx.version} - - - org.openjfx - javafx-fxml - ${javafx.version} - - - commons-io - commons-io - 2.11.0 - + + org.hid4java hid4java 0.7.0 + + one.util streamex 0.8.1 + + org.ow2.asm asm @@ -135,53 +201,15 @@ asm-util ${asm.version} - - javax.activation - activation - 1.1.1 - + + com.github.kwhat jnativehook 2.2.2 - - io.obs-websocket.community - client - 2.0.0 - - - slf4j-simple - org.slf4j - - - - - org.eclipse.jetty.websocket - websocket-client - ${jetty.websocket.client.version} - - - org.eclipse.jetty - jetty-client - ${jetty.websocket.client.version} - - - org.eclipse.jetty - jetty-io - ${jetty.websocket.client.version} - - - org.eclipse.jetty - jetty-http - ${jetty.websocket.client.version} - - - org.eclipse.jetty - jetty-util - ${jetty.websocket.client.version} - + com.illposed.osc javaosc-core @@ -197,21 +225,22 @@ + + org.jetbrains annotations 24.0.1 - - org.apache.xmlgraphics - batik-transcoder - 1.16 - + + com.hivemq hivemq-mqtt-client 1.3.3 + + io.reactivex.rxjava3 rxjava @@ -230,6 +259,12 @@ ${dbus-java.version} + + + io.quarkus + quarkus-junit5 + test + org.junit.jupiter junit-jupiter-engine @@ -240,6 +275,11 @@ junit-jupiter-params test + + io.rest-assured + rest-assured + test + @@ -251,11 +291,6 @@ - - ${project.basedir}/src/packaging - true - ${project.build.directory}/packaging - ${project.basedir}/src/main/resources true @@ -273,105 +308,94 @@ - org.springframework.boot - spring-boot-maven-plugin - - - org.apache.maven.plugins - maven-install-plugin + cz.habarta.typescript-generator + typescript-generator-maven-plugin + ${typescript-generator.version} + + + generate-websocket-typescript-contract + compile + + generate + + + - true + module + jackson2 + implementationFile + asUnion + true + true + + com.getpcpanel.rest.model.** + com.getpcpanel.commands.Commands + com.getpcpanel.commands.command.** + com.getpcpanel.wavelink.command.** + **.dto.** + + + javax.annotation.Nullable + jakarta.annotation.Nullable + + ${project.basedir}/src/main/webui/src/app/models/generated/backend.types.ts - org.apache.maven.plugins - maven-dependency-plugin + ${quarkus.platform.group-id} + quarkus-maven-plugin + ${quarkus.platform.version} + true - copy-dependencies - package - copy-dependencies + build + generate-code + generate-code-tests - - runtime - org.openjfx - org.apache.maven.plugins - maven-jar-plugin + maven-compiler-plugin - ${project.build.directory}/dependency + + + org.projectlombok + lombok + ${lombok.version} + + - org.openjfx - javafx-maven-plugin - 0.0.8 + org.apache.maven.plugins + maven-surefire-plugin - ${start-class} - - --add-exports javafx.controls/com.sun.javafx.scene.control.skin.resources=${module.name} - --add-exports javafx.base/com.sun.javafx.event=${module.name} - - true + + org.jboss.logmanager.LogManager + ${maven.home} + - io.github.wiverson - jtoolprovider-plugin - 1.0.34 + org.apache.maven.plugins + maven-failsafe-plugin - jlink - package - java-tool + integration-test + verify - - jlink - ${project.build.directory}/jvm-image/ - ${jvm.modules} - ${project.build.directory}/jvm-image - - --strip-native-commands - --no-header-files - --strip-debug - --no-man-pages - --compress=2 - - - - - jpackage - install - - java-tool - - - jpackage - true - true - true - ${project.build.directory}/installer-work - @${project.build.directory}/packaging/${os.detected.name}-jpackage.txt - - - - org.apache.maven.plugins - maven-compiler-plugin - - - org.projectlombok - lombok - - + + ${project.build.directory}/${project.build.finalName}-runner + org.jboss.logmanager.LogManager + ${maven.home} + @@ -390,26 +414,7 @@ - - - - org.codehaus.mojo - versions-maven-plugin - - - - dependency-updates-report - plugin-updates-report - property-updates-report - - - - - false - - - - + not-github-build @@ -424,77 +429,9 @@ - windows-active - - - windows - - - - - - com.coderplus.maven.plugins - copy-rename-maven-plugin - 1.0.1 - - - rename-file - install - - rename - - - ${project.build.directory}/${app.name}-${app.version}.msi - ${project.build.directory}/${app.name}-${project.version}.msi - - - - - - org.codehaus.mojo - exec-maven-plugin - 3.0.0 - - - install - add-launch-to-msi - - exec - - - - - cscript - ${project.build.directory}/msi-result.log - ${project.build.directory} - - ${project.build.directory}/packaging/add-launch-to-msi.js - - - - - + native + + - - - - spring-milestones - Spring Milestones - https://repo.spring.io/milestone - - false - - - - - - spring-milestones - Spring Milestones - https://repo.spring.io/milestone - - false - - - diff --git a/src/lombok.config b/src/lombok.config index 38c6ac6b..c2cc1123 100644 --- a/src/lombok.config +++ b/src/lombok.config @@ -1,13 +1,10 @@ config.stopBubbling = true lombok.accessors.chain=true -lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Value lombok.log.custom.flagUsage=error lombok.log.apacheCommons.flagUsage=error lombok.log.flogger.flagUsage=error -lombok.log.jbosslog.flagUsage=error lombok.log.javaUtilLogging.flagUsage=error -lombok.log.log4j.flagUsage=error lombok.log.slf4j.flagUsage=error lombok.log.xslf4j.flagUsage=error diff --git a/src/main/java/com/getpcpanel/CachingConfig.java b/src/main/java/com/getpcpanel/CachingConfig.java index c33d06dd..bf9bec33 100644 --- a/src/main/java/com/getpcpanel/CachingConfig.java +++ b/src/main/java/com/getpcpanel/CachingConfig.java @@ -1,28 +1,19 @@ package com.getpcpanel; -import org.springframework.cache.CacheManager; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.EnableCaching; -import org.springframework.cache.concurrent.ConcurrentMapCacheManager; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.scheduling.annotation.Scheduled; +import io.quarkus.cache.CacheInvalidateAll; +import io.quarkus.scheduler.Scheduled; +import jakarta.enterprise.context.ApplicationScoped; -@Configuration -@EnableCaching +@ApplicationScoped public class CachingConfig { - @Bean - public CacheManager cacheManager() { - return new ConcurrentMapCacheManager("icon", "command-icon"); - } - @CacheEvict(allEntries = true, cacheNames = "icon") - @Scheduled(fixedDelay = 300_000) + @CacheInvalidateAll(cacheName = "icon") + @Scheduled(every = "300s") public void iconEvict() { } - @CacheEvict(allEntries = true, cacheNames = "command-icon") - @Scheduled(fixedDelay = 1_000) + @CacheInvalidateAll(cacheName = "command-icon") + @Scheduled(every = "1s") public void commandIconEvict() { } } diff --git a/src/main/java/com/getpcpanel/Json.java b/src/main/java/com/getpcpanel/Json.java index 578d9a5a..cff1ee44 100644 --- a/src/main/java/com/getpcpanel/Json.java +++ b/src/main/java/com/getpcpanel/Json.java @@ -1,10 +1,9 @@ package com.getpcpanel; -import org.springframework.stereotype.Service; - import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import lombok.SneakyThrows; import lombok.extern.log4j.Log4j2; @@ -12,10 +11,10 @@ * Jackson wrapper */ @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public final class Json { - private final ObjectMapper mapper; + @Inject + ObjectMapper mapper; @SneakyThrows public String write(Object o) { diff --git a/src/main/java/com/getpcpanel/Main.java b/src/main/java/com/getpcpanel/Main.java index f8799bf2..4d14d266 100644 --- a/src/main/java/com/getpcpanel/Main.java +++ b/src/main/java/com/getpcpanel/Main.java @@ -2,30 +2,37 @@ import java.util.Set; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.cache.annotation.EnableCaching; -import org.springframework.scheduling.annotation.EnableScheduling; +import org.eclipse.microprofile.config.ConfigProvider; import com.getpcpanel.hid.HidDebug; import com.getpcpanel.util.FileChecker; -import javafx.application.Application; +import io.quarkus.runtime.Quarkus; +import io.quarkus.runtime.QuarkusApplication; +import io.quarkus.runtime.annotations.QuarkusMain; -@EnableCaching -@EnableScheduling -@SpringBootApplication -public class Main { - public static void main(String[] args) { - var argSet = Set.of(args); - if (!argSet.contains("skipfilecheck")) { - FileChecker.createAndStart(); - } +@QuarkusMain +public class Main implements QuarkusApplication { + private static final String SKIP_FILE_CHECK_ARG = "skipfilecheck"; + private static final String SKIP_FILE_CHECK_PROPERTY = "pcpanel.skip-file-check"; + static void main(String... args) { + var argSet = Set.of(args); if (argSet.contains("hiddebug")) { new HidDebug().execute(); return; } + Quarkus.run(Main.class, args); + } - Application.launch(MainFX.class, args); + @Override + public int run(String... args) throws Exception { + var argSet = Set.of(args); + var skipFileCheck = argSet.contains(SKIP_FILE_CHECK_ARG) || ConfigProvider.getConfig().getOptionalValue(SKIP_FILE_CHECK_PROPERTY, Boolean.class).orElse(false); + if (!skipFileCheck) { + FileChecker.createAndStart(); + } + Quarkus.waitForExit(); + return 0; } } diff --git a/src/main/java/com/getpcpanel/MainFX.java b/src/main/java/com/getpcpanel/MainFX.java deleted file mode 100644 index ff8be95e..00000000 --- a/src/main/java/com/getpcpanel/MainFX.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.getpcpanel; - -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; - -import javax.annotation.Nullable; - -import org.springframework.boot.builder.SpringApplicationBuilder; -import org.springframework.context.ConfigurableApplicationContext; - -import com.getpcpanel.ui.HomePage; - -import javafx.application.Application; -import javafx.application.Platform; -import javafx.stage.Stage; -import lombok.Getter; -import lombok.extern.log4j.Log4j2; - -@Log4j2 -public class MainFX extends Application { - @Getter @SuppressWarnings("StaticNonFinalField") private static ConfigurableApplicationContext context; - private static final Map, CacheObject> beanCache = new ConcurrentHashMap<>(); - - @Override - @SuppressWarnings("AssignmentToStaticFieldFromInstanceMethod") - public void init() throws Exception { - context = new SpringApplicationBuilder(Main.class) - .headless(false) - .run(getParameters().getRaw().toArray(new String[0])); - } - - @Override - public void start(Stage primaryStage) throws Exception { - log.info("Starting v{}", context.getEnvironment().getProperty("application.version")); - context.getBean(HomePage.class).start(primaryStage, getParameters().getRaw().contains("quiet")); - } - - @Override - public void stop() throws Exception { - context.close(); - Platform.exit(); - } - - @SuppressWarnings("unchecked") - public static Optional getOptionalBean(Class clazz) { - return Optional.ofNullable((T) beanCache.computeIfAbsent(clazz, cls -> { - try { - return new CacheObject(context.getBean(clazz)); - } catch (Exception e) { - return CacheObject.NULL; - } - }).o); - } - - public static T getBean(Class clazz) { - return context.getBean(clazz); - } - - record CacheObject(@Nullable Object o) { - private static final CacheObject NULL = new CacheObject(null); - } -} diff --git a/src/main/java/com/getpcpanel/RestTemplateConfig.java b/src/main/java/com/getpcpanel/RestTemplateConfig.java deleted file mode 100644 index 2e943b8d..00000000 --- a/src/main/java/com/getpcpanel/RestTemplateConfig.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.getpcpanel; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.client.RestTemplate; - -@Configuration -public class RestTemplateConfig { - @Bean - public RestTemplate restTemplate() { - return new RestTemplate(); - } -} diff --git a/src/main/java/com/getpcpanel/commands/AbstractNewXVolumeService.java b/src/main/java/com/getpcpanel/commands/AbstractNewXVolumeService.java deleted file mode 100644 index 433d3137..00000000 --- a/src/main/java/com/getpcpanel/commands/AbstractNewXVolumeService.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.getpcpanel.commands; - -import java.util.Map; -import java.util.function.Function; - -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; - -import com.getpcpanel.commands.command.Command; -import com.getpcpanel.device.Device; -import com.getpcpanel.hid.DeviceCommunicationHandler; -import com.getpcpanel.hid.DeviceHolder; - -import lombok.RequiredArgsConstructor; -import lombok.extern.log4j.Log4j2; -import one.util.streamex.EntryStream; -import one.util.streamex.StreamEx; - -/** - * Parent class for services that trigger when a process starts or another tool connects. - */ -@Log4j2 -@Service -@RequiredArgsConstructor -public abstract class AbstractNewXVolumeService { - private final DeviceHolder devices; - private final ApplicationEventPublisher eventPublisher; - - protected void triggerCommandsOf(Class clazz, Function, EntryStream> chain) { - StreamEx.of(devices.all()) - .mapToEntry(Device::getSerialNumber).invert() - .mapValues(Device::currentProfile) - .flatMapKeyValue((id, profile) -> EntryStream.of(profile.getDialData()).mapKeys(d -> new DeviceAndDial(id, d))) - .mapToEntry(Map.Entry::getKey, Map.Entry::getValue) - .flatMapValues(d -> Commands.cmds(d).stream()) - .selectValues(clazz) - .chain(chain) - .forKeyValue((idAndDial, cmd) -> devices.getDevice(idAndDial.id()).ifPresent(device -> { - var current = device.getKnobRotation(idAndDial.dial()); - eventPublisher.publishEvent(new DeviceCommunicationHandler.KnobRotateEvent(idAndDial.id(), idAndDial.dial(), current, false)); - })); - } - - protected record DeviceAndDial(String id, int dial) { - } -} diff --git a/src/main/java/com/getpcpanel/commands/CommandDispatcher.java b/src/main/java/com/getpcpanel/commands/CommandDispatcher.java index 9e573762..9ef326cb 100644 --- a/src/main/java/com/getpcpanel/commands/CommandDispatcher.java +++ b/src/main/java/com/getpcpanel/commands/CommandDispatcher.java @@ -3,17 +3,16 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; - import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; import lombok.extern.log4j.Log4j2; @Log4j2 -@Service +@ApplicationScoped public final class CommandDispatcher { - private final Map map = new ConcurrentHashMap<>(); - private final HandlerThread handler = new HandlerThread(); + final Map map = new ConcurrentHashMap<>(); + final HandlerThread handler = new HandlerThread(); @PostConstruct public void init() { @@ -24,8 +23,7 @@ public void init() { private CommandDispatcher() { } - @EventListener - public void onCommand(PCPanelControlEvent event) { + public void onCommand(@Observes PCPanelControlEvent event) { map.put(event.serialNum() + event.knob(), event.buildRunnable()); handler.doNotify(); } diff --git a/src/main/java/com/getpcpanel/commands/Commands.java b/src/main/java/com/getpcpanel/commands/Commands.java index e7deaae0..b29f7fda 100644 --- a/src/main/java/com/getpcpanel/commands/Commands.java +++ b/src/main/java/com/getpcpanel/commands/Commands.java @@ -4,9 +4,12 @@ import java.util.Optional; import javax.annotation.Nonnull; + import javax.annotation.Nullable; +import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; import com.getpcpanel.commands.command.Command; import com.getpcpanel.commands.command.CommandNoOp; @@ -27,7 +30,8 @@ public class Commands { return List.of(); } - public Commands(@Nonnull List commands, @Nullable CommandsType type) { + @JsonCreator + public Commands(@JsonProperty("commands") @Nonnull List commands, @JsonProperty("type") @Nullable CommandsType type) { this.commands = StreamEx.of(commands).remove(CommandNoOp.class::isInstance).toImmutableList(); this.type = type != null ? type : CommandsType.allAtOnce; } diff --git a/src/main/java/com/getpcpanel/commands/IIconHandler.java b/src/main/java/com/getpcpanel/commands/IIconHandler.java index 77b97299..019f9bcb 100644 --- a/src/main/java/com/getpcpanel/commands/IIconHandler.java +++ b/src/main/java/com/getpcpanel/commands/IIconHandler.java @@ -4,10 +4,10 @@ import com.getpcpanel.commands.command.Command; -import javafx.scene.image.Image; +import java.awt.image.BufferedImage; public interface IIconHandler { Class getCommandClass(); - Optional supplyImage(C cmd); + Optional supplyImage(C cmd); } diff --git a/src/main/java/com/getpcpanel/commands/ILightingDialogMuteOverrideHelper.java b/src/main/java/com/getpcpanel/commands/ILightingDialogMuteOverrideHelper.java new file mode 100644 index 00000000..e8f9c845 --- /dev/null +++ b/src/main/java/com/getpcpanel/commands/ILightingDialogMuteOverrideHelper.java @@ -0,0 +1,9 @@ +package com.getpcpanel.commands; + +/** + * Constants for mute override behavior in lighting configuration. + * Moved from com.getpcpanel.ui to commands package as part of Quarkus migration. + */ +public interface ILightingDialogMuteOverrideHelper { + String FOLLOW_PROCESS = "__follow__"; +} diff --git a/src/main/java/com/getpcpanel/commands/IconService.java b/src/main/java/com/getpcpanel/commands/IconService.java index 0d57fb87..cc56893e 100644 --- a/src/main/java/com/getpcpanel/commands/IconService.java +++ b/src/main/java/com/getpcpanel/commands/IconService.java @@ -2,18 +2,20 @@ import static com.getpcpanel.cpp.AudioSession.SYSTEM; +import java.awt.image.BufferedImage; import java.io.File; +import java.io.IOException; import java.util.HashMap; import java.util.List; -import java.util.Objects; import java.util.function.BiFunction; import javax.annotation.Nonnull; + import javax.annotation.Nullable; +import javax.imageio.ImageIO; + import org.apache.commons.lang3.StringUtils; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.stereotype.Service; import com.getpcpanel.commands.command.Command; import com.getpcpanel.commands.command.CommandBrightness; @@ -28,31 +30,52 @@ import com.getpcpanel.commands.command.CommandVolumeProcess; import com.getpcpanel.cpp.ISndCtrl; import com.getpcpanel.iconextract.IIconService; -import com.getpcpanel.profile.KnobSetting; -import com.getpcpanel.util.Images; +import com.getpcpanel.profile.dto.KnobSetting; +import io.quarkus.arc.All; +import io.quarkus.cache.CacheResult; import jakarta.annotation.PostConstruct; -import javafx.scene.image.Image; -import lombok.RequiredArgsConstructor; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import lombok.extern.log4j.Log4j2; import one.util.streamex.StreamEx; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public class IconService { - public static final Image DEFAULT = new Image(Objects.requireNonNull(IconService.class.getResource("/assets/32x32.png")).toExternalForm()); - private static final Image OBS = new Image(Objects.requireNonNull(IconService.class.getResource("/assets/obs.png")).toExternalForm()); - private static final Image VOICEMEETER = new Image(Objects.requireNonNull(IconService.class.getResource("/assets/voicemeeter.png")).toExternalForm()); - public static final Image DEVICE = new Image(Objects.requireNonNull(IconService.class.getResource("/assets/device.png")).toExternalForm()); - public static final Image SYSTEM_SOUND = new Image(Objects.requireNonNull(IconService.class.getResource("/assets/systemsounds.ico")).toExternalForm()); + public BufferedImage DEFAULT; + private BufferedImage OBS; + private BufferedImage VOICEMEETER; + public BufferedImage DEVICE; + public BufferedImage SYSTEM_SOUND; + + private static BufferedImage loadImage(String path) { + try { + var url = IconService.class.getResource(path); + if (url != null) + return ImageIO.read(url); + } catch (IOException e) { + // fall through + } + return new BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB); + } + private final SafeMap imageHandlers = new SafeMap(); - private final ISndCtrl sndCtrl; - private final IIconService iconService; - private final List> iconHandlers; + @Inject + ISndCtrl sndCtrl; + @Inject + IIconService iconService; + @Inject @All + List> iconHandlers; @PostConstruct public void init() { + DEFAULT = loadImage("/assets/32x32.png"); + OBS = loadImage("/assets/obs.png"); + VOICEMEETER = loadImage("/assets/voicemeeter.png"); + DEVICE = loadImage("/assets/device.png"); + SYSTEM_SOUND = loadImage("/assets/systemsounds.ico"); + imageHandlers.put(Command.class, (a, b) -> DEFAULT); // Dials @@ -72,13 +95,13 @@ public void init() { // Other handlers iconHandlers.forEach(ih -> { IIconHandler untyped = ih; - imageHandlers.put(ih.getCommandClass(), (is, cmd) -> (Image) untyped.supplyImage(cmd).orElse(null)); + imageHandlers.put(ih.getCommandClass(), (is, cmd) -> (BufferedImage) untyped.supplyImage(cmd).orElse(null)); }); } - @Cacheable("command-icon") + @CacheResult(cacheName = "command-icon") @Nonnull - public Image getImageFrom(@Nullable Commands commands, @Nullable KnobSetting override) { + public BufferedImage getImageFrom(@Nullable Commands commands, @Nullable KnobSetting override) { if (!Commands.hasCommands(commands)) { return DEFAULT; } @@ -87,13 +110,13 @@ public Image getImageFrom(@Nullable Commands commands, @Nullable KnobSetting ove try { var iconStr = override.getOverlayIcon(); if (StringUtils.endsWithAny(iconStr, "exe", "dll") && new File(iconStr).exists()) { - var result = iconService.getIconImageForFile(32, 32, new File(iconStr)); + var result = iconService.getIconForFile(32, 32, new File(iconStr)); if (result != null) { return result; } } if (StringUtils.isNotBlank(iconStr)) { - return new Image(override.getOverlayIcon()); + return loadImage(iconStr); } } catch (Exception e) { log.trace("Unable to load {}", override, e); @@ -107,12 +130,12 @@ public Image getImageFrom(@Nullable Commands commands, @Nullable KnobSetting ove .orElse(DEFAULT); } - public boolean isDefault(Image img) { + public boolean isDefault(BufferedImage img) { //noinspection ObjectEquality return img == DEFAULT; } - private Image getRunningProcessIcon(CommandVolumeProcess commandIcon) { + private BufferedImage getRunningProcessIcon(CommandVolumeProcess commandIcon) { var allProcesses = sndCtrl.getRunningApplications(); for (var process : commandIcon.getProcessName()) { if (StringUtils.equalsIgnoreCase(process, SYSTEM)) { @@ -120,7 +143,7 @@ private Image getRunningProcessIcon(CommandVolumeProcess commandIcon) { } for (var runningProcess : allProcesses) { if (StringUtils.containsIgnoreCase(runningProcess.file().getAbsolutePath(), process)) { - var image = iconService.getIconImageForFile(32, 32, runningProcess.file()); + var image = iconService.getIconForFile(32, 32, runningProcess.file()); if (image != null) { return image; } @@ -130,53 +153,56 @@ private Image getRunningProcessIcon(CommandVolumeProcess commandIcon) { return DEFAULT; } - private Image getFocusProcessIcon(CommandVolumeFocus command) { - var image = iconService.getIconImageForFile(32, 32, new File(sndCtrl.getFocusApplication())); + private BufferedImage getFocusProcessIcon(CommandVolumeFocus command) { + if (sndCtrl.getFocusApplication() == null) { + return DEFAULT; + } + var image = iconService.getIconForFile(32, 32, new File(sndCtrl.getFocusApplication())); if (image == null) { return DEFAULT; } return image; } - private Image getDeviceIcon(Command command) { + private BufferedImage getDeviceIcon(Command command) { return DEVICE; } - private Image getVoiceMeeterIcon(CommandVoiceMeeter command) { + private BufferedImage getVoiceMeeterIcon(CommandVoiceMeeter command) { return VOICEMEETER; } - private Image getObsIcon(CommandObs command) { + private BufferedImage getObsIcon(CommandObs command) { return OBS; } - private class SafeMap extends HashMap, BiFunction> { + private class SafeMap extends HashMap, BiFunction> { - public void put(Class key, BiFunction value) { + public void put(Class key, BiFunction value) { super.put(key, value); } - public Image handle(T icon) { + public BufferedImage handle(T icon) { if (icon == null) return DEFAULT; //noinspection unchecked - return ((BiFunction) ensureHandler(icon.getClass())).apply(IconService.this, icon); + return ((BiFunction) ensureHandler(icon.getClass())).apply(IconService.this, icon); } @SuppressWarnings("unchecked") - private BiFunction ensureHandler(Class icon) { + private BiFunction ensureHandler(Class icon) { if (imageHandlers.containsKey(icon)) { - return (BiFunction) imageHandlers.get(icon); + return (BiFunction) imageHandlers.get(icon); } - var handler = (BiFunction) ensureHandler(icon.getSuperclass()); - imageHandlers.put((Class extends Command>) icon, (BiFunction) handler); + var handler = (BiFunction) ensureHandler(icon.getSuperclass()); + imageHandlers.put((Class extends Command>) icon, (BiFunction) handler); return handler; } } - private Image getBrightnessIcon(Command command) { - return Images.lighting; + private BufferedImage getBrightnessIcon(Command command) { + return DEFAULT; } } diff --git a/src/main/java/com/getpcpanel/commands/SetMuteOverrideService.java b/src/main/java/com/getpcpanel/commands/SetMuteOverrideService.java index a6d72de5..44b472cf 100644 --- a/src/main/java/com/getpcpanel/commands/SetMuteOverrideService.java +++ b/src/main/java/com/getpcpanel/commands/SetMuteOverrideService.java @@ -9,9 +9,6 @@ import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.math.NumberUtils; -import org.springframework.context.event.EventListener; -import org.springframework.core.annotation.Order; -import org.springframework.stereotype.Service; import com.getpcpanel.commands.command.CommandObsSetSourceVolume; import com.getpcpanel.commands.command.CommandVoiceMeeter; @@ -24,18 +21,19 @@ import com.getpcpanel.cpp.EventType; import com.getpcpanel.cpp.ISndCtrl; import com.getpcpanel.hid.DeviceHolder; -import com.getpcpanel.hid.DeviceScanner; import com.getpcpanel.obs.OBS; import com.getpcpanel.obs.OBSConnectEvent; import com.getpcpanel.obs.OBSMuteEvent; -import com.getpcpanel.profile.LightingConfig; import com.getpcpanel.profile.Profile; import com.getpcpanel.profile.SaveService; -import com.getpcpanel.profile.SingleKnobLightingConfig; -import com.getpcpanel.profile.SingleSliderLabelLightingConfig; -import com.getpcpanel.profile.SingleSliderLightingConfig; -import com.getpcpanel.ui.ILightingDialogMuteOverrideHelper; -import com.getpcpanel.ui.LightingChangedToDefaultEvent; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.profile.dto.LightingConfig.LightingMode; +import com.getpcpanel.profile.dto.SingleKnobLightingConfig; +import com.getpcpanel.profile.dto.SingleKnobLightingConfig.SINGLE_KNOB_MODE; +import com.getpcpanel.profile.dto.SingleSliderLabelLightingConfig; +import com.getpcpanel.profile.dto.SingleSliderLabelLightingConfig.SINGLE_SLIDER_LABEL_MODE; +import com.getpcpanel.profile.dto.SingleSliderLightingConfig; +import com.getpcpanel.profile.dto.SingleSliderLightingConfig.SINGLE_SLIDER_MODE; import com.getpcpanel.util.coloroverride.ColorOverrideHolder; import com.getpcpanel.util.coloroverride.IOverrideColorProvider; import com.getpcpanel.util.coloroverride.IOverrideColorProviderProvider; @@ -43,7 +41,10 @@ import com.getpcpanel.voicemeeter.Voicemeeter.ButtonType; import com.getpcpanel.voicemeeter.Voicemeeter.ControlType; -import lombok.RequiredArgsConstructor; +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; import lombok.extern.log4j.Log4j2; import one.util.streamex.EntryStream; import one.util.streamex.StreamEx; @@ -52,21 +53,23 @@ * Triggers a color change when the device or application that is controlled by the dial/slider is muted/unmuted. */ @Log4j2 -@Service -@Order(0) -@RequiredArgsConstructor +@ApplicationScoped +@Priority(0) public class SetMuteOverrideService implements IOverrideColorProviderProvider { private static final Pattern voiceMeeterPattern = Pattern.compile("VoiceMeeter: (Input|Output) (\\d+), (.*)"); // 1: In/Out, 2: Idx, 3: ButtonType - private final DeviceHolder devices; - private final ISndCtrl sndCtrl; - private final SaveService saveService; - private final OBS obs; + @Inject + DeviceHolder devices; + @Inject + ISndCtrl sndCtrl; + @Inject + SaveService saveService; + @Inject + OBS obs; private final ColorOverrideHolder colorOverrideHolder = new ColorOverrideHolder(); - @EventListener({ DeviceScanner.DeviceConnectedEvent.class, LightingChangedToDefaultEvent.class }) public void triggerAll() { colorOverrideHolder.clearAllOverrides(); - for (var device : sndCtrl.getDevices()) { + for (var device : sndCtrl.devices()) { onAudioDevice(new AudioDeviceEvent(device, EventType.CHANGED)); } for (var sess : sndCtrl.getAllSessions()) { @@ -75,8 +78,7 @@ public void triggerAll() { updateObs(new OBSConnectEvent(obs.isConnected())); } - @EventListener(OBSConnectEvent.class) - public void updateObs(OBSConnectEvent event) { + public void updateObs(@Observes OBSConnectEvent event) { if (!event.connected()) { return; } @@ -84,8 +86,7 @@ public void updateObs(OBSConnectEvent event) { EntryStream.of(obs.getSourcesWithMuteState()).mapKeyValue(OBSMuteEvent::new).forEach(this::onObsSource); } - @EventListener - public void onObsSource(OBSMuteEvent event) { + public void onObsSource(@Observes OBSMuteEvent event) { var lcName = event.input(); handleEvent( dlc -> isFollow(dlc) && @@ -93,8 +94,7 @@ public void onObsSource(OBSMuteEvent event) { event.muted()); } - @EventListener - public void onVoiceMeeterSource(VoiceMeeterMuteEvent event) { + public void onVoiceMeeterSource(@Observes VoiceMeeterMuteEvent event) { var type = event.ct(); var idx = event.idx(); var button = event.button(); @@ -105,7 +105,7 @@ public void onVoiceMeeterSource(VoiceMeeterMuteEvent event) { if (voiceMeeterCmd instanceof CommandVoiceMeeterBasic vmBasic) { return vmBasic.getCt() == type && vmBasic.getIndex() == idx; } else if (voiceMeeterCmd instanceof CommandVoiceMeeterAdvanced vmAdv) { - return StringUtils.startsWithIgnoreCase(vmAdv.getFullParam(), type.name() + "[" + idx + "]"); + return StringUtils.startsWithIgnoreCase(vmAdv.getFullParam(), type.getName() + "[" + idx + "]"); } } else if (StringUtils.isNotBlank(dlc.deviceOrFollow)) { var matcher = voiceMeeterPattern.matcher(dlc.deviceOrFollow); @@ -122,8 +122,7 @@ public void onVoiceMeeterSource(VoiceMeeterMuteEvent event) { event.state()); } - @EventListener - public void onAudioSession(AudioSessionEvent event) { + public void onAudioSession(@Observes AudioSessionEvent event) { var lcName = StringUtils.lowerCase(event.session().executable().getName().toLowerCase()); handleEvent( dlc -> isFollow(dlc) && @@ -131,8 +130,7 @@ public void onAudioSession(AudioSessionEvent event) { event.session().muted()); } - @EventListener - public void onAudioDevice(AudioDeviceEvent event) { + public void onAudioDevice(@Observes AudioDeviceEvent event) { handleEvent( dlc -> isDevice(event, dlc) || (isFollow(dlc) && dlc.cmd.getCommand(CommandVolumeDevice.class).filter(vd -> sndCtrl.defaultDeviceOnEmpty(vd.getDeviceId()).equals(event.device().id())).isPresent()), @@ -156,9 +154,9 @@ public void handleEvent(Predicate isApplicable, boolean i } var device = deviceOpt.get(); var deviceSave = idDeviceSave.getValue(); - var profile = deviceSave.ensureCurrentProfile(device.getDeviceType()); - var mayBeChangedLC = device.getLightingConfig(); - if (mayBeChangedLC.getLightingMode() != LightingConfig.LightingMode.CUSTOM) { + var profile = deviceSave.ensureCurrentProfile(device.deviceType()); + var mayBeChangedLC = device.lightingConfig(); + if (mayBeChangedLC.lightingMode() != LightingMode.CUSTOM) { continue; } @@ -191,18 +189,26 @@ private List tryGetAllDeviceLightingCapable(String device private List getAllDeviceLightingCapable(String deviceSerial, LightingConfig mayBeChangedLC, Profile profile) { var result = new ArrayList(); - var oLightConfig = profile.getLightingConfig(); - var knobLength = mayBeChangedLC.getKnobConfigs().length; + var oLightConfig = profile.lightingConfig(); + var knobLength = mayBeChangedLC.knobConfigs().length; + var oKnobConfigs = oLightConfig.knobConfigs(); + var oSliderConfigs = oLightConfig.sliderConfigs(); + var oSliderLabelConfigs = oLightConfig.sliderLabelConfigs(); + for (var idxCommand : profile.getDialData().entrySet()) { var idx = idxCommand.getKey(); var command = idxCommand.getValue(); if (idx < knobLength) { // It's a knob - var deviceOrFollow = oLightConfig.getKnobConfigs()[idx].getMuteOverrideDeviceOrFollow(); - var muteOverrideColor = oLightConfig.getKnobConfigs()[idx].getMuteOverrideColor(); + if (idx >= oKnobConfigs.length) { + log.warn("knobConfig index {} out of bounds (length {}), skipping", idx, oKnobConfigs.length); + continue; + } + var deviceOrFollow = oKnobConfigs[idx].getMuteOverrideDeviceOrFollow(); + var muteOverrideColor = oKnobConfigs[idx].getMuteOverrideColor(); if (StringUtils.isNoneBlank(deviceOrFollow, muteOverrideColor)) { Runnable toOriginal = () -> colorOverrideHolder.setDialOverride(deviceSerial, idx, null); - Runnable toMute = () -> colorOverrideHolder.setDialOverride(deviceSerial, idx, new SingleKnobLightingConfig().setMode(SingleKnobLightingConfig.SINGLE_KNOB_MODE.STATIC) + Runnable toMute = () -> colorOverrideHolder.setDialOverride(deviceSerial, idx, new SingleKnobLightingConfig().setMode(SINGLE_KNOB_MODE.STATIC) .setColor1(muteOverrideColor) .setMuteOverrideDeviceOrFollow(deviceOrFollow) .setMuteOverrideColor(muteOverrideColor)); @@ -210,22 +216,26 @@ private List getAllDeviceLightingCapable(String deviceSer } } else { // It's a slider with label var slider = idx - knobLength; - var sliderDeviceOrFollow = oLightConfig.getSliderConfigs()[slider].getMuteOverrideDeviceOrFollow(); - var sliderOverride = oLightConfig.getSliderConfigs()[slider].getMuteOverrideColor(); + if (slider >= oSliderConfigs.length || slider >= oSliderLabelConfigs.length) { + log.warn("sliderConfig index {} out of bounds (sliders={}, labels={}), skipping", slider, oSliderConfigs.length, oSliderLabelConfigs.length); + continue; + } + var sliderDeviceOrFollow = oSliderConfigs[slider].getMuteOverrideDeviceOrFollow(); + var sliderOverride = oSliderConfigs[slider].getMuteOverrideColor(); if (StringUtils.isNoneBlank(sliderDeviceOrFollow, sliderOverride)) { Runnable toOriginal = () -> colorOverrideHolder.setSliderOverride(deviceSerial, slider, null); - Runnable toMute = () -> colorOverrideHolder.setSliderOverride(deviceSerial, slider, new SingleSliderLightingConfig().setMode(SingleSliderLightingConfig.SINGLE_SLIDER_MODE.STATIC) + Runnable toMute = () -> colorOverrideHolder.setSliderOverride(deviceSerial, slider, new SingleSliderLightingConfig().setMode(SINGLE_SLIDER_MODE.STATIC) .setColor1(sliderOverride) .setMuteOverrideDeviceOrFollow(sliderDeviceOrFollow) .setMuteOverrideColor(sliderOverride)); result.add(new DeviceLightingCapable(sliderDeviceOrFollow, command, toOriginal, toMute)); // Slider } - var labelDeviceOrFollow = oLightConfig.getSliderLabelConfigs()[slider].getMuteOverrideDeviceOrFollow(); - var labelOverride = oLightConfig.getSliderLabelConfigs()[slider].getMuteOverrideColor(); + var labelDeviceOrFollow = oSliderLabelConfigs[slider].getMuteOverrideDeviceOrFollow(); + var labelOverride = oSliderLabelConfigs[slider].getMuteOverrideColor(); if (StringUtils.isNoneBlank(labelDeviceOrFollow, labelOverride)) { Runnable toOriginal = () -> colorOverrideHolder.setSliderLabelOverride(deviceSerial, slider, null); - Runnable toMute = () -> colorOverrideHolder.setSliderLabelOverride(deviceSerial, slider, new SingleSliderLabelLightingConfig().setMode(SingleSliderLabelLightingConfig.SINGLE_SLIDER_LABEL_MODE.STATIC) + Runnable toMute = () -> colorOverrideHolder.setSliderLabelOverride(deviceSerial, slider, new SingleSliderLabelLightingConfig().setMode(SINGLE_SLIDER_LABEL_MODE.STATIC) .setColor(labelOverride) .setMuteOverrideDeviceOrFollow(labelDeviceOrFollow) .setMuteOverrideColor(labelOverride)); diff --git a/src/main/java/com/getpcpanel/commands/command/CommandBrightness.java b/src/main/java/com/getpcpanel/commands/command/CommandBrightness.java index 99f33a91..51bbd3f6 100644 --- a/src/main/java/com/getpcpanel/commands/command/CommandBrightness.java +++ b/src/main/java/com/getpcpanel/commands/command/CommandBrightness.java @@ -2,7 +2,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -import com.getpcpanel.MainFX; +import com.getpcpanel.util.CdiHelper; import com.getpcpanel.hid.DeviceHolder; import com.getpcpanel.profile.SaveService; @@ -23,12 +23,12 @@ public CommandBrightness(@JsonProperty("dialParams") DialCommandParams dialParam @Override public void execute(DialActionParameters context) { - MainFX.getBean(DeviceHolder.class).getDevice(context.device()).ifPresent(device -> { - var lightingConfig = device.getLightingConfig(); + CdiHelper.getBean(DeviceHolder.class).getDevice(context.device()).ifPresent(device -> { + var lightingConfig = device.lightingConfig(); lightingConfig.setGlobalBrightness(context.dial().getValue(this)); device.setLighting(lightingConfig, false); - MainFX.getBean(SaveService.class).debouncedSave(); + CdiHelper.getBean(SaveService.class).debouncedSave(); }); } diff --git a/src/main/java/com/getpcpanel/commands/command/CommandConverter.java b/src/main/java/com/getpcpanel/commands/command/CommandConverter.java index 7e270f2d..ebb47194 100644 --- a/src/main/java/com/getpcpanel/commands/command/CommandConverter.java +++ b/src/main/java/com/getpcpanel/commands/command/CommandConverter.java @@ -8,9 +8,14 @@ import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.math.NumberUtils; +import com.getpcpanel.commands.command.CommandMedia.VolumeButton; import com.getpcpanel.commands.command.DialAction.DialCommandParams; import com.getpcpanel.cpp.MuteType; -import com.getpcpanel.voicemeeter.Voicemeeter; +import com.getpcpanel.voicemeeter.Voicemeeter.ButtonControlMode; +import com.getpcpanel.voicemeeter.Voicemeeter.ButtonType; +import com.getpcpanel.voicemeeter.Voicemeeter.ControlType; +import com.getpcpanel.voicemeeter.Voicemeeter.DialControlMode; +import com.getpcpanel.voicemeeter.Voicemeeter.DialType; import lombok.extern.log4j.Log4j2; import one.util.streamex.StreamEx; @@ -37,9 +42,9 @@ public static Command convert(String[] data) { case "obs_dial" -> new CommandObsSetSourceVolume(data[2], DialCommandParams.DEFAULT); case "voicemeeter_dial" -> { if ("basic".equals(data[1])) { - yield new CommandVoiceMeeterBasic(Voicemeeter.ControlType.valueOf(data[2]), NumberUtils.toInt(data[3], 1), Voicemeeter.DialType.valueOf(data[4]), DialCommandParams.DEFAULT); + yield new CommandVoiceMeeterBasic(ControlType.valueOf(data[2]), NumberUtils.toInt(data[3], 1), DialType.valueOf(data[4]), DialCommandParams.DEFAULT); } else if ("advanced".equals(data[1])) { - var dt = Voicemeeter.DialControlMode.valueOf(data[3]); + var dt = DialControlMode.valueOf(data[3]); yield new CommandVoiceMeeterAdvanced(data[2], dt, DialCommandParams.DEFAULT); } yield NOOP; @@ -52,7 +57,7 @@ public static Command convert(String[] data) { yield new CommandKeystroke(data[1]); } case "shortcut" -> new CommandShortcut(data[1]); - case "media" -> CommandMedia.VolumeButton.tryValueOf(data[1]).map(v -> new CommandMedia(v, false)).map(Command.class::cast).orElse(NOOP); + case "media" -> VolumeButton.tryValueOf(data[1]).map(v -> new CommandMedia(v, false)).map(Command.class::cast).orElse(NOOP); case "end_program" -> new CommandEndProgram(StringUtils.equals("specific", data[1]), data[2]); case "sound_device" -> new CommandVolumeDefaultDevice(data[1]); case "toggle_device" -> new CommandVolumeDefaultDeviceToggle(List.of(data[1].split("\\|"))); @@ -62,15 +67,15 @@ public static Command convert(String[] data) { if ("set_scene".equals(data[1])) { yield new CommandObsSetScene(data[2]); } else if ("mute_source".equals(data[1])) { - yield new CommandObsMuteSource(data[2], CommandObsMuteSource.MuteType.valueOf(data[3])); + yield new CommandObsMuteSource(data[2], MuteType.valueOf(data[3])); } yield NOOP; } case "voicemeeter_button" -> { if ("basic".equals(data[1])) { - yield new CommandVoiceMeeterBasicButton(Voicemeeter.ControlType.valueOf(data[2]), NumberUtils.toInt(data[3], 1), Voicemeeter.ButtonType.valueOf(data[4])); + yield new CommandVoiceMeeterBasicButton(ControlType.valueOf(data[2]), NumberUtils.toInt(data[3], 1), ButtonType.valueOf(data[4])); } else if ("advanced".equals(data[1])) { - var bt = Voicemeeter.ButtonControlMode.valueOf(data[3]); + var bt = ButtonControlMode.valueOf(data[3]); yield new CommandVoiceMeeterAdvancedButton(data[2], bt, null); } yield NOOP; diff --git a/src/main/java/com/getpcpanel/commands/command/CommandEndProgram.java b/src/main/java/com/getpcpanel/commands/command/CommandEndProgram.java index f1217ae5..ba6d5df4 100644 --- a/src/main/java/com/getpcpanel/commands/command/CommandEndProgram.java +++ b/src/main/java/com/getpcpanel/commands/command/CommandEndProgram.java @@ -2,7 +2,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -import com.getpcpanel.MainFX; +import com.getpcpanel.util.CdiHelper; import com.getpcpanel.util.IPlatformCommand; import lombok.Getter; @@ -25,7 +25,7 @@ public CommandEndProgram(@JsonProperty("specific") boolean specific, @JsonProper @Override public void execute() { - MainFX.getBean(IPlatformCommand.class).kill(specific ? name : IPlatformCommand.FOCUS); + CdiHelper.getBean(IPlatformCommand.class).kill(specific ? name : IPlatformCommand.FOCUS); } @Override diff --git a/src/main/java/com/getpcpanel/commands/command/CommandMedia.java b/src/main/java/com/getpcpanel/commands/command/CommandMedia.java index 82d47e63..649fc209 100644 --- a/src/main/java/com/getpcpanel/commands/command/CommandMedia.java +++ b/src/main/java/com/getpcpanel/commands/command/CommandMedia.java @@ -6,7 +6,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -import com.getpcpanel.MainFX; +import com.getpcpanel.util.CdiHelper; import com.getpcpanel.cpp.ISndCtrl; import com.getpcpanel.cpp.windows.SndCtrlWindows; import com.sun.jna.platform.win32.BaseTSD; @@ -92,7 +92,7 @@ private void executeSpotify() { private WinDef.HWND findSpotify() { var result = new WinDef.HWND[] { null }; - var pidIsSpotify = StreamEx.of(MainFX.getBean(SndCtrlWindows.class).getRunningApplications()).mapToEntry(ISndCtrl.RunningApplication::pid, ra -> StringUtils.equalsIgnoreCase("spotify.exe", ra.file().getName())).distinctKeys().toMap(); + var pidIsSpotify = StreamEx.of(CdiHelper.getBean(SndCtrlWindows.class).getRunningApplications()).mapToEntry(ISndCtrl.RunningApplication::pid, ra -> StringUtils.equalsIgnoreCase("spotify.exe", ra.file().getName())).distinctKeys().toMap(); User32.INSTANCE.EnumWindows((hWnd, data) -> { var target = new IntByReference(); User32.INSTANCE.GetWindowThreadProcessId(hWnd, target); diff --git a/src/main/java/com/getpcpanel/commands/command/CommandObsMuteSource.java b/src/main/java/com/getpcpanel/commands/command/CommandObsMuteSource.java index d6c899cb..7bb99479 100644 --- a/src/main/java/com/getpcpanel/commands/command/CommandObsMuteSource.java +++ b/src/main/java/com/getpcpanel/commands/command/CommandObsMuteSource.java @@ -2,8 +2,9 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -import com.getpcpanel.MainFX; +import com.getpcpanel.cpp.MuteType; import com.getpcpanel.obs.OBS; +import com.getpcpanel.util.CdiHelper; import lombok.Getter; import lombok.ToString; @@ -11,10 +12,6 @@ @Getter @ToString(callSuper = true) public class CommandObsMuteSource extends CommandObs implements ButtonAction { - public enum MuteType { - toggle, mute, unmute - } - private final String source; private final MuteType type; @@ -26,7 +23,7 @@ public CommandObsMuteSource(@JsonProperty("source") String source, @JsonProperty @Override public void execute() { - var obs = MainFX.getBean(OBS.class); + var obs = CdiHelper.getBean(OBS.class); if (obs.isConnected()) { switch (type) { case toggle -> obs.toggleSourceMute(source); diff --git a/src/main/java/com/getpcpanel/commands/command/CommandObsSetScene.java b/src/main/java/com/getpcpanel/commands/command/CommandObsSetScene.java index e520268e..047944d7 100644 --- a/src/main/java/com/getpcpanel/commands/command/CommandObsSetScene.java +++ b/src/main/java/com/getpcpanel/commands/command/CommandObsSetScene.java @@ -2,7 +2,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -import com.getpcpanel.MainFX; +import com.getpcpanel.util.CdiHelper; import com.getpcpanel.obs.OBS; import lombok.Getter; @@ -20,7 +20,7 @@ public CommandObsSetScene(@JsonProperty("scene") String scene) { @Override public void execute() { - var obs = MainFX.getBean(OBS.class); + var obs = CdiHelper.getBean(OBS.class); if (obs.isConnected()) { obs.setCurrentScene(scene); } diff --git a/src/main/java/com/getpcpanel/commands/command/CommandObsSetSourceVolume.java b/src/main/java/com/getpcpanel/commands/command/CommandObsSetSourceVolume.java index b3c22bad..2cfd1f59 100644 --- a/src/main/java/com/getpcpanel/commands/command/CommandObsSetSourceVolume.java +++ b/src/main/java/com/getpcpanel/commands/command/CommandObsSetSourceVolume.java @@ -2,7 +2,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -import com.getpcpanel.MainFX; +import com.getpcpanel.util.CdiHelper; import com.getpcpanel.obs.OBS; import lombok.Getter; @@ -24,7 +24,7 @@ public CommandObsSetSourceVolume( @Override public void execute(DialActionParameters context) { - var obs = MainFX.getBean(OBS.class); + var obs = CdiHelper.getBean(OBS.class); if (obs.isConnected()) { obs.setSourceVolume(sourceName, context.dial().getValue(this)); } diff --git a/src/main/java/com/getpcpanel/commands/command/CommandProfile.java b/src/main/java/com/getpcpanel/commands/command/CommandProfile.java index 8302f615..5e1da4e5 100644 --- a/src/main/java/com/getpcpanel/commands/command/CommandProfile.java +++ b/src/main/java/com/getpcpanel/commands/command/CommandProfile.java @@ -6,10 +6,9 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -import com.getpcpanel.MainFX; import com.getpcpanel.hid.DeviceHolder; +import com.getpcpanel.util.CdiHelper; -import javafx.application.Platform; import lombok.Getter; import lombok.ToString; @@ -24,7 +23,7 @@ public CommandProfile(@Nullable @JsonProperty("profile") String profile) { @Override public void execute(DeviceActionParameters context) { - Platform.runLater(() -> MainFX.getBean(DeviceHolder.class).getDevice(context.device()).ifPresent(device -> device.setProfile(profile))); + CdiHelper.getBean(DeviceHolder.class).getDevice(context.device()).ifPresent(device -> device.setProfile(profile)); } @Override diff --git a/src/main/java/com/getpcpanel/commands/command/CommandRun.java b/src/main/java/com/getpcpanel/commands/command/CommandRun.java index f0eb090b..3b49932c 100644 --- a/src/main/java/com/getpcpanel/commands/command/CommandRun.java +++ b/src/main/java/com/getpcpanel/commands/command/CommandRun.java @@ -2,7 +2,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -import com.getpcpanel.MainFX; +import com.getpcpanel.util.CdiHelper; import com.getpcpanel.util.IPlatformCommand; import lombok.Getter; @@ -22,7 +22,7 @@ public CommandRun(@JsonProperty("command") String command) { @Override public void execute() { - MainFX.getBean(IPlatformCommand.class).exec(command); + CdiHelper.getBean(IPlatformCommand.class).exec(command); } @Override diff --git a/src/main/java/com/getpcpanel/commands/command/CommandShortcut.java b/src/main/java/com/getpcpanel/commands/command/CommandShortcut.java index 13006e46..31d77282 100644 --- a/src/main/java/com/getpcpanel/commands/command/CommandShortcut.java +++ b/src/main/java/com/getpcpanel/commands/command/CommandShortcut.java @@ -2,7 +2,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -import com.getpcpanel.MainFX; +import com.getpcpanel.util.CdiHelper; import com.getpcpanel.util.IPlatformCommand; import lombok.Getter; @@ -23,7 +23,7 @@ public CommandShortcut(@JsonProperty("shortcut") String shortcut) { @Override public void execute() { - MainFX.getBean(IPlatformCommand.class).exec(shortcut); + CdiHelper.getBean(IPlatformCommand.class).exec(shortcut); } @Override diff --git a/src/main/java/com/getpcpanel/commands/command/CommandVoiceMeeterAdvanced.java b/src/main/java/com/getpcpanel/commands/command/CommandVoiceMeeterAdvanced.java index e7c4b848..251b54ab 100644 --- a/src/main/java/com/getpcpanel/commands/command/CommandVoiceMeeterAdvanced.java +++ b/src/main/java/com/getpcpanel/commands/command/CommandVoiceMeeterAdvanced.java @@ -2,7 +2,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -import com.getpcpanel.MainFX; +import com.getpcpanel.util.CdiHelper; import com.getpcpanel.voicemeeter.Voicemeeter; import lombok.Getter; @@ -30,7 +30,7 @@ public void execute(DialActionParameters context) { if (ct == null) { return; } - var voiceMeeter = MainFX.getBean(Voicemeeter.class); + var voiceMeeter = CdiHelper.getBean(Voicemeeter.class); if (voiceMeeter.login()) { voiceMeeter.controlLevel(fullParam, ct, context.dial().getValue(this)); } diff --git a/src/main/java/com/getpcpanel/commands/command/CommandVoiceMeeterAdvancedButton.java b/src/main/java/com/getpcpanel/commands/command/CommandVoiceMeeterAdvancedButton.java index 05844aad..14c8c4c2 100644 --- a/src/main/java/com/getpcpanel/commands/command/CommandVoiceMeeterAdvancedButton.java +++ b/src/main/java/com/getpcpanel/commands/command/CommandVoiceMeeterAdvancedButton.java @@ -6,7 +6,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -import com.getpcpanel.MainFX; +import com.getpcpanel.util.CdiHelper; import com.getpcpanel.voicemeeter.Voicemeeter; import lombok.Getter; @@ -31,7 +31,7 @@ public CommandVoiceMeeterAdvancedButton( @Override public void execute() { - var voiceMeeter = MainFX.getBean(Voicemeeter.class); + var voiceMeeter = CdiHelper.getBean(Voicemeeter.class); if (voiceMeeter.login()) { voiceMeeter.controlButton(fullParam, bt, stringValue); } diff --git a/src/main/java/com/getpcpanel/commands/command/CommandVoiceMeeterBasic.java b/src/main/java/com/getpcpanel/commands/command/CommandVoiceMeeterBasic.java index 43c97e87..7063ac97 100644 --- a/src/main/java/com/getpcpanel/commands/command/CommandVoiceMeeterBasic.java +++ b/src/main/java/com/getpcpanel/commands/command/CommandVoiceMeeterBasic.java @@ -2,7 +2,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -import com.getpcpanel.MainFX; +import com.getpcpanel.util.CdiHelper; import com.getpcpanel.voicemeeter.Voicemeeter; import lombok.Getter; @@ -30,7 +30,7 @@ public CommandVoiceMeeterBasic( @Override public void execute(DialActionParameters context) { - var voiceMeeter = MainFX.getBean(Voicemeeter.class); + var voiceMeeter = CdiHelper.getBean(Voicemeeter.class); if (voiceMeeter.login()) { voiceMeeter.controlLevel(ct, index, dt, context.dial().getValue(this)); } diff --git a/src/main/java/com/getpcpanel/commands/command/CommandVoiceMeeterBasicButton.java b/src/main/java/com/getpcpanel/commands/command/CommandVoiceMeeterBasicButton.java index 7e0cf8d3..ef27f022 100644 --- a/src/main/java/com/getpcpanel/commands/command/CommandVoiceMeeterBasicButton.java +++ b/src/main/java/com/getpcpanel/commands/command/CommandVoiceMeeterBasicButton.java @@ -2,7 +2,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -import com.getpcpanel.MainFX; +import com.getpcpanel.util.CdiHelper; import com.getpcpanel.voicemeeter.Voicemeeter; import lombok.Getter; @@ -24,7 +24,7 @@ public CommandVoiceMeeterBasicButton(@JsonProperty("ct") Voicemeeter.ControlType @Override public void execute() { - var voiceMeeter = MainFX.getBean(Voicemeeter.class); + var voiceMeeter = CdiHelper.getBean(Voicemeeter.class); if (voiceMeeter.login()) { voiceMeeter.controlButton(ct, index, bt, null); } diff --git a/src/main/java/com/getpcpanel/commands/command/CommandVolume.java b/src/main/java/com/getpcpanel/commands/command/CommandVolume.java index edda5957..d3e7025f 100644 --- a/src/main/java/com/getpcpanel/commands/command/CommandVolume.java +++ b/src/main/java/com/getpcpanel/commands/command/CommandVolume.java @@ -1,13 +1,16 @@ package com.getpcpanel.commands.command; -import com.getpcpanel.MainFX; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.getpcpanel.util.CdiHelper; +import jakarta.inject.Inject; import com.getpcpanel.cpp.ISndCtrl; import lombok.ToString; @ToString(callSuper = true) public abstract class CommandVolume extends Command { + @JsonIgnore protected ISndCtrl getSndCtrl() { - return MainFX.getBean(ISndCtrl.class); + return CdiHelper.getBean(ISndCtrl.class); } } diff --git a/src/main/java/com/getpcpanel/commands/command/CommandVolumeApplicationDeviceToggle.java b/src/main/java/com/getpcpanel/commands/command/CommandVolumeApplicationDeviceToggle.java index a9305c42..48cd2bc7 100644 --- a/src/main/java/com/getpcpanel/commands/command/CommandVolumeApplicationDeviceToggle.java +++ b/src/main/java/com/getpcpanel/commands/command/CommandVolumeApplicationDeviceToggle.java @@ -65,7 +65,7 @@ private void setDevices(DeviceSet deviceSet) { return null; } var sndCtrl = getWinSndCtrl(); - return StreamEx.of(sndCtrl.getDevices()).findFirst(d -> StringUtils.containsIgnoreCase(d.name(), mediaPlayback)).map(AudioDevice::id).orElse(null); + return StreamEx.of(sndCtrl.devices()).findFirst(d -> StringUtils.containsIgnoreCase(d.name(), mediaPlayback)).map(AudioDevice::id).orElse(null); } private Set determineTargets() { diff --git a/src/main/java/com/getpcpanel/commands/command/CommandVolumeDefaultDeviceAdvanced.java b/src/main/java/com/getpcpanel/commands/command/CommandVolumeDefaultDeviceAdvanced.java index 3d7436f0..02ffb867 100644 --- a/src/main/java/com/getpcpanel/commands/command/CommandVolumeDefaultDeviceAdvanced.java +++ b/src/main/java/com/getpcpanel/commands/command/CommandVolumeDefaultDeviceAdvanced.java @@ -2,7 +2,7 @@ import javax.annotation.Nullable; -import com.getpcpanel.MainFX; +import com.getpcpanel.util.CdiHelper; import com.getpcpanel.cpp.DataFlow; import com.getpcpanel.cpp.Role; import com.getpcpanel.cpp.windows.SndCtrlWindows; @@ -27,7 +27,7 @@ public class CommandVolumeDefaultDeviceAdvanced extends CommandVolume implements @Override public void execute() { - var windowsSndCtrl = MainFX.getBean(SndCtrlWindows.class); + var windowsSndCtrl = CdiHelper.getBean(SndCtrlWindows.class); windowsSndCtrl.setDefaultDevice(mediaPb, DataFlow.dfRender, Role.roleMultimedia); windowsSndCtrl.setDefaultDevice(mediaRec, DataFlow.dfCapture, Role.roleMultimedia); windowsSndCtrl.setDefaultDevice(communicationPb, DataFlow.dfRender, Role.roleCommunications); diff --git a/src/main/java/com/getpcpanel/commands/command/CommandVolumeFocus.java b/src/main/java/com/getpcpanel/commands/command/CommandVolumeFocus.java index fd9cc1d3..2110f779 100644 --- a/src/main/java/com/getpcpanel/commands/command/CommandVolumeFocus.java +++ b/src/main/java/com/getpcpanel/commands/command/CommandVolumeFocus.java @@ -2,6 +2,8 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import com.getpcpanel.volume.VolumeCoordinatorService; +import com.getpcpanel.util.CdiHelper; import lombok.Getter; import lombok.ToString; @@ -18,7 +20,7 @@ public CommandVolumeFocus(@JsonProperty("dialParams") DialCommandParams dialPara @Override public void execute(DialActionParameters context) { - getSndCtrl().setFocusVolume(context.dial().getValue(this, 0, 1)); + CdiHelper.getBean(VolumeCoordinatorService.class).setFocusVolume(context.dial().getValue(this, 0, 1)); } @Override @@ -26,3 +28,4 @@ public String buildLabel() { return ""; } } + diff --git a/src/main/java/com/getpcpanel/commands/command/DialAction.java b/src/main/java/com/getpcpanel/commands/command/DialAction.java index ca26e670..76695ef1 100644 --- a/src/main/java/com/getpcpanel/commands/command/DialAction.java +++ b/src/main/java/com/getpcpanel/commands/command/DialAction.java @@ -15,7 +15,8 @@ default boolean hasOverlay() { return true; } - @Nullable DialCommandParams getDialParams(); + @Nullable + DialCommandParams getDialParams(); default boolean isInvert() { return getDialParams() != null && getDialParams().invert; diff --git a/src/main/java/com/getpcpanel/cpp/AudioDevice.java b/src/main/java/com/getpcpanel/cpp/AudioDevice.java index fb585a56..46e24deb 100644 --- a/src/main/java/com/getpcpanel/cpp/AudioDevice.java +++ b/src/main/java/com/getpcpanel/cpp/AudioDevice.java @@ -2,8 +2,10 @@ import java.io.Serializable; -import org.springframework.context.ApplicationEventPublisher; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.enterprise.event.Event; import lombok.AccessLevel; import lombok.Data; import lombok.Setter; @@ -14,22 +16,22 @@ @Setter(AccessLevel.PROTECTED) @SuppressWarnings("unused") // Methods called from JNI public class AudioDevice implements Serializable { - protected final transient ApplicationEventPublisher eventPublisher; - private final String name; - private final String id; + @JsonIgnore protected final transient Event eventBus; + @JsonProperty private final String name; + @JsonProperty private final String id; private float volume; private boolean muted; - private DataFlow dataflow; + @JsonProperty private DataFlow dataflow; - public AudioDevice(ApplicationEventPublisher eventPublisher, String name, String id) { - this.eventPublisher = eventPublisher; + public AudioDevice(Event eventBus, String name, String id) { + this.eventBus = eventBus; this.name = name; this.id = id; } private void setState(float volume, boolean muted) { volume(volume).muted(muted); - eventPublisher.publishEvent(new AudioDeviceEvent(this, EventType.CHANGED)); + eventBus.fire(new AudioDeviceEvent(this, EventType.CHANGED)); log.trace("State changed: {}", this); } diff --git a/src/main/java/com/getpcpanel/cpp/AudioSession.java b/src/main/java/com/getpcpanel/cpp/AudioSession.java index a47d1d5d..74ebd316 100644 --- a/src/main/java/com/getpcpanel/cpp/AudioSession.java +++ b/src/main/java/com/getpcpanel/cpp/AudioSession.java @@ -2,13 +2,16 @@ import java.io.File; +import org.apache.commons.lang3.StringUtils; + import javax.annotation.Nullable; -import org.apache.commons.lang3.StringUtils; -import org.springframework.context.ApplicationEventPublisher; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.enterprise.event.Event; import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.EqualsAndHashCode.Exclude; import lombok.ToString; import lombok.extern.log4j.Log4j2; @@ -17,16 +20,17 @@ @SuppressWarnings("unused") // Methods called from JNI public class AudioSession { public static final String SYSTEM = "System Sounds"; - @EqualsAndHashCode.Exclude @ToString.Exclude @Nullable private final ApplicationEventPublisher eventPublisher; + @JsonIgnore @Exclude @ToString.Exclude @Nullable + transient Event eventBus; private int pid; private File executable; - @EqualsAndHashCode.Exclude private String title; - @EqualsAndHashCode.Exclude @Nullable private String icon; - @EqualsAndHashCode.Exclude private float volume; - @EqualsAndHashCode.Exclude private boolean muted; + @Exclude private String title; + @Exclude @Nullable private String icon; + @Exclude private float volume; + @Exclude private boolean muted; - public AudioSession(@Nullable ApplicationEventPublisher eventPublisher, int pid, File executable, String title, @Nullable String icon, float volume, boolean muted) { - this.eventPublisher = eventPublisher; + public AudioSession(@Nullable Event eventBus, int pid, File executable, String title, @Nullable String icon, float volume, boolean muted) { + this.eventBus = eventBus; this.pid = pid; this.executable = executable; this.icon = icon; @@ -77,8 +81,8 @@ protected AudioSession setVolumeNoTrigger(float volume) { } private void triggerChange() { - if (eventPublisher != null) { - eventPublisher.publishEvent(new AudioSessionEvent(this, EventType.CHANGED)); + if (eventBus != null) { + eventBus.fire(new AudioSessionEvent(this, EventType.CHANGED)); } } } diff --git a/src/main/java/com/getpcpanel/cpp/ISndCtrl.java b/src/main/java/com/getpcpanel/cpp/ISndCtrl.java index eb7736c7..2b28cc1d 100644 --- a/src/main/java/com/getpcpanel/cpp/ISndCtrl.java +++ b/src/main/java/com/getpcpanel/cpp/ISndCtrl.java @@ -9,7 +9,7 @@ public interface ISndCtrl { Map getDevicesMap(); - Collection getDevices(); + Collection devices(); Collection getAllSessions(); @@ -39,4 +39,24 @@ public interface ISndCtrl { record RunningApplication(int pid, File file, String name) { } + + static ISndCtrl noOp() { + return new ISndCtrl() { + @Override public Map getDevicesMap() { return Map.of(); } + @Override public Collection devices() { return List.of(); } + @Override public Collection getAllSessions() { return List.of(); } + @Override public AudioDevice getDevice(String id) { return null; } + @Override public void setDeviceVolume(String deviceId, float volume) {} + @Override public void muteDevice(String deviceId, MuteType mute) {} + @Override public void setDefaultDevice(String deviceId) {} + @Override public void setProcessVolume(String fileName, String device, float volume) {} + @Override public void setFocusVolume(float volume) {} + @Override public void muteProcesses(Set fileName, MuteType mute) {} + @Override public String getFocusApplication() { return null; } + @Override public List getRunningApplications() { return List.of(); } + @Override public String defaultDeviceOnEmpty(String deviceId) { return deviceId; } + @Override public String defaultPlayer() { return null; } + @Override public String defaultRecorder() { return null; } + }; + } } diff --git a/src/main/java/com/getpcpanel/cpp/SetNewSessionVolumeService.java b/src/main/java/com/getpcpanel/cpp/SetNewSessionVolumeService.java deleted file mode 100644 index 8f5d95d4..00000000 --- a/src/main/java/com/getpcpanel/cpp/SetNewSessionVolumeService.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.getpcpanel.cpp; - -import org.apache.commons.lang3.StringUtils; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; - -import com.getpcpanel.commands.AbstractNewXVolumeService; -import com.getpcpanel.commands.command.CommandVolumeProcess; -import com.getpcpanel.cpp.windows.WindowsAudioSession; -import com.getpcpanel.hid.DeviceHolder; -import com.getpcpanel.profile.SaveService; - -import lombok.extern.log4j.Log4j2; - -/** - * Triggers a volume change when a new audio session is started and that session is controlled by the panel. - */ -@Log4j2 -@Service -public class SetNewSessionVolumeService extends AbstractNewXVolumeService { - private final ISndCtrl sndCtrl; - private final SaveService save; - - public SetNewSessionVolumeService(DeviceHolder devices, ApplicationEventPublisher eventPublisher, @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") ISndCtrl sndCtrl, SaveService save) { - super(devices, eventPublisher); - this.sndCtrl = sndCtrl; - this.save = save; - } - - @EventListener - public void onNewAudioSession(AudioSessionEvent event) { - if (event.eventType() == EventType.ADDED || (save.get().isForceVolume() && event.eventType() == EventType.CHANGED)) { - triggerCommandsOf(CommandVolumeProcess.class, s -> s.filterValues(c -> isProcessAndDevice(event, c))); - } - } - - private boolean isProcessAndDevice(AudioSessionEvent event, CommandVolumeProcess c) { - var session = event.session(); - if (!c.getProcessName().contains(session.executable().getName())) { - return false; - } - - if (session instanceof WindowsAudioSession wis) { - var device = wis.device(); - return StringUtils.equals("*", c.getDevice()) - || (StringUtils.isBlank(c.getDevice()) && StringUtils.equals(sndCtrl.defaultDeviceOnEmpty(c.getDevice()), device.id())); - } - return true; - } -} diff --git a/src/main/java/com/getpcpanel/cpp/linux/LinuxProcessHelper.java b/src/main/java/com/getpcpanel/cpp/linux/LinuxProcessHelper.java index 9cd77f9c..92c38c97 100644 --- a/src/main/java/com/getpcpanel/cpp/linux/LinuxProcessHelper.java +++ b/src/main/java/com/getpcpanel/cpp/linux/LinuxProcessHelper.java @@ -2,29 +2,37 @@ import java.io.IOException; import java.nio.charset.Charset; -import java.util.Objects; import java.util.Optional; -import javax.annotation.Nullable; - import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.math.NumberUtils; -import org.springframework.stereotype.Service; +import org.eclipse.microprofile.config.ConfigProvider; + +import javax.annotation.Nullable; -import com.getpcpanel.MainFX; -import com.getpcpanel.spring.ConditionalOnLinux; +import com.getpcpanel.platform.LinuxBuild; import com.getpcpanel.util.ProcessHelper; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import lombok.Getter; -import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; @Log4j2 -@Service -@ConditionalOnLinux -@RequiredArgsConstructor +@ApplicationScoped +@LinuxBuild public class LinuxProcessHelper { - private final ProcessHelper processHelper; + @Inject + ProcessHelper processHelper; + + @PostConstruct + void logResolvedTools() { + for (var tool : Tool.values()) { + var command = tool.command(); + log.info("Active window tool {} command: {} (available: {})", tool.tool, command, tool.available(command)); + } + } public ProcessBuilder builder(String... command) { return processHelper.builder(command); @@ -37,9 +45,10 @@ public int getActiveProcessPid() { } private Optional getActiveProcessPid(Tool tool) { - if (tool.available()) { + var command = tool.command(); + if (tool.available(command)) { try { - var line = lineFrom(tool.command, "getactivewindow", "getwindowpid"); + var line = lineFrom(command, "getactivewindow", "getwindowpid"); return Optional.of(NumberUtils.toInt(line, -1)).filter(v -> v != -1); } catch (Exception e) { log.error("Unable to run process", e); @@ -65,7 +74,7 @@ private Optional getActiveProcessPid(Tool tool) { if (lines.isEmpty()) { return null; } - return lines.getFirst(); + return lines.get(0); } @Getter @@ -73,13 +82,26 @@ private enum Tool { XDoTool("xdotool"), KDoTool("kdotool"); - private final String command; - private final boolean available; + private final String tool; Tool(String tool) { - command = resolveHomeRelativePath(Objects.requireNonNullElse(MainFX.getContext().getEnvironment().getProperty("linux.commands." + tool), tool)); - available = ProcessConditionalHelper.isProcessAvailable(command); - log.info("Active Window tool {} enabled: {}", tool, available); + this.tool = tool; + } + + @PostConstruct + public void bla() { + log.error("Post construct"); + } + + private String command() { + var configured = ConfigProvider.getConfig().getOptionalValue("linux.commands." + tool, String.class).orElse(tool); + return resolveHomeRelativePath(configured); + } + + private boolean available(String command) { + var available = ProcessConditionalHelper.isProcessAvailable(command); + log.debug("Active Window tool {} command {} enabled: {}", tool, command, available); + return available; } private static String resolveHomeRelativePath(String process) { diff --git a/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/ConditionalOnPulseAudio.java b/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/ConditionalOnPulseAudio.java deleted file mode 100644 index 3027aace..00000000 --- a/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/ConditionalOnPulseAudio.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.getpcpanel.cpp.linux.pulseaudio; - -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -import org.apache.commons.lang3.SystemUtils; -import org.springframework.context.annotation.Condition; -import org.springframework.context.annotation.ConditionContext; -import org.springframework.context.annotation.Conditional; -import org.springframework.core.type.AnnotatedTypeMetadata; - -import com.getpcpanel.cpp.linux.ProcessConditionalHelper; - -import lombok.extern.log4j.Log4j2; - -@Documented -@Retention(RetentionPolicy.RUNTIME) -@Target({ ElementType.TYPE, ElementType.METHOD }) -@Conditional(ConditionalOnPulseAudio.OnLinuxCondition.class) -public @interface ConditionalOnPulseAudio { - @Log4j2 - class OnLinuxCondition implements Condition { - private static final String PACTL = "pactl"; - - @Override - public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { - if (!SystemUtils.IS_OS_LINUX) { - return false; - } - var pactlAvailable = ProcessConditionalHelper.isProcessAvailable(PACTL); - if (!pactlAvailable) { - log.error("Pactl is not available, install it first"); - System.exit(1); - } - return pactlAvailable; - } - } -} diff --git a/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/PulseAudioAudioDevice.java b/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/PulseAudioAudioDevice.java index 6e71d87d..b95ceee6 100644 --- a/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/PulseAudioAudioDevice.java +++ b/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/PulseAudioAudioDevice.java @@ -1,6 +1,6 @@ package com.getpcpanel.cpp.linux.pulseaudio; -import org.springframework.context.ApplicationEventPublisher; +import jakarta.enterprise.event.Event; import com.getpcpanel.cpp.AudioDevice; import com.getpcpanel.cpp.DataFlow; @@ -13,8 +13,8 @@ public class PulseAudioAudioDevice extends AudioDevice { private final boolean isDefault; private final boolean isOutput; - public PulseAudioAudioDevice(ApplicationEventPublisher eventPublisher, int index, String name, String id, boolean isDefault, boolean isOutput) { - super(eventPublisher, name, id); + public PulseAudioAudioDevice(Event eventBus, int index, String name, String id, boolean isDefault, boolean isOutput) { + super(eventBus, name, id); this.index = index; this.isDefault = isDefault; this.isOutput = isOutput; diff --git a/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/PulseAudioAudioSession.java b/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/PulseAudioAudioSession.java index 4eaa624b..e460dd7d 100644 --- a/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/PulseAudioAudioSession.java +++ b/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/PulseAudioAudioSession.java @@ -2,7 +2,7 @@ import java.io.File; -import org.springframework.context.ApplicationEventPublisher; +import jakarta.enterprise.event.Event; import com.getpcpanel.cpp.AudioSession; @@ -14,8 +14,8 @@ public class PulseAudioAudioSession extends AudioSession { private final int index; - public PulseAudioAudioSession(ApplicationEventPublisher eventPublisher, int index, int pid, File executable, String title, String icon, float volume, boolean muted) { - super(eventPublisher, pid, executable, title, icon, volume, muted); + public PulseAudioAudioSession(Event eventBus, int index, int pid, File executable, String title, String icon, float volume, boolean muted) { + super(eventBus, pid, executable, title, icon, volume, muted); this.index = index; } diff --git a/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/PulseAudioEventListener.java b/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/PulseAudioEventListener.java index 2b1b7c71..2f15c48c 100644 --- a/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/PulseAudioEventListener.java +++ b/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/PulseAudioEventListener.java @@ -7,47 +7,54 @@ import java.util.Date; import java.util.regex.Pattern; -import javax.annotation.Nullable; - import org.apache.commons.collections4.queue.CircularFifoQueue; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.math.NumberUtils; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Component; +import javax.annotation.Nullable; + +import com.getpcpanel.platform.LinuxBuild; import com.getpcpanel.util.ProcessHelper; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; -import lombok.RequiredArgsConstructor; +import jakarta.enterprise.event.Event; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import io.quarkus.runtime.Startup; import lombok.extern.log4j.Log4j2; @Log4j2 -@Component -@ConditionalOnPulseAudio -@RequiredArgsConstructor -public class PulseAudioEventListener extends Thread { - private final ApplicationEventPublisher eventPublisher; - private final ProcessHelper processHelper; +@Startup +@Singleton +@LinuxBuild +public class PulseAudioEventListener { + @Inject + Event eventBus; + @Inject + ProcessHelper processHelper; private final CircularFifoQueue latestEvents = new CircularFifoQueue<>(50); private final Pattern numberPattern = Pattern.compile("#(\\d+)"); - private boolean running = true; + private volatile boolean running = true; + private Thread thread; @PostConstruct public void init() { - setName("PulseAudio change listener"); - setDaemon(true); - start(); + thread = new Thread(this::run, "PulseAudio change listener"); + thread.setDaemon(true); + thread.start(); } @PreDestroy public void deInit() { running = false; + if (thread != null) { + thread.interrupt(); + } } - @Override - public void run() { + private void run() { while (running) { try { var process = processHelper.builder("pactl", "subscribe").start(); @@ -76,10 +83,10 @@ private void checkTrigger(String line) { , "Event 'remove' on sink-input" , "Event 'change' on sink-input")) { var m = numberPattern.matcher(line); - eventPublisher.publishEvent(new LinuxSessionChangedEvent(m.find() ? NumberUtils.toInt(m.group(1)) : null)); + eventBus.fire(new LinuxSessionChangedEvent(m.find() ? NumberUtils.toInt(m.group(1)) : null)); } if (StringUtils.containsAnyIgnoreCase(line, "Event 'new' on sink", "Event 'remove' on sink")) { - eventPublisher.publishEvent(new LinuxDeviceChangedEvent()); + eventBus.fire(new LinuxDeviceChangedEvent()); } } diff --git a/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/PulseAudioWrapper.java b/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/PulseAudioWrapper.java index f5a4025a..ef03d9e4 100644 --- a/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/PulseAudioWrapper.java +++ b/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/PulseAudioWrapper.java @@ -12,26 +12,27 @@ import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.math.NumberUtils; -import org.springframework.stereotype.Service; +import jakarta.inject.Inject; +import jakarta.enterprise.context.ApplicationScoped; import com.getpcpanel.cpp.MuteType; +import com.getpcpanel.platform.LinuxBuild; import com.getpcpanel.util.ProcessHelper; import lombok.Builder; -import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; import lombok.extern.log4j.Log4j2; import one.util.streamex.StreamEx; @Log4j2 -@Service -@ConditionalOnPulseAudio -@RequiredArgsConstructor +@ApplicationScoped +@LinuxBuild public class PulseAudioWrapper { public static final int NO_OP_IDX = -1; public static final int DEFAULT_DEVICE = -2; private static final Pattern pactlFirstLine = Pattern.compile("(.*) #(\\d+)"); - private final ProcessHelper processHelper; + @Inject + ProcessHelper processHelper; public static int volumeFtoI(float volume) { return Math.round(volume * 65536); @@ -41,7 +42,7 @@ public static float volumeItoF(int volume) { return volume / 65536f; } - public List getDevices() { + public List devices() { return StreamEx.of(execAndParse(InOutput.output)).append(execAndParse(InOutput.input)).toList(); } @@ -165,11 +166,14 @@ List getDebugOutput() { public record PulseAudioTarget(int index, boolean isDefault, Map metas, Map properties, InOutput type) { } - @RequiredArgsConstructor - enum InOutput { + enum InOutput { input("sources"), output("sinks"), session("sink-inputs"); private final String pulseType; + + InOutput(String pulseType) { + this.pulseType = pulseType; + } } private String muteTypeToMute(MuteType type) { diff --git a/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/SndCtrlPulseAudio.java b/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/SndCtrlPulseAudio.java index cc2985d7..c3b1f1de 100644 --- a/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/SndCtrlPulseAudio.java +++ b/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/SndCtrlPulseAudio.java @@ -13,14 +13,12 @@ import java.util.Set; import java.util.function.Function; -import javax.annotation.Nullable; import javax.annotation.concurrent.GuardedBy; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.math.NumberUtils; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; + +import javax.annotation.Nullable; import com.getpcpanel.cpp.AudioDevice; import com.getpcpanel.cpp.AudioSession; @@ -29,41 +27,58 @@ import com.getpcpanel.cpp.ISndCtrl; import com.getpcpanel.cpp.MuteType; import com.getpcpanel.cpp.linux.LinuxProcessHelper; +import com.getpcpanel.cpp.linux.pulseaudio.PulseAudioEventListener.LinuxDeviceChangedEvent; import com.getpcpanel.cpp.linux.pulseaudio.PulseAudioEventListener.LinuxSessionChangedEvent; +import com.getpcpanel.cpp.linux.pulseaudio.PulseAudioWrapper.InOutput; +import com.getpcpanel.cpp.linux.pulseaudio.PulseAudioWrapper.PulseAudioTarget; +import com.getpcpanel.platform.LinuxBuild; +import io.quarkus.runtime.StartupEvent; import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; import lombok.extern.log4j.Log4j2; import one.util.streamex.StreamEx; @Log4j2 -@Service -@ConditionalOnPulseAudio -@RequiredArgsConstructor +@ApplicationScoped +@LinuxBuild public class SndCtrlPulseAudio implements ISndCtrl { public static final String INPUT_PREFIX = "in_"; - private final PulseAudioWrapper cmd; - private final LinuxProcessHelper processHelper; - private final ApplicationEventPublisher eventPublisher; + @Inject PulseAudioWrapper cmd; + @Inject LinuxProcessHelper processHelper; + @Inject Event eventBus; @GuardedBy("devices") private final Map devices = new HashMap<>(); @GuardedBy("sessions") private final Set sessions = new HashSet<>(); @PostConstruct public void init() { - initDevices(); - initSessions(null); + initDevices(null); + synchronized (sessions) { + sessions.addAll(getSessionsFromCmd()); + } } - @EventListener(PulseAudioEventListener.LinuxDeviceChangedEvent.class) - public void initDevices() { + public void onStart(@Observes StartupEvent ev) { + Set snapshot; + synchronized (sessions) { + snapshot = new HashSet<>(sessions); + } + snapshot.stream() + .map(sess -> new AudioSessionEvent(sess, EventType.ADDED)) + .forEach(e -> eventBus.fire(e)); + } + + public void initDevices(@Observes @Nullable LinuxDeviceChangedEvent event) { synchronized (devices) { devices.clear(); StreamEx.of(getDevicesFromCmd()).mapToEntry(AudioDevice::id, Function.identity()).into(devices); } } - @EventListener - public void initSessions(@Nullable LinuxSessionChangedEvent event) { + public void initSessions(@Observes @Nullable LinuxSessionChangedEvent event) { synchronized (sessions) { var prevByIndex = StreamEx.of(sessions).mapToEntry(PulseAudioAudioSession::index).invert().toMap(); sessions.clear(); @@ -78,7 +93,7 @@ public void initSessions(@Nullable LinuxSessionChangedEvent event) { added.map(sess -> new AudioSessionEvent(sess, EventType.ADDED)) .append(removed.map(sess -> new AudioSessionEvent(sess, EventType.REMOVED))) .append(changed.map(sess -> new AudioSessionEvent(sess, EventType.CHANGED))) - .forEach(eventPublisher::publishEvent); + .forEach(e -> eventBus.fire(e)); } } @@ -102,7 +117,7 @@ public Map getDevicesMap() { } @Override - public Collection getDevices() { + public Collection devices() { synchronized (devices) { return StreamEx.ofValues(devices).select(AudioDevice.class).toSet(); } @@ -178,7 +193,7 @@ public void muteProcesses(Set fileName, MuteType mute) { @Override public @Nullable String getFocusApplication() { - return null; + return processHelper.getActiveProcess(); } @Override @@ -206,22 +221,22 @@ public List getRunningApplications() { } private Set getDevicesFromCmd() { - return StreamEx.of(cmd.getDevices()).mapPartial(this::toDevice).toSet(); + return StreamEx.of(cmd.devices()).mapPartial(this::toDevice).toSet(); } - private Optional toDevice(PulseAudioWrapper.PulseAudioTarget pa) { - var isOutput = pa.type() == PulseAudioWrapper.InOutput.output; + private Optional toDevice(PulseAudioTarget pa) { + var isOutput = pa.type() == InOutput.output; var name = pa.metas().get("Name"); if (StringUtils.isBlank(name)) { return Optional.empty(); } - return Optional.of(new PulseAudioAudioDevice(eventPublisher, pa.index(), pa.metas().get("Description"), (isOutput ? "" : INPUT_PREFIX) + pa.metas().get("Name"), pa.isDefault(), isOutput)); + return Optional.of(new PulseAudioAudioDevice(eventBus, pa.index(), pa.metas().get("Description"), (isOutput ? "" : INPUT_PREFIX) + pa.metas().get("Name"), pa.isDefault(), isOutput)); } private Set getSessionsFromCmd() { return StreamEx.of(cmd.getSessions()) .map(pa -> - new PulseAudioAudioSession(eventPublisher, + new PulseAudioAudioSession(eventBus, pa.index(), NumberUtils.toInt(pa.properties().get("application.process.id"), -1), new File(pa.properties().getOrDefault("application.process.binary", "/")), @@ -230,7 +245,7 @@ private Set getSessionsFromCmd() { .toSet(); } - float extractVolume(PulseAudioWrapper.PulseAudioTarget pa) { + float extractVolume(PulseAudioTarget pa) { var volumeStr = pa.metas().getOrDefault("Volume", "mono: 0 / 0% / -inf dB"); var outputParts = volumeStr.split(":", 2); if (outputParts.length < 2) { diff --git a/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/SndCtrlPulseAudioDebug.java b/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/SndCtrlPulseAudioDebug.java index f741d897..22f542f7 100644 --- a/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/SndCtrlPulseAudioDebug.java +++ b/src/main/java/com/getpcpanel/cpp/linux/pulseaudio/SndCtrlPulseAudioDebug.java @@ -3,19 +3,21 @@ import java.awt.Toolkit; import java.awt.datatransfer.StringSelection; -import org.springframework.stereotype.Service; +import com.getpcpanel.platform.LinuxBuild; +import jakarta.inject.Inject; +import jakarta.enterprise.context.ApplicationScoped; -import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import one.util.streamex.StreamEx; @Log4j2 -@Service -@ConditionalOnPulseAudio -@RequiredArgsConstructor +@ApplicationScoped +@LinuxBuild public class SndCtrlPulseAudioDebug { - private final PulseAudioWrapper paWrapper; - private final PulseAudioEventListener paEventListener; + @Inject + PulseAudioWrapper paWrapper; + @Inject + PulseAudioEventListener paEventListener; public void copyDebugOutput() { var output = StreamEx.of(paWrapper.getDebugOutput()) diff --git a/src/main/java/com/getpcpanel/cpp/windows/SndCtrlWindows.java b/src/main/java/com/getpcpanel/cpp/windows/SndCtrlWindows.java index 280fbacf..f698326b 100644 --- a/src/main/java/com/getpcpanel/cpp/windows/SndCtrlWindows.java +++ b/src/main/java/com/getpcpanel/cpp/windows/SndCtrlWindows.java @@ -12,8 +12,6 @@ import javax.annotation.concurrent.GuardedBy; import org.apache.commons.lang3.StringUtils; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; import com.getpcpanel.cpp.AudioDevice; import com.getpcpanel.cpp.AudioDeviceEvent; @@ -23,22 +21,24 @@ import com.getpcpanel.cpp.ISndCtrl; import com.getpcpanel.cpp.MuteType; import com.getpcpanel.cpp.Role; -import com.getpcpanel.spring.ConditionalOnWindows; +import com.getpcpanel.platform.WindowsBuild; import com.getpcpanel.util.ExtractUtil; import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; +import jakarta.inject.Inject; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import one.util.streamex.StreamEx; @Log4j2 -@Service -@ConditionalOnWindows -@RequiredArgsConstructor +@ApplicationScoped +@WindowsBuild @SuppressWarnings("unused") // Methods are called from JNI public class SndCtrlWindows implements ISndCtrl { - private final ExtractUtil extractUtil; - private final ApplicationEventPublisher eventPublisher; + @Inject ExtractUtil extractUtil; + @Inject Event eventBus; @GuardedBy("defaults") private final Map defaults = new HashMap<>(); @GuardedBy("devices") private final Map devices = new HashMap<>(); @@ -75,7 +75,7 @@ public Map getDevicesMap() { } @Override - public Collection getDevices() { + public Collection devices() { synchronized (devices) { return Collections.unmodifiableCollection(devices.values()); } @@ -202,38 +202,44 @@ public String defaultRecorder() { } } - private AudioDevice deviceAdded(String name, String id, float volume, boolean muted, int dataFlow) { - var result = new WindowsAudioDevice(eventPublisher, name, id).volume(volume).muted(muted).dataflow(DataFlow.from(dataFlow)); + public AudioDevice deviceAdded(String name, String id, float volume, boolean muted, int dataFlow) { + var result = new WindowsAudioDevice(eventBus, name, id).volume(volume).muted(muted).dataflow(DataFlow.from(dataFlow)); synchronized (devices) { devices.put(id, result); } log.trace("Device added: {}", result); - eventPublisher.publishEvent(new AudioDeviceEvent(result, EventType.ADDED)); + fireEvent(new AudioDeviceEvent(result, EventType.ADDED)); return result; } - private void deviceRemoved(String id) { + private void fireEvent(Object result) { + if (eventBus != null) { + eventBus.fire(result); + } + } + + public void deviceRemoved(String id) { log.trace("Device removed: {}", id); AudioDevice removed; synchronized (devices) { removed = devices.remove(id); } if (removed != null) { - eventPublisher.publishEvent(new AudioDeviceEvent(removed, EventType.REMOVED)); + fireEvent(new AudioDeviceEvent(removed, EventType.REMOVED)); } } - private void setDefaultDevice(String id, int dataFlow, int role) { + public void setDefaultDevice(String id, int dataFlow, int role) { synchronized (defaults) { defaults.put(DefaultFor.of(dataFlow, role), id); } log.trace("Default changed: {}: {}", DefaultFor.of(dataFlow, role), id); } - private void focusChanged(String to) { + public void focusChanged(String to) { log.trace("Focus changed to {}", to); - eventPublisher.publishEvent(new WindowFocusChangedEvent(to)); + fireEvent(new WindowFocusChangedEvent(to)); } public Map getDefaults() { @@ -266,11 +272,13 @@ public enum DefaultFor { communicationPlayback(DataFlow.dfRender.ordinal(), Role.roleCommunications.ordinal()), communicationRecord(DataFlow.dfCapture.ordinal(), Role.roleCommunications.ordinal()); - public static @Nullable DefaultFor of(DataFlow dataFlow, Role role) { + @Nullable + public static DefaultFor of(DataFlow dataFlow, Role role) { return of(dataFlow.ordinal(), role.ordinal()); } - public static @Nullable DefaultFor of(int dataFlow, int role) { + @Nullable + public static DefaultFor of(int dataFlow, int role) { return StreamEx.of(values()).findFirst(d -> d.dataFlow == dataFlow && d.role == role).orElse(null); } diff --git a/src/main/java/com/getpcpanel/cpp/windows/WindowsAudioDevice.java b/src/main/java/com/getpcpanel/cpp/windows/WindowsAudioDevice.java index c9a808c8..b89f67b8 100644 --- a/src/main/java/com/getpcpanel/cpp/windows/WindowsAudioDevice.java +++ b/src/main/java/com/getpcpanel/cpp/windows/WindowsAudioDevice.java @@ -4,7 +4,9 @@ import java.util.HashMap; import java.util.Map; -import org.springframework.context.ApplicationEventPublisher; +import jakarta.enterprise.event.Event; + +import com.fasterxml.jackson.annotation.JsonIgnore; import com.getpcpanel.cpp.AudioDevice; import com.getpcpanel.cpp.AudioSession; @@ -19,19 +21,20 @@ public class WindowsAudioDevice extends AudioDevice { private final transient Map sessions = new HashMap<>(); // pid -> pointer_addr -> session - public WindowsAudioDevice(ApplicationEventPublisher eventPublisher, String name, String id) { - super(eventPublisher, name, id); + public WindowsAudioDevice(Event eventBus, String name, String id) { + super(eventBus, name, id); } + @JsonIgnore public Map getSessions() { return sessions; } public AudioSession addSession(long pointer, int pid, String name, String title, String icon, float volume, boolean muted) { log.debug("Add device session: {} {} {} {} {} {} {}", pointer, pid, name, title, icon, volume, muted); - var result = sessions.computeIfAbsent(pid, p -> new WindowsAudioSession(this, eventPublisher, pid, new File(name), title, icon, volume, muted)); + var result = sessions.computeIfAbsent(pid, p -> new WindowsAudioSession(this, eventBus, pid, new File(name), title, icon, volume, muted)); result.pointers().add(pointer); - eventPublisher.publishEvent(new AudioSessionEvent(result, EventType.ADDED)); + eventBus.fire(new AudioSessionEvent(result, EventType.ADDED)); return result; } @@ -46,7 +49,7 @@ public void removeSession(long pointer, int pid) { if (session.pointers().isEmpty()) { log.debug("Session removed: {} ({})", pid, pointer); sessions.remove(pid); - eventPublisher.publishEvent(new AudioSessionEvent(session, EventType.REMOVED)); + eventBus.fire(new AudioSessionEvent(session, EventType.REMOVED)); } } diff --git a/src/main/java/com/getpcpanel/cpp/windows/WindowsAudioSession.java b/src/main/java/com/getpcpanel/cpp/windows/WindowsAudioSession.java index 9cd0d5d9..ef18ee4b 100644 --- a/src/main/java/com/getpcpanel/cpp/windows/WindowsAudioSession.java +++ b/src/main/java/com/getpcpanel/cpp/windows/WindowsAudioSession.java @@ -4,7 +4,9 @@ import java.util.HashSet; import java.util.Set; -import org.springframework.context.ApplicationEventPublisher; +import jakarta.enterprise.event.Event; + +import com.fasterxml.jackson.annotation.JsonIgnore; import com.getpcpanel.cpp.AudioDevice; import com.getpcpanel.cpp.AudioSession; @@ -16,12 +18,12 @@ @Getter @EqualsAndHashCode(callSuper = true) public class WindowsAudioSession extends AudioSession { - @ToString.Exclude private final AudioDevice device; - private final Set pointers = new HashSet<>(); + @JsonIgnore @ToString.Exclude private final AudioDevice device; + @JsonIgnore private final Set pointers = new HashSet<>(); - public WindowsAudioSession(AudioDevice device, ApplicationEventPublisher eventPublisher, int pid, File executable, String title, String icon, + public WindowsAudioSession(AudioDevice device, Event eventBus, int pid, File executable, String title, String icon, float volume, boolean muted) { - super(eventPublisher, pid, executable, title, icon, volume, muted); + super(eventBus, pid, executable, title, icon, volume, muted); this.device = device; } } diff --git a/src/main/java/com/getpcpanel/device/Device.java b/src/main/java/com/getpcpanel/device/Device.java index 68e3d940..7f94d7b8 100644 --- a/src/main/java/com/getpcpanel/device/Device.java +++ b/src/main/java/com/getpcpanel/device/Device.java @@ -1,268 +1,36 @@ package com.getpcpanel.device; -import javax.annotation.Nullable; - -import org.apache.commons.lang3.StringUtils; -import org.springframework.context.ApplicationEventPublisher; - import com.getpcpanel.commands.IconService; -import com.getpcpanel.commands.command.CommandVolumeFocus; import com.getpcpanel.hid.OutputInterpreter; import com.getpcpanel.profile.DeviceSave; -import com.getpcpanel.profile.LightingConfig; import com.getpcpanel.profile.Profile; import com.getpcpanel.profile.SaveService; -import com.getpcpanel.ui.FxHelper; -import com.getpcpanel.ui.LightingChangedToDefaultEvent; -import com.getpcpanel.ui.LimitedTextField; -import com.getpcpanel.util.Images; +import com.getpcpanel.profile.dto.LightingConfig; -import javafx.application.Platform; -import javafx.collections.FXCollections; -import javafx.geometry.Pos; -import javafx.scene.Node; -import javafx.scene.control.Button; -import javafx.scene.control.ComboBox; -import javafx.scene.control.ContentDisplay; -import javafx.scene.control.ContextMenu; -import javafx.scene.control.ListCell; -import javafx.scene.control.MenuItem; -import javafx.scene.image.Image; -import javafx.scene.image.ImageView; -import javafx.scene.input.KeyCode; -import javafx.scene.input.KeyEvent; -import javafx.scene.input.MouseButton; -import javafx.scene.input.MouseEvent; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Pane; -import javafx.scene.shape.SVGPath; -import javafx.stage.Stage; -import lombok.AccessLevel; import lombok.Getter; import lombok.extern.log4j.Log4j2; -import one.util.streamex.StreamEx; @Log4j2 public abstract class Device { - @Getter(AccessLevel.PROTECTED) private final FxHelper fxHelper; private final SaveService saveService; private final OutputInterpreter outputInterpreter; private final IconService iconService; - private final ApplicationEventPublisher eventPublisher; - @Getter private HBox profileMenu; - private ComboBox profiles; @Getter protected String serialNumber; protected DeviceSave save; private LightingConfig lightingConfig; - protected Device(FxHelper fxHelper, SaveService saveService, OutputInterpreter outputInterpreter, IconService iconService, ApplicationEventPublisher eventPublisher, String serialNum, - DeviceSave deviceSave) { - this.fxHelper = fxHelper; + protected Device(SaveService saveService, OutputInterpreter outputInterpreter, IconService iconService, String serialNum, DeviceSave deviceSave) { this.saveService = saveService; this.outputInterpreter = outputInterpreter; this.iconService = iconService; - this.eventPublisher = eventPublisher; serialNumber = serialNum; save = deviceSave; - initProfileMenu(); } protected void postInit() { - updateAllImages(); - } - - private void initProfileMenu() { - profileMenu = new HBox(); - profileMenu.setAlignment(Pos.CENTER_RIGHT); - profiles = new ComboBox<>(FXCollections.observableArrayList(save.getProfiles())); - profiles.setPrefWidth(400.0D); - profiles.getSelectionModel().select(currentProfile()); - var textfield = new LimitedTextField(10); - profiles.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, newValue) -> { - if (newValue == null) - return; - log.debug("change"); - var name = newValue.getName(); - updateCurrentProfileName(name); - }); - var buttonCell = new ListCell() { - @Override - protected void updateItem(Profile item, boolean btl) { - super.updateItem(item, btl); - setGraphic(null); - if (item != null) - setText(item.getName()); - } - }; - textfield.setOnAction(c -> { - var p = buttonCell.getItem(); - var oldName = p.getName(); - var newName = textfield.getText(); - buttonCell.setGraphic(null); - if (save.getProfile(newName).isPresent()) { - buttonCell.setText(oldName); - return; - } - p.setName(newName); - buttonCell.setText(newName); - profiles.getItems().set(profiles.getItems().indexOf(p), p); - saveService.save(); - }); - textfield.focusedProperty().addListener((arg, oldVal, newVal) -> { - if (!newVal) { - buttonCell.setGraphic(null); - buttonCell.setText(buttonCell.getItem().getName()); - } - }); - textfield.addEventFilter(KeyEvent.KEY_PRESSED, event -> { - if (event.getCode() == KeyCode.ESCAPE) { - buttonCell.setGraphic(null); - buttonCell.setText(buttonCell.getItem().getName()); - } - }); - profiles.setButtonCell(buttonCell); - profiles.addEventFilter(MouseEvent.MOUSE_RELEASED, event -> { - if (event.getButton() == MouseButton.SECONDARY) { - event.consume(); - var rename = new MenuItem("Rename"); - var delete = new MenuItem("Delete"); - rename.setOnAction(c -> { - buttonCell.setGraphic(textfield); - textfield.requestFocus(); - textfield.setText(buttonCell.getText()); - textfield.selectAll(); - buttonCell.setText(""); - }); - delete.setOnAction(c -> { - save.getProfiles().remove(buttonCell.getItem()); - profiles.getItems().remove(buttonCell.getItem()); - if (profiles.getValue() == null) - profiles.getSelectionModel().select(0); - }); - if (profiles.getItems().size() <= 1) - delete.setDisable(true); - var cm = new ContextMenu(rename, delete); - profiles.setContextMenu(cm); - } else if (event.getButton() == MouseButton.PRIMARY) { - buttonCell.getGraphic(); - } - }); - - profileMenu.getChildren().add(buildAddButton()); - profileMenu.getChildren().add(buildSettingsButton()); - profileMenu.getChildren().addAll(profiles); - } - - private void updateCurrentProfileName(String name) { - var profile = save.setCurrentProfile(name); - if (profile.isEmpty()) - return; - setLighting(profile.get().getLightingConfig(), true); - saveService.save(); - eventPublisher.publishEvent(LightingChangedToDefaultEvent.INSTANCE); } public void focusChanged(String from, String to) { - if (!StringUtils.equals(from, to) && switchForApplication(to)) - return; - - switchAwayFromApplication(from); - } - - private boolean switchForApplication(String to) { - var result = new boolean[] { false }; - save.getProfiles() - .stream() - .filter(p -> StreamEx.of(p.getActivateApplications()).anyMatch(i -> StringUtils.equalsIgnoreCase(i, to))) - .findFirst() - .ifPresent(p -> { - Platform.runLater(() -> profiles.getSelectionModel().select(p)); - result[0] = true; - }); - return result[0]; - } - - private void switchAwayFromApplication(String from) { - var mainProfile = StreamEx.of(save.getProfiles()).findFirst(Profile::isMainProfile); - var item = profiles.getSelectionModel().getSelectedItem(); - if (item == null || !item.isFocusBackOnLost() || mainProfile.isEmpty()) { - return; - } - if (StreamEx.of(item.getActivateApplications()).anyMatch(a -> StringUtils.equalsIgnoreCase(a, from))) { - Platform.runLater(() -> profiles.getSelectionModel().select(mainProfile.get())); - } - } - - private Button buildAddButton() { - var addButton = new Button(); - var svgCode = "M28,14H18V4c0-1.104-0.896-2-2-2s-2,0.896-2,2v10H4c-1.104,0-2,0.896-2,2s0.896,2,2,2h10v10c0,1.104,0.896,2,2,2 s2-0.896,2-2V18h10c1.104,0,2-0.896,2-2S29.104,14,28,14z"; - var path = new SVGPath(); - path.setStyle("-fx-fill:white;"); - path.setContent(svgCode); - path.setScaleX(0.7D); - path.setScaleY(0.7D); - addButton.setGraphic(path); - addButton.setContentDisplay(ContentDisplay.GRAPHIC_ONLY); - addButton.setOnAction(c -> { - String newName; - //noinspection ForLoopWithMissingComponent - for (var i = 1; ; i++) { - newName = "profile " + i; - if (save.getProfile(newName).isEmpty()) - break; - } - var newProfile = new Profile(newName, getDeviceType()); - save.getProfiles().add(newProfile); - profiles.getItems().add(newProfile); - profiles.getSelectionModel().select(newProfile); - }); - addButton.setPrefSize(44.0D, 44.0D); - return addButton; - } - - private Button buildSettingsButton() { - var settingSvg = "M24.38,10.175l-2.231-0.268c-0.228-0.851-0.562-1.655-0.992-2.401l1.387-1.763c0.212-0.271,0.188-0.69-0.057-0.934" + - "l-2.299-2.3c-0.242-0.243-0.662-0.269-0.934-0.057l-1.766,1.389c-0.743-0.43-1.547-0.764-2.396-0.99L14.825,0.62" + - "C14.784,0.279,14.469,0,14.125,0h-3.252c-0.344,0-0.659,0.279-0.699,0.62L9.906,2.851c-0.85,0.227-1.655,0.562-2.398,0.991" + - "L5.743,2.455c-0.27-0.212-0.69-0.187-0.933,0.056L2.51,4.812C2.268,5.054,2.243,5.474,2.456,5.746L3.842,7.51" + - "c-0.43,0.744-0.764,1.549-0.991,2.4l-2.23,0.267C0.28,10.217,0,10.532,0,10.877v3.252c0,0.344,0.279,0.657,0.621,0.699l2.231,0.268" + - "c0.228,0.848,0.561,1.652,0.991,2.396l-1.386,1.766c-0.211,0.271-0.187,0.69,0.057,0.934l2.296,2.301" + - "c0.243,0.242,0.663,0.269,0.933,0.057l1.766-1.39c0.744,0.43,1.548,0.765,2.398,0.991l0.268,2.23" + - "c0.041,0.342,0.355,0.62,0.699,0.62h3.252c0.345,0,0.659-0.278,0.699-0.62l0.268-2.23c0.851-0.228,1.655-0.562,2.398-0.991" + - "l1.766,1.387c0.271,0.212,0.69,0.187,0.933-0.056l2.299-2.301c0.244-0.242,0.269-0.662,0.056-0.935l-1.388-1.764" + - "c0.431-0.744,0.764-1.548,0.992-2.397l2.23-0.268C24.721,14.785,25,14.473,25,14.127v-3.252" + - "C25.001,10.529,24.723,10.216,24.38,10.175z M12.501,18.75c-3.452,0-6.25-2.798-6.25-6.25s2.798-6.25,6.25-6.25" + - "s6.25,2.798,6.25,6.25S15.954,18.75,12.501,18.75z"; - var setPath = new SVGPath(); - setPath.setStyle("-fx-fill:white;"); - setPath.setContent(settingSvg); - setPath.setScaleX(.9); - setPath.setScaleY(.9); - - var settingsButton = new Button(); - settingsButton.setGraphic(setPath); - settingsButton.setContentDisplay(ContentDisplay.GRAPHIC_ONLY); - settingsButton.setOnAction(c -> { - try { - var stage = new Stage(); - var selection = profiles.getSelectionModel().getSelectedItem(); - fxHelper.buildProfileSettingsDialog(save, selection).start(stage); - stage.setOnHidden(e -> Platform.runLater(() -> { - profiles.getButtonCell().setText(selection.getName()); - if (profiles.getSelectionModel().getSelectedItem().equals(selection)) { - updateCurrentProfileName(selection.getName()); - } - })); - } catch (Exception e) { - log.error("Unable to load profile settings dialog", e); - } - }); - settingsButton.setPrefSize(44.0D, 44.0D); - return settingsButton; - } - - public void setProfile(@Nullable String name) { - save.getProfile(name).ifPresent(profiles::setValue); } public String getDisplayName() { @@ -273,21 +41,15 @@ public void setDisplayName(String name) { save.setDisplayName(name); } - /** - * This will have muted colors for mute-override-controls - */ - public LightingConfig getLightingConfig() { + public LightingConfig lightingConfig() { if (lightingConfig == null) { - lightingConfig = currentProfile().getLightingConfig(); + lightingConfig = currentProfile().lightingConfig(); } return lightingConfig; } - /** - * Will have the original colors, even when mute-override is active. - */ public LightingConfig getSavedLightingConfig() { - return currentProfile().getLightingConfig(); + return currentProfile().lightingConfig(); } public void setLighting(LightingConfig config, boolean priority) { @@ -301,87 +63,25 @@ public void setSavedLighting(LightingConfig config) { private void doSetLighting(LightingConfig config, boolean priority) { lightingConfig = config; - if (config == null) { - config = LightingConfig.defaultLightingConfig(getDeviceType()); + config = LightingConfig.defaultLightingConfig(deviceType()); saveService.save(); } try { - var finalConfig = config; - Platform.runLater(() -> showLightingConfigToUI(finalConfig)); - //noinspection NestedTryStatement - try { - outputInterpreter.sendLightingConfig(getSerialNumber(), getDeviceType(), config, priority); - } catch (Exception e) { - log.error("Unable to send lighting config", e); - } + outputInterpreter.sendLightingConfig(serialNumber, deviceType(), config, priority); } catch (Exception e) { - log.error("Unable to set lighting", e); - setLighting(LightingConfig.defaultLightingConfig(getDeviceType()), priority); + log.error("Unable to send lighting config", e); + setLighting(LightingConfig.defaultLightingConfig(deviceType()), priority); } } - protected SVGPath getLightingImage() { - return Images.light(); - } - public void focusApplicationChanged() { - var images = getKnobImages(); - for (var i = 0; i < images.length; i++) { - var idx = i; - var dialData = currentProfile().getDialData(i); - if (dialData != null) { - dialData.getCommand(CommandVolumeFocus.class).ifPresent(c -> determineAndSetImage(idx)); - } - } } public void saveChanged() { - updateAllImages(); - } - - protected void updateAllImages() { - for (var i = 0; i < getKnobImages().length; i++) { - determineAndSetImage(i); - } - } - - private void determineAndSetImage(int dial) { - var images = getKnobImages(); - if (!isShowIcons()) { - images[dial].setImage(null); - return; - } - - var cmd = currentProfile().getDialData(dial); - var settings = currentProfile().getKnobSettings(dial); - - var image = iconService.getImageFrom(cmd, settings); - images[dial].setImage(iconService.isDefault(image) ? null : image); } - protected ImageView buildKnobImageView() { - var result = new ImageView(); - result.setOpacity(.4); - result.setMouseTransparent(true); - return result; - } - - private boolean isShowIcons() { - return saveService.get().isMainUIIcons(); - } - - protected abstract ImageView[] getKnobImages(); - - public abstract Pane getDevicePane(); - - public abstract Node getLabel(); - - public abstract Button getLightingButton(); - - public abstract Image getPreviewImage(); - - public abstract DeviceType getDeviceType(); + public abstract DeviceType deviceType(); public abstract void setKnobRotation(int paramInt1, int paramInt2); @@ -389,15 +89,14 @@ private boolean isShowIcons() { public abstract void setButtonPressed(int paramInt, boolean paramBoolean); - public abstract void closeDialogs(); - - public abstract void showLightingConfigToUI(LightingConfig paramLightingConfig); - public void disconnected() { - closeDialogs(); + } + + public void setProfile(String profileName) { + save.setCurrentProfileName(profileName); } public Profile currentProfile() { - return save.ensureCurrentProfile(getDeviceType()); + return save.ensureCurrentProfile(deviceType()); } } diff --git a/src/main/java/com/getpcpanel/device/DeviceFactory.java b/src/main/java/com/getpcpanel/device/DeviceFactory.java index a28097e7..381277ff 100644 --- a/src/main/java/com/getpcpanel/device/DeviceFactory.java +++ b/src/main/java/com/getpcpanel/device/DeviceFactory.java @@ -1,40 +1,34 @@ package com.getpcpanel.device; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.annotation.Configuration; -import org.springframework.stereotype.Service; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import com.getpcpanel.commands.IconService; import com.getpcpanel.hid.InputInterpreter; import com.getpcpanel.hid.OutputInterpreter; import com.getpcpanel.profile.DeviceSave; import com.getpcpanel.profile.SaveService; -import com.getpcpanel.ui.FxHelper; import com.getpcpanel.util.coloroverride.OverrideColorService; import lombok.RequiredArgsConstructor; -@Service -@Configuration -@RequiredArgsConstructor +@ApplicationScoped public class DeviceFactory { - private final FxHelper fxHelper; - private final InputInterpreter inputInterpreter; - private final SaveService saveService; - private final OutputInterpreter outputInterpreter; - private final IconService iconService; - private final ApplicationEventPublisher eventPublisher; - private final OverrideColorService overrideColorService; + @Inject InputInterpreter inputInterpreter; + @Inject SaveService saveService; + @Inject OutputInterpreter outputInterpreter; + @Inject IconService iconService; + @Inject OverrideColorService overrideColorService; - public PCPanelRGBUI buildRgb(String serialNum, DeviceSave deviceSave) { - return new PCPanelRGBUI(fxHelper, inputInterpreter, saveService, outputInterpreter, iconService, eventPublisher, overrideColorService, deviceSave, serialNum); + public Device buildRgb(String serialNum, DeviceSave deviceSave) { + return new PCPanelRGBDevice(inputInterpreter, saveService, outputInterpreter, iconService, overrideColorService, deviceSave, serialNum); } - public PCPanelMiniUI buildMini(String serialNum, DeviceSave deviceSave) { - return new PCPanelMiniUI(fxHelper, inputInterpreter, saveService, outputInterpreter, iconService, eventPublisher, overrideColorService, serialNum, deviceSave); + public Device buildMini(String serialNum, DeviceSave deviceSave) { + return new PCPanelMiniDevice(inputInterpreter, saveService, outputInterpreter, iconService, overrideColorService, serialNum, deviceSave); } - public PCPanelProUI buildPro(String serialNum, DeviceSave deviceSave) { - return new PCPanelProUI(fxHelper, inputInterpreter, saveService, outputInterpreter, iconService, eventPublisher, overrideColorService, serialNum, deviceSave); + public Device buildPro(String serialNum, DeviceSave deviceSave) { + return new PCPanelProDevice(inputInterpreter, saveService, outputInterpreter, iconService, overrideColorService, serialNum, deviceSave); } } diff --git a/src/main/java/com/getpcpanel/device/GlobalBrightnessChangedEvent.java b/src/main/java/com/getpcpanel/device/GlobalBrightnessChangedEvent.java new file mode 100644 index 00000000..bcbe3def --- /dev/null +++ b/src/main/java/com/getpcpanel/device/GlobalBrightnessChangedEvent.java @@ -0,0 +1,7 @@ +package com.getpcpanel.device; + +/** + * Fired when the global brightness setting changes. + */ +public record GlobalBrightnessChangedEvent(String serialNum, int brightness) { +} diff --git a/src/main/java/com/getpcpanel/device/PCPanelMiniDevice.java b/src/main/java/com/getpcpanel/device/PCPanelMiniDevice.java new file mode 100644 index 00000000..205abd9d --- /dev/null +++ b/src/main/java/com/getpcpanel/device/PCPanelMiniDevice.java @@ -0,0 +1,22 @@ +package com.getpcpanel.device; + +import com.getpcpanel.commands.IconService; +import com.getpcpanel.hid.InputInterpreter; +import com.getpcpanel.hid.OutputInterpreter; +import com.getpcpanel.profile.DeviceSave; +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.util.coloroverride.OverrideColorService; + +public class PCPanelMiniDevice extends Device { + private final int[] knobRotations = new int[DeviceType.PCPANEL_MINI.getAnalogCount()]; + + public PCPanelMiniDevice(InputInterpreter inputInterpreter, SaveService saveService, OutputInterpreter outputInterpreter, + IconService iconService, OverrideColorService overrideColorService, String serialNum, DeviceSave deviceSave) { + super(saveService, outputInterpreter, iconService, serialNum, deviceSave); + } + + @Override public DeviceType deviceType() { return DeviceType.PCPANEL_MINI; } + @Override public void setKnobRotation(int knob, int rotation) { knobRotations[knob] = rotation; } + @Override public int getKnobRotation(int knob) { return knobRotations[knob]; } + @Override public void setButtonPressed(int button, boolean pressed) {} +} diff --git a/src/main/java/com/getpcpanel/device/PCPanelMiniUI.java b/src/main/java/com/getpcpanel/device/PCPanelMiniUI.java deleted file mode 100644 index 73e06ea8..00000000 --- a/src/main/java/com/getpcpanel/device/PCPanelMiniUI.java +++ /dev/null @@ -1,274 +0,0 @@ -package com.getpcpanel.device; - -import java.io.IOException; -import java.util.Objects; - -import org.springframework.context.ApplicationEventPublisher; - -import com.getpcpanel.commands.IconService; -import com.getpcpanel.hid.DeviceCommunicationHandler; -import com.getpcpanel.hid.InputInterpreter; -import com.getpcpanel.hid.OutputInterpreter; -import com.getpcpanel.profile.DeviceSave; -import com.getpcpanel.profile.LightingConfig; -import com.getpcpanel.profile.LightingConfig.LightingMode; -import com.getpcpanel.profile.SaveService; -import com.getpcpanel.profile.SingleKnobLightingConfig; -import com.getpcpanel.profile.SingleKnobLightingConfig.SINGLE_KNOB_MODE; -import com.getpcpanel.ui.FxHelper; -import com.getpcpanel.ui.HomePage; -import com.getpcpanel.util.Util; -import com.getpcpanel.util.coloroverride.OverrideColorService; - -import javafx.application.Platform; -import javafx.fxml.FXML; -import javafx.scene.Node; -import javafx.scene.control.Button; -import javafx.scene.control.ContentDisplay; -import javafx.scene.control.Label; -import javafx.scene.image.Image; -import javafx.scene.image.ImageView; -import javafx.scene.input.MouseButton; -import javafx.scene.layout.Pane; -import javafx.scene.layout.Region; -import javafx.scene.paint.Color; -import javafx.scene.paint.Paint; -import javafx.scene.shape.Shape; -import javafx.scene.text.Font; -import javafx.stage.Stage; -import lombok.extern.log4j.Log4j2; - -@Log4j2 -public class PCPanelMiniUI extends Device { - private final InputInterpreter inputInterpreter; - private final OverrideColorService overrideColorService; - - public static final int KNOB_COUNT = 4; - private static final double MAX_ANALOG_VALUE = 100; - @FXML private Pane lightPanes; - @FXML private Pane panelPane; - private Label label; - private Button lightingButton; - private final Button[] knobs = new Button[KNOB_COUNT]; - private final int[] analogValue = new int[KNOB_COUNT]; - private final ImageView[] images = new ImageView[KNOB_COUNT]; - private static final Image previewImage = new Image(Objects.requireNonNull(PCPanelMiniUI.class.getResource("/assets/PCPanelMini/preview.png")).toExternalForm()); - private Stage childDialogStage; - - public PCPanelMiniUI(FxHelper fxHelper, InputInterpreter inputInterpreter, SaveService saveService, OutputInterpreter outputInterpreter, IconService iconService, ApplicationEventPublisher eventPublisher, OverrideColorService overrideColorService, - String serialNum, DeviceSave deviceSave) { - super(fxHelper, saveService, outputInterpreter, iconService, eventPublisher, serialNum, deviceSave); - this.inputInterpreter = inputInterpreter; - this.overrideColorService = overrideColorService; - var loader = getFxHelper().getLoader(getClass().getResource("/assets/PCPanelMini/PCPanelMini.fxml")); - loader.setController(this); - try { - Pane pane = loader.load(); - initButtons(); - initLabel(); - initLightingButton(); - pane.getStylesheets().addAll(Objects.requireNonNull(getClass().getResource("/assets/PCPanelMini/PCPanelMini.css")).toExternalForm()); - } catch (IOException e) { - log.error("Unable to initialize ui", e); - } - postInit(); - } - - @Override - public Node getLabel() { - return label; - } - - @Override - public Pane getDevicePane() { - return panelPane; - } - - private void rotateKnob(int knob, int val) { - if (knob >= analogValue.length) { - log.error("Getting knob {} value ({}), but the amount of knobs is less: {}", knob, val, analogValue.length); - return; - } - analogValue[knob] = val; - if (getLightingConfig().getLightingMode() == LightingMode.CUSTOM) - showLightingConfigToUI(getLightingConfig()); - ((Region) knobs[knob].getGraphic()).getChildrenUnmodifiable().get(3).setRotate(Util.analogValueToRotation(val)); - } - - @Override - public int getKnobRotation(int knob) { - return analogValue[knob]; - } - - private void initLabel() { - label = new Label("PCPANEL MINI"); - var f = Font.loadFont(getClass().getResourceAsStream("/assets/apex-mk2.regular.otf"), 50.0D); - label.setFont(f); - label.setUnderline(true); - label.setTextFill(Paint.valueOf("white")); - } - - private void initLightingButton() { - lightingButton = new Button("Lighting", getLightingImage()); - lightingButton.setStyle("-fx-background-color: transparent;"); - lightingButton.setContentDisplay(ContentDisplay.TOP); - lightingButton.setMinHeight(100.0D); - lightingButton.setOnAction(e -> { - childDialogStage = new Stage(); - getFxHelper().buildMiniLightingDialog(this).start(childDialogStage); - }); - } - - private void initButtons() throws IOException { - var xPos = 56.3D; - var yPos = 133.4D; - var xDelta = 115.0D; - var buttonSize = 80; - for (var i = 0; i < KNOB_COUNT; i++) { - var loader = getFxHelper().getLoader(getClass().getResource("/assets/PCPanelMini/knob.fxml")); - Node nx = loader.load(); - images[i] = buildKnobImageView(); - knobs[i] = new Button("", nx); - knobs[i].setId("dial_button"); - knobs[i].setContentDisplay(ContentDisplay.CENTER); - knobs[i].setMinSize(buttonSize, buttonSize); - knobs[i].setMaxSize(buttonSize, buttonSize); - knobs[i].setLayoutX(xPos); - knobs[i].setLayoutY(yPos); - knobs[i].setScaleX(1.2D); - knobs[i].setScaleY(1.2D); - - images[i].setLayoutX(xPos + 5); - images[i].setLayoutY(yPos + 5); - images[i].setFitWidth(70); - images[i].setFitHeight(70); - - var knob = i; - knobs[i].setOnAction(e -> { - HomePage.showHint(false); - var bm = getFxHelper().buildBasicMacro(this, knob); - try { - childDialogStage = new Stage(); - bm.start(childDialogStage); - } catch (Exception ex) { - log.error("Unable to init button", ex); - } - }); - var idx = i; - knobs[i].setOnMouseClicked(c -> { - if (c.getButton() == MouseButton.MIDDLE) { - try { - inputInterpreter.onButtonPress(new DeviceCommunicationHandler.ButtonPressEvent(getSerialNumber(), knob, true)); - } catch (IOException e1) { - log.error("Unable to handle button press", e1); - } - try { - inputInterpreter.onButtonPress(new DeviceCommunicationHandler.ButtonPressEvent(getSerialNumber(), knob, false)); - } catch (IOException e1) { - log.error("Unable to handle button up", e1); - } - } else if (c.getButton() == MouseButton.SECONDARY) { - getFxHelper().buildMiniLightingDialog(this).select(idx).start(new Stage()); - } - }); - panelPane.getChildren().add(knobs[i]); - panelPane.getChildren().add(images[i]); - xPos += xDelta; - } - } - - public String toString() { - return getDisplayName(); - } - - @Override - public Image getPreviewImage() { - return previewImage; - } - - private int getKnobCount() { - return KNOB_COUNT; - } - - @Override - public void setKnobRotation(int knob, int value) { - Platform.runLater(() -> rotateKnob(knob, value)); - } - - @Override - public void setButtonPressed(int knob, boolean pressed) { - Platform.runLater(() -> knobs[knob].setOpacity(pressed ? 0.5D : 1.0D)); - } - - private void setKnobUIColorHex(int knob, String color) { - var lightPane = (Shape) lightPanes.getChildren().get(knob); - lightPane.setFill(Paint.valueOf(color)); - } - - private void setKnobUIColor(int knob, Paint color) { - var lightPane = (Shape) lightPanes.getChildren().get(knob); - lightPane.setFill(color); - } - - private void setAllKnobUIColor(Paint color) { - for (var i = 0; i < getKnobCount(); i++) { - setKnobUIColor(i, color); - } - } - - @Override - public void closeDialogs() { - if (childDialogStage != null && childDialogStage.isShowing()) - childDialogStage.close(); - } - - @Override - public Button getLightingButton() { - return lightingButton; - } - - @Override - public DeviceType getDeviceType() { - return DeviceType.PCPANEL_MINI; - } - - @Override - public void showLightingConfigToUI(LightingConfig config) { - var mode = config.getLightingMode(); - if (mode == LightingMode.ALL_COLOR) { - setAllKnobUIColor(Color.valueOf(config.getAllColor())); - } else if (mode == LightingMode.SINGLE_COLOR) { - for (var i = 0; i < getKnobCount(); i++) { - var color = overrideColorService.getDialOverride(serialNumber, i).map(SingleKnobLightingConfig::getColor1).orElse(config.getIndividualColors()[i]); - setKnobUIColorHex(i, color); - } - } else if (mode == LightingMode.ALL_RAINBOW) { - for (var i = 0; i < getKnobCount(); i++) - setKnobUIColor(i, - Color.hsb((360 * (getKnobCount() - i - 1) * (0xFF & config.getRainbowPhaseShift())) / 255.0D * getKnobCount(), 1.0D, (0xFF & config.getRainbowBrightness()) / 255.0D)); - } else if (mode == LightingMode.ALL_WAVE) { - for (var i = 0; i < getKnobCount(); i++) - setKnobUIColor(i, Color.hsb(360.0D * (0xFF & config.getWaveHue()) / 255.0D, 1.0D, (0xFF & config.getWaveBrightness()) / 255.0D)); - } else if (mode == LightingMode.ALL_BREATH) { - for (var i = 0; i < getKnobCount(); i++) - setKnobUIColor(i, Color.hsb(360.0D * (0xFF & config.getBreathHue()) / 255.0D, 1.0D, (0xFF & config.getBreathBrightness()) / 255.0D)); - } else { - var knobConfigs = config.getKnobConfigs(); - for (var i = 0; i < KNOB_COUNT; i++) { - var knobConfig = overrideColorService.getDialOverride(serialNumber, i).orElse(knobConfigs[i]); - if (knobConfig.getMode() == SINGLE_KNOB_MODE.STATIC) { - setKnobUIColorHex(i, knobConfig.getColor1()); - } else if (knobConfig.getMode() == SINGLE_KNOB_MODE.VOLUME_GRADIENT) { - var c1 = Color.web(knobConfig.getColor1()); - var c2 = Color.web(knobConfig.getColor2()); - setKnobUIColor(i, c1.interpolate(c2, analogValue[i] / MAX_ANALOG_VALUE)); - } - } - } - } - - @Override - protected ImageView[] getKnobImages() { - return images; - } -} diff --git a/src/main/java/com/getpcpanel/device/PCPanelProDevice.java b/src/main/java/com/getpcpanel/device/PCPanelProDevice.java new file mode 100644 index 00000000..f3f9a2b8 --- /dev/null +++ b/src/main/java/com/getpcpanel/device/PCPanelProDevice.java @@ -0,0 +1,22 @@ +package com.getpcpanel.device; + +import com.getpcpanel.commands.IconService; +import com.getpcpanel.hid.InputInterpreter; +import com.getpcpanel.hid.OutputInterpreter; +import com.getpcpanel.profile.DeviceSave; +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.util.coloroverride.OverrideColorService; + +public class PCPanelProDevice extends Device { + private final int[] knobRotations = new int[DeviceType.PCPANEL_PRO.getAnalogCount()]; + + public PCPanelProDevice(InputInterpreter inputInterpreter, SaveService saveService, OutputInterpreter outputInterpreter, + IconService iconService, OverrideColorService overrideColorService, String serialNum, DeviceSave deviceSave) { + super(saveService, outputInterpreter, iconService, serialNum, deviceSave); + } + + @Override public DeviceType deviceType() { return DeviceType.PCPANEL_PRO; } + @Override public void setKnobRotation(int knob, int rotation) { knobRotations[knob] = rotation; } + @Override public int getKnobRotation(int knob) { return knobRotations[knob]; } + @Override public void setButtonPressed(int button, boolean pressed) {} +} diff --git a/src/main/java/com/getpcpanel/device/PCPanelProUI.java b/src/main/java/com/getpcpanel/device/PCPanelProUI.java deleted file mode 100644 index c4e520a2..00000000 --- a/src/main/java/com/getpcpanel/device/PCPanelProUI.java +++ /dev/null @@ -1,387 +0,0 @@ -package com.getpcpanel.device; - -import java.io.IOException; -import java.util.Objects; - -import org.springframework.context.ApplicationEventPublisher; - -import com.getpcpanel.commands.IconService; -import com.getpcpanel.hid.DeviceCommunicationHandler; -import com.getpcpanel.hid.InputInterpreter; -import com.getpcpanel.hid.OutputInterpreter; -import com.getpcpanel.profile.DeviceSave; -import com.getpcpanel.profile.LightingConfig; -import com.getpcpanel.profile.LightingConfig.LightingMode; -import com.getpcpanel.profile.SaveService; -import com.getpcpanel.profile.SingleKnobLightingConfig.SINGLE_KNOB_MODE; -import com.getpcpanel.profile.SingleLogoLightingConfig.SINGLE_LOGO_MODE; -import com.getpcpanel.profile.SingleSliderLabelLightingConfig.SINGLE_SLIDER_LABEL_MODE; -import com.getpcpanel.profile.SingleSliderLightingConfig.SINGLE_SLIDER_MODE; -import com.getpcpanel.ui.FxHelper; -import com.getpcpanel.ui.HomePage; -import com.getpcpanel.util.Util; -import com.getpcpanel.util.coloroverride.OverrideColorService; - -import javafx.application.Platform; -import javafx.fxml.FXML; -import javafx.fxml.FXMLLoader; -import javafx.scene.Node; -import javafx.scene.control.Button; -import javafx.scene.control.ContentDisplay; -import javafx.scene.control.Label; -import javafx.scene.image.Image; -import javafx.scene.image.ImageView; -import javafx.scene.input.MouseButton; -import javafx.scene.layout.Pane; -import javafx.scene.layout.Region; -import javafx.scene.paint.Color; -import javafx.scene.paint.Paint; -import javafx.scene.shape.SVGPath; -import javafx.scene.text.Font; -import javafx.stage.Stage; -import lombok.extern.log4j.Log4j2; - -@Log4j2 -public class PCPanelProUI extends Device { - private final InputInterpreter inputInterpreter; - private final OverrideColorService overrideColorService; - - public static final int KNOB_COUNT = 5; - public static final int SLIDER_COUNT = 4; - private static final int LEDS_PER_SLIDER = 5; - private static final int MAX_ANALOG_VALUE = 100; - @FXML private Pane lightPanes; - @FXML private Pane panelPane; - private Label label; - private Button lightingButton; - private final Button[] knobs = new Button[9]; - private final ImageView[] images = new ImageView[9]; - private static final Image previewImage = new Image(Objects.requireNonNull(PCPanelProUI.class.getResource("/assets/PCPanelPro/Pro_Cutout.png")).toExternalForm()); - private Stage childDialogStage; - @FXML private Pane sliderHolder1; - @FXML private Pane sliderHolder2; - @FXML private Pane sliderHolder3; - @FXML private Pane sliderHolder4; - @FXML private SVGPath sliderLabel1; - @FXML private SVGPath sliderLabel2; - @FXML private SVGPath sliderLabel3; - @FXML private SVGPath sliderLabel4; - @FXML private SVGPath logoLight; - @FXML private SVGPath knobColor1; - @FXML private SVGPath knobColor2; - @FXML private SVGPath knobColor3; - @FXML private SVGPath knobColor4; - @FXML private SVGPath knobColor5; - @FXML private Pane sliderLightPane1; - @FXML private Pane sliderLightPane2; - @FXML private Pane sliderLightPane3; - @FXML private Pane sliderLightPane4; - private final Pane[] sliderLightPanes = new Pane[4]; - private final SVGPath[] knobColors = new SVGPath[5]; - private final SVGPath[] sliderLabels = new SVGPath[4]; - private final int[] analogValue = new int[9]; - private final Pane[] sliderHolders = new Pane[4]; - - public PCPanelProUI(FxHelper fxHelper, InputInterpreter inputInterpreter, SaveService saveService, OutputInterpreter outputInterpreter, IconService iconService, ApplicationEventPublisher eventPublisher, OverrideColorService overrideColorService, - String serialNum, DeviceSave deviceSave) { - super(fxHelper, saveService, outputInterpreter, iconService, eventPublisher, serialNum, deviceSave); - this.inputInterpreter = inputInterpreter; - this.overrideColorService = overrideColorService; - var loader = getFxHelper().getLoader(getClass().getResource("/assets/PCPanelPro/PCPanelPro.fxml")); - loader.setController(this); - try { - Pane pane = loader.load(); - Util.fill(sliderLightPanes, (Object[]) new Pane[] { sliderLightPane1, sliderLightPane2, sliderLightPane3, sliderLightPane4 }); - Util.fill(knobColors, (Object[]) new SVGPath[] { knobColor1, knobColor2, knobColor3, knobColor4, knobColor5 }); - Util.fill(sliderLabels, (Object[]) new SVGPath[] { sliderLabel1, sliderLabel2, sliderLabel3, sliderLabel4 }); - Util.fill(sliderHolders, (Object[]) new Pane[] { sliderHolder1, sliderHolder2, sliderHolder3, sliderHolder4 }); - initButtons(); - initLabel(); - initLightingButton(); - pane.getStylesheets().addAll(Objects.requireNonNull(getClass().getResource("/assets/PCPanelPro/PCPanelPro.css")).toExternalForm()); - } catch (IOException e) { - log.error("Unable to init ui", e); - } - postInit(); - } - - @Override - public Node getLabel() { - return label; - } - - @Override - public Pane getDevicePane() { - return panelPane; - } - - private void rotateKnob(int knob, int val) { - analogValue[knob] = val; - if (getLightingConfig().getLightingMode() == LightingMode.CUSTOM) - showLightingConfigToUI(getLightingConfig()); - if (knob < 5) { - ((Region) knobs[knob].getGraphic()).getChildrenUnmodifiable().get(3).setRotate(Util.analogValueToRotation(val)); - } else { - var x = Util.map(val, 0.0D, 255.0D, sliderHolders[knob - 5].getPrefHeight(), 0.0D) - 40.0D; - knobs[knob].setLayoutY(x); - images[knob].setLayoutY(x); - } - } - - @Override - public int getKnobRotation(int knob) { - return analogValue[knob]; - } - - private void initLabel() { - label = new Label("PCPANEL PRO"); - var f = Font.loadFont(getClass().getResourceAsStream("/assets/apex-mk2.regular.otf"), 50.0D); - label.setFont(f); - label.setUnderline(true); - label.setTextFill(Paint.valueOf("white")); - } - - private void initLightingButton() { - lightingButton = new Button("Lighting", getLightingImage()); - lightingButton.setStyle("-fx-background-color: transparent;"); - lightingButton.setContentDisplay(ContentDisplay.TOP); - lightingButton.setMinHeight(100.0D); - lightingButton.setOnAction(e -> { - childDialogStage = new Stage(); - getFxHelper().buildProLightingDialog(this).start(childDialogStage); - }); - } - - private void initButtons() throws IOException { - var xPos = 121.3D; - var yPos = 66.3D; - var xDelta = 133.0D; - var yDelta = 97.5D; - var buttonSize = 80; - for (var i = 0; i < 9; i++) { - FXMLLoader loader; - if (i < 5) { - loader = getFxHelper().getLoader(getClass().getResource("/assets/PCPanelPro/knob.fxml")); - } else { - loader = getFxHelper().getLoader(getClass().getResource("/assets/PCPanelPro/slider.fxml")); - } - Node nx = loader.load(); - images[i] = buildKnobImageView(); - knobs[i] = new Button("", nx); - knobs[i].setId("dial_button"); - knobs[i].setContentDisplay(ContentDisplay.CENTER); - if (i < 5) { - knobs[i].setMinSize(buttonSize, buttonSize); - knobs[i].setMaxSize(buttonSize, buttonSize); - knobs[i].setLayoutX(xPos); - knobs[i].setLayoutY(yPos); - knobs[i].setScaleX(1.2D); - knobs[i].setScaleY(1.2D); - - images[i].setLayoutX(xPos + 5); - images[i].setLayoutY(yPos + 5); - images[i].setFitWidth(71); - images[i].setFitHeight(71); - } else { - knobs[i].setMinSize(buttonSize, buttonSize); - knobs[i].setMaxSize(buttonSize, buttonSize); - knobs[i].setLayoutX(-26.0D); - knobs[i].setScaleX(0.4D); - knobs[i].setScaleY(0.4D); - - images[i].setLayoutX(-11.0D); - images[i].setFitWidth(50); - images[i].setFitHeight(50); - } - var knob = i; - knobs[i].setOnAction(e -> { - HomePage.showHint(false); - var name = (knob < 5) ? ("Knob " + (knob + 1)) : ("Slider " + (knob - 5 + 1)); - var analogType = (knob < 5) ? "Knob" : "Slider"; - var bm = getFxHelper().buildBasicMacro(this, knob, knob < 5, name, analogType); - try { - childDialogStage = new Stage(); - bm.start(childDialogStage); - } catch (Exception ex) { - log.error("Unable to start dialog", ex); - } - }); - var idx = i; - knobs[i].setOnMouseClicked(c -> { - if (c.getButton() == MouseButton.MIDDLE) { - try { - inputInterpreter.onButtonPress(new DeviceCommunicationHandler.ButtonPressEvent(getSerialNumber(), knob, true)); - } catch (IOException e1) { - log.error("Unable to handle button press", e1); - } - try { - inputInterpreter.onButtonPress(new DeviceCommunicationHandler.ButtonPressEvent(getSerialNumber(), knob, false)); - } catch (IOException e1) { - log.error("Unable to handle button release", e1); - } - } else if (c.getButton() == MouseButton.SECONDARY) { - getFxHelper().buildProLightingDialog(this).select(idx).start(new Stage()); - } - }); - if (i < 5) { - panelPane.getChildren().add(knobs[i]); - panelPane.getChildren().add(images[i]); - } else { - sliderHolders[i - 5].getChildren().add(knobs[i]); - sliderHolders[i - 5].getChildren().add(images[i]); - } - xPos += xDelta; - if (i == 1) { - yPos += yDelta; - xPos -= 332.5D; - } - } - } - - public String toString() { - return getDisplayName(); - } - - @Override - public Image getPreviewImage() { - return previewImage; - } - - @Override - public void setKnobRotation(int knob, int value) { - Platform.runLater(() -> rotateKnob(knob, value)); - } - - @Override - public void setButtonPressed(int knob, boolean pressed) { - Platform.runLater(() -> knobs[knob].setOpacity(pressed ? 0.5D : 1.0D)); - } - - @Override - public void closeDialogs() { - if (childDialogStage != null && childDialogStage.isShowing()) - childDialogStage.close(); - } - - @Override - public Button getLightingButton() { - return lightingButton; - } - - @Override - public DeviceType getDeviceType() { - return DeviceType.PCPANEL_PRO; - } - - private void setAllColor(Paint color) { - for (var p : knobColors) { - p.setFill(color); - } - for (var p : sliderLabels) { - p.setFill(color); - } - for (var pane : sliderLightPanes) { - for (var n : pane.getChildren()) - ((SVGPath) n).setFill(color); - } - logoLight.setFill(color); - } - - @Override - public void showLightingConfigToUI(LightingConfig config) { - var mode = config.getLightingMode(); - if (mode == LightingMode.ALL_COLOR) { - setAllColor(Paint.valueOf(config.getAllColor())); - } else if (mode == LightingMode.ALL_RAINBOW) { - var totalRows = 9; - var row = 0; - knobColor1.setFill(createFill(config, totalRows, row)); - knobColor2.setFill(createFill(config, totalRows, row)); - row++; - knobColor3.setFill(createFill(config, totalRows, row)); - knobColor4.setFill(createFill(config, totalRows, row)); - knobColor5.setFill(createFill(config, totalRows, row)); - row++; - for (var p : sliderLabels) { - p.setFill(createFill(config, totalRows, row)); - } - row++; - for (var i = 4; i >= 0; i--) { - for (var a = 0; a < 4; a++) - ((SVGPath) sliderLightPanes[a].getChildren().get(i)).setFill(createFill(config, totalRows, row)); - row++; - } - logoLight.setFill(createFill(config, totalRows, row)); - } else if (mode == LightingMode.ALL_WAVE) { - setAllColor(Color.hsb(360.0D * (0xFF & config.getWaveHue()) / 255.0D, 1.0D, (0xFF & config.getWaveBrightness()) / 255.0D)); - } else if (mode == LightingMode.ALL_BREATH) { - setAllColor(Color.hsb(360.0D * (0xFF & config.getBreathHue()) / 255.0D, 1.0D, (0xFF & config.getBreathBrightness()) / 255.0D)); - } else if (mode == LightingMode.CUSTOM) { - var knobConfigs = config.getKnobConfigs(); - var sliderLabelConfigs = config.getSliderLabelConfigs(); - var sliderConfigs = config.getSliderConfigs(); - for (var i = 0; i < KNOB_COUNT; i++) { - var knobConfig = overrideColorService.getDialOverride(serialNumber, i).orElse(knobConfigs[i]); - if (knobConfig.getMode() == SINGLE_KNOB_MODE.STATIC) { - knobColors[i].setFill(Paint.valueOf(knobConfig.getColor1())); - } else if (knobConfig.getMode() == SINGLE_KNOB_MODE.VOLUME_GRADIENT) { - var c1 = Color.web(knobConfig.getColor1()); - var c2 = Color.web(knobConfig.getColor2()); - knobColors[i].setFill(c1.interpolate(c2, analogValue[i] / 100.0D)); - } - } - for (var i = 0; i < SLIDER_COUNT; i++) { - var sliderLabelConfig = overrideColorService.getSliderLabelOverride(serialNumber, i).orElse(sliderLabelConfigs[i]); - if (sliderLabelConfig.getMode() == SINGLE_SLIDER_LABEL_MODE.STATIC) - sliderLabels[i].setFill(Paint.valueOf(sliderLabelConfig.getColor())); - } - for (var i = 0; i < SLIDER_COUNT; i++) { - var sliderConfig = overrideColorService.getSliderOverride(serialNumber, i).orElse(sliderConfigs[i]); - if (sliderConfig.getMode() == SINGLE_SLIDER_MODE.STATIC) { - for (var n : sliderLightPanes[i].getChildren()) - ((SVGPath) n).setFill(Paint.valueOf(sliderConfig.getColor1())); - } else if (sliderConfig.getMode() == SINGLE_SLIDER_MODE.STATIC_GRADIENT) { - var c1 = Color.web(sliderConfig.getColor1()); - var c2 = Color.web(sliderConfig.getColor2()); - var f = 0.0D; - var delta = 0.25D; - for (var a = 0; a < LEDS_PER_SLIDER; a++) { - ((SVGPath) sliderLightPanes[i].getChildren().get(a)).setFill(c1.interpolate(c2, f)); - f += delta; - } - } else if (sliderConfig.getMode() == SINGLE_SLIDER_MODE.VOLUME_GRADIENT) { - var c1 = Color.web(sliderConfig.getColor1()); - var c2 = Color.web(sliderConfig.getColor2()); - var f = 0.0D; - var delta = 0.25D; - for (var a = 0; a < 5; a++) { - if (a < (analogValue[i + 5] + 10) * 5 / MAX_ANALOG_VALUE) { - ((SVGPath) sliderLightPanes[i].getChildren().get(a)).setFill(c1.interpolate(c2, f)); - } else { - ((SVGPath) sliderLightPanes[i].getChildren().get(a)).setFill(Paint.valueOf("black")); - } - f += delta; - } - } - } - - var logoConfig = overrideColorService.getLogoOverride(serialNumber).orElse(config.getLogoConfig()); - if (logoConfig.getMode() == SINGLE_LOGO_MODE.STATIC) { - logoLight.setFill(Paint.valueOf(logoConfig.getColor())); - } else if (logoConfig.getMode() == SINGLE_LOGO_MODE.RAINBOW) { - logoLight.setFill(Color.RED); - } else if (logoConfig.getMode() == SINGLE_LOGO_MODE.BREATH) { - logoLight.setFill(Color.hsb(360.0D * (0xFF & logoConfig.getHue()) / 255.0D, 1.0D, (0xFF & logoConfig.getBrightness()) / 255.0D)); - } - } - } - - private static Color createFill(LightingConfig config, int totalRows, int row) { - return Color.hsb((360 * (totalRows - row - 1) * (0xFF & config.getRainbowPhaseShift())) / 255.0D * totalRows, 1.0D, (0xFF & config.getRainbowBrightness()) / 255.0D); - } - - @Override - protected ImageView[] getKnobImages() { - return images; - } -} diff --git a/src/main/java/com/getpcpanel/device/PCPanelRGBDevice.java b/src/main/java/com/getpcpanel/device/PCPanelRGBDevice.java new file mode 100644 index 00000000..3e831558 --- /dev/null +++ b/src/main/java/com/getpcpanel/device/PCPanelRGBDevice.java @@ -0,0 +1,22 @@ +package com.getpcpanel.device; + +import com.getpcpanel.commands.IconService; +import com.getpcpanel.hid.InputInterpreter; +import com.getpcpanel.hid.OutputInterpreter; +import com.getpcpanel.profile.DeviceSave; +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.util.coloroverride.OverrideColorService; + +public class PCPanelRGBDevice extends Device { + private final int[] knobRotations = new int[DeviceType.PCPANEL_RGB.getAnalogCount()]; + + public PCPanelRGBDevice(InputInterpreter inputInterpreter, SaveService saveService, OutputInterpreter outputInterpreter, + IconService iconService, OverrideColorService overrideColorService, DeviceSave deviceSave, String serialNum) { + super(saveService, outputInterpreter, iconService, serialNum, deviceSave); + } + + @Override public DeviceType deviceType() { return DeviceType.PCPANEL_RGB; } + @Override public void setKnobRotation(int knob, int rotation) { knobRotations[knob] = rotation; } + @Override public int getKnobRotation(int knob) { return knobRotations[knob]; } + @Override public void setButtonPressed(int button, boolean pressed) {} +} diff --git a/src/main/java/com/getpcpanel/device/PCPanelRGBUI.java b/src/main/java/com/getpcpanel/device/PCPanelRGBUI.java deleted file mode 100644 index 873fd394..00000000 --- a/src/main/java/com/getpcpanel/device/PCPanelRGBUI.java +++ /dev/null @@ -1,250 +0,0 @@ -package com.getpcpanel.device; - -import java.io.IOException; -import java.util.Objects; - -import org.springframework.context.ApplicationEventPublisher; - -import com.getpcpanel.commands.IconService; -import com.getpcpanel.hid.DeviceCommunicationHandler; -import com.getpcpanel.hid.InputInterpreter; -import com.getpcpanel.hid.OutputInterpreter; -import com.getpcpanel.profile.DeviceSave; -import com.getpcpanel.profile.LightingConfig; -import com.getpcpanel.profile.LightingConfig.LightingMode; -import com.getpcpanel.profile.SaveService; -import com.getpcpanel.profile.SingleKnobLightingConfig; -import com.getpcpanel.ui.FxHelper; -import com.getpcpanel.ui.HomePage; -import com.getpcpanel.util.Util; -import com.getpcpanel.util.coloroverride.OverrideColorService; - -import javafx.application.Platform; -import javafx.fxml.FXML; -import javafx.scene.Node; -import javafx.scene.control.Button; -import javafx.scene.control.ContentDisplay; -import javafx.scene.control.Label; -import javafx.scene.image.Image; -import javafx.scene.image.ImageView; -import javafx.scene.input.MouseButton; -import javafx.scene.layout.Pane; -import javafx.scene.layout.Region; -import javafx.scene.paint.Color; -import javafx.scene.paint.Paint; -import javafx.scene.shape.Shape; -import javafx.scene.text.Font; -import javafx.stage.Stage; -import lombok.extern.log4j.Log4j2; - -@Log4j2 -public class PCPanelRGBUI extends Device { - private final InputInterpreter inputInterpreter; - private final OverrideColorService overrideColorService; - - private static final int KNOB_COUNT = 4; - @FXML private Pane lightPanes; - @FXML private Pane panelPane; - private Label label; - private Button lightingButton; - private final Button[] knobs = new Button[KNOB_COUNT]; - private final ImageView[] images = new ImageView[KNOB_COUNT]; - private static final Image previewImage = new Image(Objects.requireNonNull(PCPanelRGBUI.class.getResource("/assets/PCPanelRGB/preview.png")).toExternalForm()); - private Stage childDialogStage; - - public PCPanelRGBUI(FxHelper fxHelper, InputInterpreter inputInterpreter, SaveService saveService, OutputInterpreter outputInterpreter, IconService iconService, ApplicationEventPublisher eventPublisher, OverrideColorService overrideColorService, - DeviceSave deviceSave, String serialNum) { - super(fxHelper, saveService, outputInterpreter, iconService, eventPublisher, serialNum, deviceSave); - this.inputInterpreter = inputInterpreter; - this.overrideColorService = overrideColorService; - var loader = getFxHelper().getLoader(getClass().getResource("/assets/PCPanelRGB/PCPanelRGB.fxml")); - loader.setController(this); - try { - Pane pane = loader.load(); - initButtons(); - initLabel(); - initLightingButton(); - pane.getStylesheets().addAll(Objects.requireNonNull(getClass().getResource("/assets/PCPanelRGB/PCPanelRGB.css")).toExternalForm()); - } catch (IOException e) { - log.error("Unable to init ui", e); - } - postInit(); - } - - @Override - public Node getLabel() { - return label; - } - - @Override - public Pane getDevicePane() { - return panelPane; - } - - private void rotateKnob(int knob, int val) { - ((Region) knobs[knob].getGraphic()).getChildrenUnmodifiable().get(3).setRotate(Util.analogValueToRotation(val)); - } - - @Override - public int getKnobRotation(int knob) { - return Util.rotationToAnalogValue(((Region) knobs[knob].getGraphic()).getChildrenUnmodifiable().get(3).getRotate()); - } - - private void initLabel() { - label = new Label("PCPANEL RGB"); - var f = Font.loadFont(getClass().getResourceAsStream("/assets/apex-mk2.regular.otf"), 50.0D); - label.setFont(f); - label.setUnderline(true); - label.setTextFill(Paint.valueOf("white")); - } - - private void initLightingButton() { - lightingButton = new Button("Lighting", getLightingImage()); - lightingButton.setStyle("-fx-background-color: transparent;"); - lightingButton.setContentDisplay(ContentDisplay.TOP); - lightingButton.setMinHeight(100.0D); - lightingButton.setOnAction(e -> { - childDialogStage = new Stage(); - getFxHelper().buildRGBLightingDialog(this).start(childDialogStage); - }); - } - - private void initButtons() throws IOException { - var xPos = 52.0D; - var yPos = 64.0D; - var xDelta = 107.3D; - var buttonSize = 80; - for (var i = 0; i < KNOB_COUNT; i++) { - var loader = getFxHelper().getLoader(getClass().getResource("/assets/PCPanelRGB/knob.fxml")); - Node nx = loader.load(); - images[i] = buildKnobImageView(); - knobs[i] = new Button("", nx); - knobs[i].setId("dial_button"); - knobs[i].setContentDisplay(ContentDisplay.CENTER); - knobs[i].setMinSize(buttonSize, buttonSize); - knobs[i].setMaxSize(buttonSize, buttonSize); - knobs[i].setLayoutX(xPos); - knobs[i].setLayoutY(yPos); - - images[i].setLayoutX(xPos + 10); - images[i].setLayoutY(yPos + 10); - images[i].setFitWidth(buttonSize - 20); - images[i].setFitHeight(buttonSize - 20); - - var knob = i; - knobs[i].setOnAction(e -> { - HomePage.showHint(false); - var bm = getFxHelper().buildBasicMacro(this, knob); - try { - childDialogStage = new Stage(); - bm.start(childDialogStage); - } catch (Exception ex) { - log.error("Unable to init button", ex); - } - }); - var idx = i; - knobs[i].setOnMouseClicked(c -> { - if (c.getButton() == MouseButton.MIDDLE) { - try { - inputInterpreter.onButtonPress(new DeviceCommunicationHandler.ButtonPressEvent(getSerialNumber(), knob, true)); - } catch (IOException e1) { - log.error("Unable to handle button press", e1); - } - try { - inputInterpreter.onButtonPress(new DeviceCommunicationHandler.ButtonPressEvent(getSerialNumber(), knob, false)); - } catch (IOException e1) { - log.error("Unable to handle button release", e1); - } - } else if (c.getButton() == MouseButton.SECONDARY) { - getFxHelper().buildRGBLightingDialog(this).select(idx).start(new Stage()); - } - }); - panelPane.getChildren().add(knobs[i]); - panelPane.getChildren().add(images[i]); - xPos += xDelta; - } - } - - public String toString() { - return getDisplayName(); - } - - @Override - public Image getPreviewImage() { - return previewImage; - } - - public int getKnobCount() { - return KNOB_COUNT; - } - - @Override - public void setKnobRotation(int knob, int value) { - Platform.runLater(() -> rotateKnob(knob, value)); - } - - @Override - public void setButtonPressed(int knob, boolean pressed) { - Platform.runLater(() -> knobs[knob].setOpacity(pressed ? 0.5D : 1.0D)); - } - - private void setAllKnobUIColor(Color color) { - for (var i = 0; i < getKnobCount(); i++) { - setKnobUIColor(i, color); - } - } - - private void setKnobUIColorHex(int knob, String color) { - var lightPane = (Shape) lightPanes.getChildren().get(knob); - lightPane.setFill(Paint.valueOf(color)); - } - - private void setKnobUIColor(int knob, Color color) { - var lightPane = (Shape) lightPanes.getChildren().get(knob); - lightPane.setFill(color); - } - - @Override - public void closeDialogs() { - if (childDialogStage != null && childDialogStage.isShowing()) - childDialogStage.close(); - } - - @Override - public Button getLightingButton() { - return lightingButton; - } - - @Override - public DeviceType getDeviceType() { - return DeviceType.PCPANEL_RGB; - } - - @Override - public void showLightingConfigToUI(LightingConfig config) { - var mode = config.getLightingMode(); - if (mode == LightingMode.ALL_COLOR) { - setAllKnobUIColor(Color.valueOf(config.getAllColor())); - } else if (mode == LightingMode.SINGLE_COLOR) { - for (var i = 0; i < getKnobCount(); i++) { - var color = overrideColorService.getDialOverride(serialNumber, i).map(SingleKnobLightingConfig::getColor1).orElse(config.getIndividualColors()[i]); - setKnobUIColorHex(i, color); - } - } else if (mode == LightingMode.ALL_RAINBOW) { - for (var i = 0; i < getKnobCount(); i++) - setKnobUIColor(i, - Color.hsb((360 * (getKnobCount() - i - 1) * (0xFF & config.getRainbowPhaseShift())) / 255.0D * getKnobCount(), 1.0D, (0xFF & config.getRainbowBrightness()) / 255.0D)); - } else if (mode == LightingMode.ALL_WAVE) { - for (var i = 0; i < getKnobCount(); i++) - setKnobUIColor(i, Color.hsb(360.0D * (0xFF & config.getWaveHue()) / 255.0D, 1.0D, (0xFF & config.getWaveBrightness()) / 255.0D)); - } else if (mode == LightingMode.ALL_BREATH) { - for (var i = 0; i < getKnobCount(); i++) - setKnobUIColor(i, Color.hsb(360.0D * (0xFF & config.getBreathHue()) / 255.0D, 1.0D, (0xFF & config.getBreathBrightness()) / 255.0D)); - } - } - - @Override - protected ImageView[] getKnobImages() { - return images; - } -} diff --git a/src/main/java/com/getpcpanel/graalvm/NativeImageConfig.java b/src/main/java/com/getpcpanel/graalvm/NativeImageConfig.java new file mode 100644 index 00000000..f3b623a3 --- /dev/null +++ b/src/main/java/com/getpcpanel/graalvm/NativeImageConfig.java @@ -0,0 +1,250 @@ +package com.getpcpanel.graalvm; + +import org.hid4java.jna.HidApi; +import org.hid4java.jna.HidApiLibrary; +import org.hid4java.jna.HidDeviceInfoStructure; +import org.hid4java.jna.HidDeviceStructure; +import org.hid4java.jna.HidrawHidApiLibrary; +import org.hid4java.jna.LibusbHidApiLibrary; +import org.hid4java.jna.WideStringBuffer; + +import com.getpcpanel.commands.Commands; +import com.getpcpanel.commands.CommandsType; +import com.getpcpanel.commands.DeviceSet; +import com.getpcpanel.commands.command.Command; +import com.getpcpanel.commands.command.CommandBrightness; +import com.getpcpanel.commands.command.CommandEndProgram; +import com.getpcpanel.commands.command.CommandKeystroke; +import com.getpcpanel.commands.command.CommandMedia; +import com.getpcpanel.commands.command.CommandMedia.VolumeButton; +import com.getpcpanel.commands.command.CommandNoOp; +import com.getpcpanel.commands.command.CommandObs; +import com.getpcpanel.commands.command.CommandObsMuteSource; +import com.getpcpanel.commands.command.CommandObsSetScene; +import com.getpcpanel.commands.command.CommandObsSetSourceVolume; +import com.getpcpanel.commands.command.CommandProfile; +import com.getpcpanel.commands.command.CommandRun; +import com.getpcpanel.commands.command.CommandShortcut; +import com.getpcpanel.commands.command.CommandVoiceMeeter; +import com.getpcpanel.commands.command.CommandVoiceMeeterAdvanced; +import com.getpcpanel.commands.command.CommandVoiceMeeterAdvancedButton; +import com.getpcpanel.commands.command.CommandVoiceMeeterBasic; +import com.getpcpanel.commands.command.CommandVoiceMeeterBasicButton; +import com.getpcpanel.commands.command.CommandVolume; +import com.getpcpanel.commands.command.CommandVolumeApplicationDeviceToggle; +import com.getpcpanel.commands.command.CommandVolumeDefaultDevice; +import com.getpcpanel.commands.command.CommandVolumeDefaultDeviceAdvanced; +import com.getpcpanel.commands.command.CommandVolumeDefaultDeviceToggle; +import com.getpcpanel.commands.command.CommandVolumeDefaultDeviceToggleAdvanced; +import com.getpcpanel.commands.command.CommandVolumeDevice; +import com.getpcpanel.commands.command.CommandVolumeDeviceMute; +import com.getpcpanel.commands.command.CommandVolumeFocus; +import com.getpcpanel.commands.command.CommandVolumeFocusMute; +import com.getpcpanel.commands.command.CommandVolumeProcess; +import com.getpcpanel.commands.command.CommandVolumeProcessMute; +import com.getpcpanel.commands.command.DialAction.DialCommandParams; +import com.getpcpanel.profile.DeviceSave; +import com.getpcpanel.profile.Profile; +import com.getpcpanel.profile.Save; +import com.getpcpanel.profile.dto.KnobSetting; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.profile.dto.LightingConfig.LightingMode; +import com.getpcpanel.profile.dto.MqttSettings; +import com.getpcpanel.profile.dto.MqttSettings.HomeAssistantSettings; +import com.getpcpanel.profile.dto.OSCBinding; +import com.getpcpanel.profile.dto.OSCConnectionInfo; +import com.getpcpanel.profile.dto.OverlayPosition; +import com.getpcpanel.profile.dto.SingleKnobLightingConfig; +import com.getpcpanel.profile.dto.SingleKnobLightingConfig.SINGLE_KNOB_MODE; +import com.getpcpanel.profile.dto.SingleLogoLightingConfig; +import com.getpcpanel.profile.dto.SingleLogoLightingConfig.SINGLE_LOGO_MODE; +import com.getpcpanel.profile.dto.SingleSliderLabelLightingConfig; +import com.getpcpanel.profile.dto.SingleSliderLabelLightingConfig.SINGLE_SLIDER_LABEL_MODE; +import com.getpcpanel.profile.dto.SingleSliderLightingConfig; +import com.getpcpanel.profile.dto.SingleSliderLightingConfig.SINGLE_SLIDER_MODE; +import com.getpcpanel.profile.dto.WaveLinkSettings; +import com.getpcpanel.wavelink.command.CommandWaveLink; +import com.getpcpanel.wavelink.command.CommandWaveLinkAddFocusToChannel; +import com.getpcpanel.wavelink.command.CommandWaveLinkChange; +import com.getpcpanel.wavelink.command.CommandWaveLinkChangeLevel; +import com.getpcpanel.wavelink.command.CommandWaveLinkChangeMute; +import com.getpcpanel.wavelink.command.CommandWaveLinkChannelEffect; +import com.getpcpanel.wavelink.command.CommandWaveLinkMainOutput; +import com.getpcpanel.wavelink.command.WaveLinkCommandTarget; + +import dev.niels.wavelink.impl.model.WaveLinkApp; +import dev.niels.wavelink.impl.model.WaveLinkChannel; +import dev.niels.wavelink.impl.model.WaveLinkControlAction; +import dev.niels.wavelink.impl.model.WaveLinkEffect; +import dev.niels.wavelink.impl.model.WaveLinkGain; +import dev.niels.wavelink.impl.model.WaveLinkImage; +import dev.niels.wavelink.impl.model.WaveLinkInput; +import dev.niels.wavelink.impl.model.WaveLinkInputDevice; +import dev.niels.wavelink.impl.model.WaveLinkMainOutput; +import dev.niels.wavelink.impl.model.WaveLinkMix; +import dev.niels.wavelink.impl.model.WaveLinkOutput; +import dev.niels.wavelink.impl.model.WaveLinkOutputDevice; +import dev.niels.wavelink.impl.rpc.JsonRpcMessage; +import dev.niels.wavelink.impl.rpc.JsonRpcResponse; +import dev.niels.wavelink.impl.rpc.JsonRpcResponse.ErrorDetail; +import dev.niels.wavelink.impl.rpc.WaveLinkAddToChannelCommand; +import dev.niels.wavelink.impl.rpc.WaveLinkChannelChangedCommand; +import dev.niels.wavelink.impl.rpc.WaveLinkChannelsChangedCommand; +import dev.niels.wavelink.impl.rpc.WaveLinkFocusedAppChangedCommand; +import dev.niels.wavelink.impl.rpc.WaveLinkGetApplicationInfo; +import dev.niels.wavelink.impl.rpc.WaveLinkGetChannels; +import dev.niels.wavelink.impl.rpc.WaveLinkGetInputDevices; +import dev.niels.wavelink.impl.rpc.WaveLinkGetMixes; +import dev.niels.wavelink.impl.rpc.WaveLinkGetOutputDevices; +import dev.niels.wavelink.impl.rpc.WaveLinkJsonRpcCommand; +import dev.niels.wavelink.impl.rpc.WaveLinkMixChangedCommand; +import dev.niels.wavelink.impl.rpc.WaveLinkOutputDeviceChangedCommand; +import dev.niels.wavelink.impl.rpc.WaveLinkSetChannelCommand; +import dev.niels.wavelink.impl.rpc.WaveLinkSetMixCommand; +import dev.niels.wavelink.impl.rpc.WaveLinkSetOutputDeviceCommand; +import dev.niels.wavelink.impl.rpc.WaveLinkSetSubscription; +import dev.niels.wavelink.impl.rpc.WaveLinkUnknownCommand; +import io.quarkus.runtime.annotations.RegisterForReflection; + +/** + * GraalVM native image reflection hints. + * + * Jackson deserialises {@link Command} subtypes via {@code @JsonTypeInfo(use = ID.CLASS)}, so + * every concrete subtype must be registered for reflection. All profile/command model classes that + * are serialised/deserialised by Jackson are collected here. + * + * hid4java JNA structures and library interfaces are also registered here for JNA reflective + * instantiation and field access. + */ +@RegisterForReflection(targets = { + // hid4java JNA library + structure classes (JNA needs reflection to instantiate structures) + HidApi.class, + HidApiLibrary.class, + HidDeviceInfoStructure.class, + HidDeviceStructure.class, + HidrawHidApiLibrary.class, + LibusbHidApiLibrary.class, + WideStringBuffer.class, + + // MQTT Home Assistant discovery payload classes (serialised to JSON by Jackson) + // Note: these records are package-private so referenced by classNames below + + // Command type hierarchy + Command.class, + CommandBrightness.class, + CommandEndProgram.class, + CommandKeystroke.class, + CommandMedia.class, + VolumeButton.class, + CommandNoOp.class, + CommandObs.class, + CommandObsMuteSource.class, + CommandObsSetScene.class, + CommandObsSetSourceVolume.class, + CommandProfile.class, + CommandRun.class, + CommandShortcut.class, + CommandVoiceMeeter.class, + CommandVoiceMeeterAdvanced.class, + CommandVoiceMeeterAdvancedButton.class, + CommandVoiceMeeterBasic.class, + CommandVoiceMeeterBasicButton.class, + CommandVolume.class, + CommandVolumeApplicationDeviceToggle.class, + CommandVolumeDefaultDevice.class, + CommandVolumeDefaultDeviceAdvanced.class, + CommandVolumeDefaultDeviceToggle.class, + CommandVolumeDefaultDeviceToggleAdvanced.class, + CommandVolumeDevice.class, + CommandVolumeDeviceMute.class, + CommandVolumeFocus.class, + CommandVolumeFocusMute.class, + CommandVolumeProcess.class, + CommandVolumeProcessMute.class, + + // WaveLink command hierarchy (also extends Command → ID.CLASS polymorphism) + CommandWaveLink.class, + CommandWaveLinkAddFocusToChannel.class, + CommandWaveLinkChange.class, + CommandWaveLinkChangeLevel.class, + CommandWaveLinkChangeMute.class, + CommandWaveLinkChannelEffect.class, + CommandWaveLinkMainOutput.class, + WaveLinkCommandTarget.class, + + // WaveLink RPC protocol classes (Jackson @JsonSubTypes / @JsonTypeInfo) + JsonRpcMessage.class, + JsonRpcResponse.class, + ErrorDetail.class, + WaveLinkJsonRpcCommand.class, + WaveLinkChannelChangedCommand.class, + WaveLinkChannelsChangedCommand.class, + WaveLinkFocusedAppChangedCommand.class, + WaveLinkMixChangedCommand.class, + WaveLinkOutputDeviceChangedCommand.class, + WaveLinkGetApplicationInfo.class, + WaveLinkGetChannels.class, + WaveLinkGetInputDevices.class, + WaveLinkGetMixes.class, + WaveLinkGetOutputDevices.class, + WaveLinkSetChannelCommand.class, + WaveLinkSetMixCommand.class, + WaveLinkSetOutputDeviceCommand.class, + WaveLinkSetSubscription.class, + WaveLinkAddToChannelCommand.class, + WaveLinkUnknownCommand.class, + + // WaveLink model classes (deserialised from WaveLink JSON API) + WaveLinkApp.class, + WaveLinkChannel.class, + WaveLinkControlAction.class, + WaveLinkEffect.class, + WaveLinkGain.class, + WaveLinkImage.class, + WaveLinkInput.class, + WaveLinkInputDevice.class, + WaveLinkMainOutput.class, + WaveLinkMix.class, + WaveLinkOutput.class, + WaveLinkOutputDevice.class, + + // Command support types serialised by Jackson + Commands.class, + CommandsType.class, + DeviceSet.class, + DialCommandParams.class, + + // Profile / save model classes (Jackson deserialization of user save file) + Save.class, + DeviceSave.class, + Profile.class, + LightingConfig.class, + LightingMode.class, + SingleKnobLightingConfig.class, + SINGLE_KNOB_MODE.class, + SingleSliderLightingConfig.class, + SINGLE_SLIDER_MODE.class, + SingleSliderLabelLightingConfig.class, + SINGLE_SLIDER_LABEL_MODE.class, + SingleLogoLightingConfig.class, + SINGLE_LOGO_MODE.class, + KnobSetting.class, + MqttSettings.class, + HomeAssistantSettings.class, + WaveLinkSettings.class, + OSCConnectionInfo.class, + OSCBinding.class, + OverlayPosition.class, +}, classNames = { + // MQTT Home Assistant discovery records (package-private inner classes – referenced by name) + "com.getpcpanel.mqtt.MqttHomeAssistantHelper$HomeAssistantAvailability", + "com.getpcpanel.mqtt.MqttHomeAssistantHelper$HomeAssistantButtonConfig", + "com.getpcpanel.mqtt.MqttHomeAssistantHelper$HomeAssistantButtonEventConfig", + "com.getpcpanel.mqtt.MqttHomeAssistantHelper$HomeAssistantDevice", + "com.getpcpanel.mqtt.MqttHomeAssistantHelper$HomeAssistantLightConfig", + "com.getpcpanel.mqtt.MqttHomeAssistantHelper$HomeAssistantNumberConfig", +}) +public class NativeImageConfig { + private NativeImageConfig() { + } +} diff --git a/src/main/java/com/getpcpanel/hid/ByteWriter.java b/src/main/java/com/getpcpanel/hid/ByteWriter.java index 3979d3eb..43399350 100644 --- a/src/main/java/com/getpcpanel/hid/ByteWriter.java +++ b/src/main/java/com/getpcpanel/hid/ByteWriter.java @@ -2,8 +2,10 @@ import java.util.stream.Stream; -import javafx.scene.paint.Color; - +/** + * Builds byte arrays for HID lighting commands. + * Colors are passed as hex strings (e.g. "#rrggbb") or as separate R/G/B int components. + */ class ByteWriter { private final byte[] buff; private final int brightnessMultiplier; @@ -34,9 +36,32 @@ public ByteWriter appendBrightness(byte nr) { return append(applyBrightness(nr)); } - @SuppressWarnings("NumericCastThatLosesPrecision") - public ByteWriter append(Color c) { - return append(applyBrightness((byte) (c.getRed() * 255)), applyBrightness((byte) (c.getGreen() * 255)), applyBrightness((byte) (c.getBlue() * 255))); + /** + * Append RGB from a CSS hex color string ("#rrggbb"). If null/invalid, appends black. + */ + public ByteWriter appendHex(String hexColor) { + int r = 0, g = 0, b = 0; + if (hexColor != null) { + try { + String hex = hexColor.startsWith("#") ? hexColor.substring(1) : hexColor; + r = Integer.parseInt(hex.substring(0, 2), 16) & 0xFF; + g = Integer.parseInt(hex.substring(2, 4), 16) & 0xFF; + b = Integer.parseInt(hex.substring(4, 6), 16) & 0xFF; + } catch (Exception ignored) { + } + } + return append(applyBrightness((byte) r), applyBrightness((byte) g), applyBrightness((byte) b)); + } + + /** + * Append RGB from int components (0-255). Values are clamped to [0, 255]. + */ + public ByteWriter appendRGB(int r, int g, int b) { + return append( + applyBrightness((byte) (Math.min(255, Math.max(0, r)) & 0xFF)), + applyBrightness((byte) (Math.min(255, Math.max(0, g)) & 0xFF)), + applyBrightness((byte) (Math.min(255, Math.max(0, b)) & 0xFF)) + ); } private byte applyBrightness(byte nr) { diff --git a/src/main/java/com/getpcpanel/hid/DeviceCommunicationHandler.java b/src/main/java/com/getpcpanel/hid/DeviceCommunicationHandler.java index dc6d47e0..b8767802 100644 --- a/src/main/java/com/getpcpanel/hid/DeviceCommunicationHandler.java +++ b/src/main/java/com/getpcpanel/hid/DeviceCommunicationHandler.java @@ -14,14 +14,17 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import org.apache.commons.lang3.tuple.Pair; import org.hid4java.HidDevice; -import org.springframework.context.ApplicationEventPublisher; + +import javax.annotation.Nullable; import com.getpcpanel.device.DeviceType; import com.getpcpanel.profile.SaveService; +import jakarta.enterprise.event.Event; import lombok.Setter; import lombok.extern.log4j.Log4j2; @@ -30,7 +33,7 @@ public class DeviceCommunicationHandler { private static final byte INPUT_CODE_KNOB_CHANGE = 1; private static final byte INPUT_CODE_BUTTON_CHANGE = 2; - private final ApplicationEventPublisher eventPublisher; + private final Event eventBus; private final DeviceScanner deviceScanner; private final SaveService saveService; private final String key; @@ -47,23 +50,64 @@ public class DeviceCommunicationHandler { private final KnobDebouncer debouncer = new KnobDebouncer(); private final RollingAverageSetter rollingAverageSetter = new RollingAverageSetter(); private final Map prevSent = new ConcurrentHashMap<>(); + private final AtomicBoolean stopping = new AtomicBoolean(false); + private Thread readerThread; + private Thread writerThread; - public DeviceCommunicationHandler(DeviceScanner deviceScanner, ApplicationEventPublisher eventPublisher, SaveService saveService, String key, HidDevice device, DeviceType deviceType) { - this.eventPublisher = eventPublisher; + public DeviceCommunicationHandler(DeviceScanner deviceScanner, SaveService saveService, Event eventBus, String key, HidDevice device, DeviceType deviceType) { this.deviceScanner = deviceScanner; this.saveService = saveService; + this.eventBus = eventBus; this.key = key; this.device = device; this.deviceType = deviceType; } public void start() { - var reader = new Thread(this::reader, "HIDReader " + device.getSerialNumber()); - var writer = new Thread(this::writer, "HIDWriter " + device.getSerialNumber()); - reader.setDaemon(true); - writer.setDaemon(true); - reader.start(); - writer.start(); + readerThread = new Thread(this::reader, "HIDReader " + device.getSerialNumber()); + writerThread = new Thread(this::writer, "HIDWriter " + device.getSerialNumber()); + readerThread.setDaemon(true); + writerThread.setDaemon(true); + readerThread.start(); + writerThread.start(); + } + + public void stopGracefully(long joinTimeoutMs) { + if (!stopping.compareAndSet(false, true)) { + return; + } + + queue.clear(); + if (readerThread != null) { + readerThread.interrupt(); + } + if (writerThread != null) { + writerThread.interrupt(); + } + + joinThread(readerThread, joinTimeoutMs); + joinThread(writerThread, joinTimeoutMs); + + debouncer.shutdown(); + rollingAverageSetter.shutdown(); + + try { + device.close(); + } catch (Exception e) { + log.debug("Error while closing HID device {}", key, e); + } + } + + private void joinThread(@Nullable Thread thread, long timeoutMs) { + if (thread == null) { + return; + } + try { + thread.join(timeoutMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.debug("Interrupted while waiting for {} to stop", thread.getName(), e); + } } public void sendMessage(byte[]... data) { @@ -82,8 +126,10 @@ public void reader() { switch (val) { case -1 -> { - log.error("DCH ERR: {}", device.getLastErrorMessage()); - deviceScanner.deviceRemoved(key, device); + if (!stopping.get()) { + log.error("DCH ERR: {}", device.getLastErrorMessage()); + deviceScanner.deviceRemoved(key, device); + } return; } case 0 -> { @@ -91,6 +137,9 @@ public void reader() { continue; } } + if (!isConnected()) { + return; + } interpretInputData(readUntilNotInitial != 0, data); } } @@ -104,16 +153,18 @@ private void writer() { sendMessageReal(toSend); } } catch (InterruptedException e) { - throw new RuntimeException(e); + if (stopping.get()) { + return; + } + Thread.currentThread().interrupt(); + return; } } - debouncer.shutdown(); - rollingAverageSetter.shutdown(); } private boolean isConnected() { //noinspection ObjectEquality - return deviceScanner.getConnectedDevice(key) == this; + return !stopping.get() && deviceScanner.getConnectedDevice(key) == this; } private void sendMessageReal(byte[] info) { @@ -164,7 +215,7 @@ private void triggerEvent(KnobRotateEvent o) { } else { prevSent.put(o.knob(), currentSendValue); log.debug("< {}", o); - eventPublisher.publishEvent(o); + eventBus.fire(o); } } @@ -174,7 +225,7 @@ private boolean applyWorkaround(int knob) { private void triggerEvent(ButtonPressEvent o) { log.debug("< {}", o); - eventPublisher.publishEvent(o); + eventBus.fire(o); } private void triggerOrDebounce(KnobRotateEvent event) { @@ -272,7 +323,7 @@ public RollingAverageSetter() { public void setKnob(KnobRotateEvent knob, Integer rollWindowMs) { this.rollWindowMs = rollWindowMs; - var target = targets.computeIfAbsent(knob.knob(), k -> new ArrayDeque<>()); + var target = targets.computeIfAbsent(knob.knob(), ignoredKnob -> new ArrayDeque<>()); synchronized (target) { if (knob.initial()) { triggerEvent(knob); diff --git a/src/main/java/com/getpcpanel/hid/DeviceCommunicationHandlerFactory.java b/src/main/java/com/getpcpanel/hid/DeviceCommunicationHandlerFactory.java index ea0dd171..da2ef50c 100644 --- a/src/main/java/com/getpcpanel/hid/DeviceCommunicationHandlerFactory.java +++ b/src/main/java/com/getpcpanel/hid/DeviceCommunicationHandlerFactory.java @@ -1,24 +1,26 @@ package com.getpcpanel.hid; import org.hid4java.HidDevice; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; +import jakarta.enterprise.event.Event; +import jakarta.inject.Inject; +import jakarta.enterprise.context.ApplicationScoped; import com.getpcpanel.device.DeviceType; import com.getpcpanel.profile.SaveService; -import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public class DeviceCommunicationHandlerFactory { - private final ApplicationEventPublisher eventPublisher; - private final DeviceScanner deviceScanner; - private final SaveService saveService; + @Inject + Event eventBus; + @Inject + DeviceScanner deviceScanner; + @Inject + SaveService saveService; public DeviceCommunicationHandler build(String key, HidDevice device, DeviceType deviceType) { - return new DeviceCommunicationHandler(deviceScanner, eventPublisher, saveService, key, device, deviceType); + return new DeviceCommunicationHandler(deviceScanner, saveService, eventBus, key, device, deviceType); } } diff --git a/src/main/java/com/getpcpanel/hid/DeviceHolder.java b/src/main/java/com/getpcpanel/hid/DeviceHolder.java index b5ece766..57140a15 100644 --- a/src/main/java/com/getpcpanel/hid/DeviceHolder.java +++ b/src/main/java/com/getpcpanel/hid/DeviceHolder.java @@ -1,37 +1,37 @@ package com.getpcpanel.hid; -import static org.springframework.core.Ordered.HIGHEST_PRECEDENCE; - import java.util.Collection; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.function.Predicate; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.annotation.Lazy; -import org.springframework.context.event.EventListener; -import org.springframework.core.annotation.Order; -import org.springframework.stereotype.Service; +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import com.getpcpanel.commands.Commands; +import com.getpcpanel.commands.command.Command; import com.getpcpanel.cpp.windows.WindowFocusChangedEvent; import com.getpcpanel.device.Device; import com.getpcpanel.device.DeviceFactory; import com.getpcpanel.device.DeviceType; import com.getpcpanel.profile.SaveService; -import javafx.application.Platform; import lombok.RequiredArgsConstructor; -import lombok.Setter; +import one.util.streamex.EntryStream; +import one.util.streamex.StreamEx; -@Service -@RequiredArgsConstructor +@ApplicationScoped public class DeviceHolder { private final Map devices = new ConcurrentHashMap<>(); - private final SaveService saveService; - @Autowired @Lazy @Setter private DeviceFactory deviceFactory; - private final OutputInterpreter outputInterpreter; - private final ApplicationEventPublisher eventPublisher; + @Inject SaveService saveService; + @Inject DeviceFactory deviceFactory; + @Inject OutputInterpreter outputInterpreter; + @Inject Event eventBus; public Optional getDevice(String key) { return Optional.ofNullable(devices.get(key)); @@ -45,9 +45,8 @@ public Collection values() { return devices.values(); } - @EventListener - @Order(HIGHEST_PRECEDENCE) - public void deviceAdded(DeviceScanner.DeviceConnectedEvent event) { + @Priority(1) + public void deviceAdded(@Observes DeviceScanner.DeviceConnectedEvent event) { Device device; var save = saveService.get(); if (!save.getDevices().containsKey(event.serialNum())) @@ -63,25 +62,22 @@ public void deviceAdded(DeviceScanner.DeviceConnectedEvent event) { } devices.put(event.serialNum(), device); outputInterpreter.sendInit(event.serialNum()); - eventPublisher.publishEvent(new DeviceFullyConnectedEvent(device)); + device.setLighting(device.lightingConfig(), true); + eventBus.fire(new DeviceFullyConnectedEvent(device)); } - @Order - @EventListener - public void onDeviceDisconnected(DeviceScanner.DeviceDisconnectedEvent event) { + public void onDeviceDisconnected(@Observes DeviceScanner.DeviceDisconnectedEvent event) { var device = devices.remove(event.serialNum()); if (device != null) { - Platform.runLater(device::disconnected); + device.disconnected(); } } - @EventListener(WindowFocusChangedEvent.class) - public void focusApplicationChanged() { + public void focusApplicationChanged(@Observes WindowFocusChangedEvent event) { devices.values().forEach(Device::focusApplicationChanged); } - @EventListener(SaveService.SaveEvent.class) - public void saveChanged() { + public void saveChanged(@Observes SaveService.SaveEvent event) { devices.values().forEach(Device::saveChanged); } @@ -89,6 +85,32 @@ public Collection all() { return devices.values(); } + private EntryStream buildCommandStream(Class clazz) { + return StreamEx.of(all()) + .mapToEntry(Device::getSerialNumber).invert() + .mapValues(d -> d.currentProfile()) + .flatMapKeyValue((id, profile) -> EntryStream.of(profile.getDialData()).mapKeys(d -> new DeviceAndDial(id, d))) + .mapToEntry(Map.Entry::getKey, Map.Entry::getValue) + .flatMapValues(d -> Commands.cmds(d).stream()) + .selectValues(clazz); + } + + public boolean hasCommandsOf(Class clazz, Predicate filter) { + return buildCommandStream(clazz).values().anyMatch(filter); + } + + public void triggerCommandsOf(Class clazz, Function, EntryStream> chain) { + buildCommandStream(clazz) + .chain(chain) + .forKeyValue((idAndDial, cmd) -> getDevice(idAndDial.id()).ifPresent(device -> { + var current = device.getKnobRotation(idAndDial.dial()); + eventBus.fire(new DeviceCommunicationHandler.KnobRotateEvent(idAndDial.id(), idAndDial.dial(), current, false)); + })); + } + + public record DeviceAndDial(String id, int dial) { + } + public record DeviceFullyConnectedEvent(Device device) { } } diff --git a/src/main/java/com/getpcpanel/hid/DeviceScanner.java b/src/main/java/com/getpcpanel/hid/DeviceScanner.java index dd0a743d..5ccbef42 100644 --- a/src/main/java/com/getpcpanel/hid/DeviceScanner.java +++ b/src/main/java/com/getpcpanel/hid/DeviceScanner.java @@ -1,7 +1,9 @@ package com.getpcpanel.hid; import java.util.Optional; +import java.util.ArrayList; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; import org.hid4java.HidDevice; import org.hid4java.HidManager; @@ -10,25 +12,27 @@ import org.hid4java.HidServicesSpecification; import org.hid4java.ScanMode; import org.hid4java.event.HidServicesEvent; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.annotation.Lazy; -import org.springframework.stereotype.Service; import com.getpcpanel.device.DeviceType; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import io.quarkus.runtime.ShutdownEvent; +import io.quarkus.runtime.StartupEvent; import lombok.NonNull; -import lombok.RequiredArgsConstructor; -import lombok.Setter; import lombok.extern.log4j.Log4j2; +import one.util.streamex.StreamEx; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public class DeviceScanner implements HidServicesListener { private final ConcurrentHashMap connectedDeviceMap = new ConcurrentHashMap<>(); - private final ApplicationEventPublisher eventPublisher; - @Autowired @Lazy @Setter private DeviceCommunicationHandlerFactory deviceCommunicationHandlerFactory; + private final AtomicBoolean shuttingDown = new AtomicBoolean(false); + private static final long HANDLER_JOIN_TIMEOUT_MS = 1000; + @Inject Event eventBus; + @Inject DeviceCommunicationHandlerFactory deviceCommunicationHandlerFactory; private HidServices hidServices; @@ -36,13 +40,35 @@ public DeviceCommunicationHandler getConnectedDevice(String key) { return connectedDeviceMap.get(key); } - // Not @PostConstruct because the HomePage must have loaded before + // Not @PostConstruct because the startup sequence needs to control when this runs + public void onStart(@Observes StartupEvent ev) { + try { + init(); + } catch (Throwable e) { + log.error("Failed to initialize HID services – device scanning will be unavailable: {}", e.getMessage(), e); + } + } + + public void onShutdown(@Observes ShutdownEvent event) { + close(); + } + public void init() { hidServices = HidManager.getHidServices(buildSpecification()); hidServices.addHidServicesListener(this); log.info("Starting HID services."); hidServices.start(); - log.info("Enumerating attached devices..."); + log.info("Enumerating attached devices...."); + + if (!shuttingDown.compareAndSet(true, false)) { + reconnectDevicesAfterRestart(); + } + } + + private void reconnectDevicesAfterRestart() { + StreamEx.of(hidServices.getAttachedHidDevices()) + .mapToEntry(this::determineType).flatMapValues(Optional::stream) + .forKeyValue(this::foundPCPanel); } static HidServicesSpecification buildSpecification() { @@ -64,18 +90,21 @@ public void deviceAdded(@NonNull String key, @NonNull HidDevice device, DeviceTy var deviceHandler = deviceCommunicationHandlerFactory.build(key, device, deviceType); connectedDeviceMap.put(key, deviceHandler); deviceHandler.start(); - eventPublisher.publishEvent(new DeviceConnectedEvent(key, deviceType)); + fireEvent(new DeviceConnectedEvent(key, deviceType)); } public void deviceRemoved(String key, HidDevice device) { if (key == null || device == null) throw new IllegalArgumentException("serialNum or device cannot be null serialNum: " + key + " device: " + device); if (connectedDeviceMap.remove(key) != null) - eventPublisher.publishEvent(new DeviceDisconnectedEvent(key)); + fireEvent(new DeviceDisconnectedEvent(key)); } private void foundPCPanel(HidDevice newPCPanel, DeviceType deviceType) { log.info("FOUND PCPANEL : {}", newPCPanel); + if (!newPCPanel.isOpen()) + newPCPanel.open(); + try { deviceAdded(newPCPanel.getSerialNumber(), newPCPanel, deviceType); } catch (Exception e) { @@ -94,32 +123,60 @@ private void lostPCPanel(HidDevice lostPCPanel) { @Override public void hidDeviceAttached(HidServicesEvent event) { - determineType(event).ifPresent(type -> foundPCPanel(event.getHidDevice(), type)); + determineType(event.getHidDevice()).ifPresent(type -> foundPCPanel(event.getHidDevice(), type)); } @Override public void hidDeviceDetached(HidServicesEvent event) { - determineType(event).ifPresent(type -> lostPCPanel(event.getHidDevice())); + if (determineType(event.getHidDevice()).isPresent()) { + lostPCPanel(event.getHidDevice()); + } } @Override public void hidFailure(HidServicesEvent event) { - determineType(event).ifPresent(type -> lostPCPanel(event.getHidDevice())); + if (determineType(event.getHidDevice()).isPresent()) { + lostPCPanel(event.getHidDevice()); + } } - private Optional determineType(HidServicesEvent event) { + private Optional determineType(HidDevice device) { for (var deviceType : DeviceType.ALL) { - if (event.getHidDevice().isVidPidSerial(deviceType.getVid(), deviceType.getPid(), null)) + if (device.isVidPidSerial(deviceType.getVid(), deviceType.getPid(), null)) return Optional.of(deviceType); } return Optional.empty(); } public void close() { + if (!shuttingDown.compareAndSet(false, true)) { + return; + } + + var handlers = new ArrayList<>(connectedDeviceMap.values()); + connectedDeviceMap.clear(); + for (var handler : handlers) { + try { + handler.stopGracefully(HANDLER_JOIN_TIMEOUT_MS); + } catch (Exception e) { + log.debug("Error while stopping handler during shutdown.", e); + } + } + try { - hidServices.shutdown(); + if (hidServices != null) { + hidServices.removeHidServicesListener(this); + hidServices.shutdown(); + hidServices = null; + } } catch (Exception e) { - log.error("Error occurred when closing device", e); + log.error("Error occurred when closing device!", e); + } + } + + public void fireEvent(Object event) { + if (!shuttingDown.get()) { + eventBus.fire(event); } } diff --git a/src/main/java/com/getpcpanel/hid/DialValue.java b/src/main/java/com/getpcpanel/hid/DialValue.java index 2da1f5c6..8613d00e 100644 --- a/src/main/java/com/getpcpanel/hid/DialValue.java +++ b/src/main/java/com/getpcpanel/hid/DialValue.java @@ -3,7 +3,7 @@ import javax.annotation.Nullable; import com.getpcpanel.commands.command.Command; -import com.getpcpanel.profile.KnobSetting; +import com.getpcpanel.profile.dto.KnobSetting; public record DialValue( DialValueCalculator settings, diff --git a/src/main/java/com/getpcpanel/hid/DialValueCalculator.java b/src/main/java/com/getpcpanel/hid/DialValueCalculator.java index 91bd2d44..c8ba90a6 100644 --- a/src/main/java/com/getpcpanel/hid/DialValueCalculator.java +++ b/src/main/java/com/getpcpanel/hid/DialValueCalculator.java @@ -7,7 +7,7 @@ import com.getpcpanel.commands.command.Command; import com.getpcpanel.commands.command.DialAction; import com.getpcpanel.commands.command.DialAction.DialCommandParams; -import com.getpcpanel.profile.KnobSetting; +import com.getpcpanel.profile.dto.KnobSetting; public class DialValueCalculator { public static final double EXP_CONST = 1.04723275; // This will make 0-100 map to 1-101 exponentially diff --git a/src/main/java/com/getpcpanel/hid/InputInterpreter.java b/src/main/java/com/getpcpanel/hid/InputInterpreter.java index 43bb81d1..07bed28e 100644 --- a/src/main/java/com/getpcpanel/hid/InputInterpreter.java +++ b/src/main/java/com/getpcpanel/hid/InputInterpreter.java @@ -9,9 +9,10 @@ import java.util.Map; import java.util.concurrent.TimeUnit; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; +import jakarta.enterprise.event.Event; +import jakarta.inject.Inject; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.context.ApplicationScoped; import com.getpcpanel.commands.PCPanelControlEvent; import com.getpcpanel.device.DeviceType; @@ -22,20 +23,22 @@ import lombok.extern.log4j.Log4j2; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public final class InputInterpreter { - private final SaveService save; - private final DeviceHolder devices; - private final ApplicationEventPublisher eventPublisher; - private final Debouncer debouncer; + @Inject + SaveService save; + @Inject + DeviceHolder devices; + @Inject + Event eventBus; + @Inject + Debouncer debouncer; private final Map lastClicks = new HashMap<>(); - @EventListener - public void onKnobRotate(DeviceCommunicationHandler.KnobRotateEvent event) { + public void onKnobRotate(@Observes DeviceCommunicationHandler.KnobRotateEvent event) { devices.getDevice(event.serialNum()).ifPresent(device -> { var value = event.value(); - if (device.getDeviceType() == DeviceType.PCPANEL_RGB) { + if (device.deviceType() == DeviceType.PCPANEL_RGB) { value = map(value, 0, 100, 0, 255); } device.setKnobRotation(event.knob(), value); @@ -44,15 +47,14 @@ public void onKnobRotate(DeviceCommunicationHandler.KnobRotateEvent event) { }); } - @EventListener - public void onButtonPress(DeviceCommunicationHandler.ButtonPressEvent event) throws IOException { + public void onButtonPress(@Observes DeviceCommunicationHandler.ButtonPressEvent event) throws IOException { devices.getDevice(event.serialNum()).ifPresent(device -> device.setButtonPressed(event.button(), event.pressed())); if (event.pressed()) doClickAction(event.serialNum(), event.button()); } private void doDialAction(String serialNum, boolean initial, int knob, DialValue v) { - save.getProfile(serialNum).map(p -> p.getDialData(knob)).ifPresent(data -> eventPublisher.publishEvent(new PCPanelControlEvent(serialNum, knob, data, initial, v))); + save.getProfile(serialNum).map(p -> p.getDialData(knob)).ifPresent(data -> eventBus.fire(new PCPanelControlEvent(serialNum, knob, data, initial, v))); } private void doClickAction(String serialNum, int knob) { @@ -68,13 +70,13 @@ private void determineClick(ClickId clickId, long timeDiff) { if (isDblClick) { debouncer.debounce(clickId, () -> { }, debounceTime, TimeUnit.MILLISECONDS); - eventPublisher.publishEvent(new ButtonClickEvent(clickId.serialNum(), clickId.button(), true)); + eventBus.fire(new ButtonClickEvent(clickId.serialNum(), clickId.button(), true)); lastClicks.remove(clickId); return; } lastClicks.put(clickId, System.currentTimeMillis()); - Runnable trigger = () -> eventPublisher.publishEvent(new ButtonClickEvent(clickId.serialNum(), clickId.button(), false)); + Runnable trigger = () -> eventBus.fire(new ButtonClickEvent(clickId.serialNum(), clickId.button(), false)); if (save.get().isPreventClickWhenDblClick()) { debouncer.debounce(clickId, trigger, debounceTime, TimeUnit.MILLISECONDS); } else { @@ -82,16 +84,15 @@ private void determineClick(ClickId clickId, long timeDiff) { } } - @EventListener - public void onButtonPress(ButtonClickEvent event) { + public void onButtonPress(@Observes ButtonClickEvent event) { save.getProfile(event.serialNum()).ifPresent(profile -> { var click = profile.getButtonData(event.button()); var dblClick = profile.getDblButtonData(event.button()); if (event.dblClick() && hasCommands(dblClick)) { - eventPublisher.publishEvent(new PCPanelControlEvent(event.serialNum(), event.button(), dblClick, false, null)); + eventBus.fire(new PCPanelControlEvent(event.serialNum(), event.button(), dblClick, false, null)); } else if (!event.dblClick() && hasCommands(click)) { - eventPublisher.publishEvent(new PCPanelControlEvent(event.serialNum(), event.button(), click, false, null)); + eventBus.fire(new PCPanelControlEvent(event.serialNum(), event.button(), click, false, null)); } }); } diff --git a/src/main/java/com/getpcpanel/hid/OutputInterpreter.java b/src/main/java/com/getpcpanel/hid/OutputInterpreter.java index 31e5561d..f027db26 100644 --- a/src/main/java/com/getpcpanel/hid/OutputInterpreter.java +++ b/src/main/java/com/getpcpanel/hid/OutputInterpreter.java @@ -2,26 +2,25 @@ import java.util.Arrays; -import org.springframework.stereotype.Service; - import com.getpcpanel.device.DeviceType; -import com.getpcpanel.profile.LightingConfig; -import com.getpcpanel.profile.SingleKnobLightingConfig; -import com.getpcpanel.profile.SingleLogoLightingConfig; -import com.getpcpanel.profile.SingleSliderLabelLightingConfig; -import com.getpcpanel.profile.SingleSliderLightingConfig; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.profile.dto.SingleKnobLightingConfig; +import com.getpcpanel.profile.dto.SingleLogoLightingConfig; +import com.getpcpanel.profile.dto.SingleSliderLabelLightingConfig; +import com.getpcpanel.profile.dto.SingleSliderLightingConfig; import com.getpcpanel.util.coloroverride.OverrideColorService; -import javafx.scene.paint.Color; -import lombok.RequiredArgsConstructor; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import lombok.extern.log4j.Log4j2; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public final class OutputInterpreter { - private final DeviceScanner deviceScanner; - private final OverrideColorService overrideColorService; + @Inject + DeviceScanner deviceScanner; + @Inject + OverrideColorService overrideColorService; private static final byte[] OUTPUT_CODE_INIT = { 1 }; private static final byte ANIMATION_RAINBOW_HORIZONTAL = 1; @@ -61,7 +60,7 @@ public void sendFullLEDData(String deviceSerialNumber, int brightness, String[] var data = new ByteWriter(brightness, 2 + 4 * colors.length + colors.length).append(2, 0); for (var color : colors) { var toSend = overrideColorService.getDialOverride(deviceSerialNumber, 0).map(SingleKnobLightingConfig::getColor1).orElse(color); - data.append(OUTPUT_CODE_RGB_RGB).append(Color.valueOf(toSend)); + data.append(OUTPUT_CODE_RGB_RGB).appendHex(toSend); } for (var b : volumeTrack) { data.append(b ? 1 : 0); @@ -86,14 +85,18 @@ public void sendLightingConfig(String serialNumber, DeviceType dt, LightingConfi private void sendLightingConfigMini(String serialNumber, LightingConfig config) { var handler = deviceScanner.getConnectedDevice(serialNumber); - var mode = config.getLightingMode(); + var mode = config.lightingMode(); + if (mode == null) { + log.error("Null lighting mode in sendLightingConfigMini, ignoring"); + return; + } switch (mode) { case ALL_COLOR -> writeAllColor(handler, PREFIX_MINI, (byte) 5, config); case ALL_RAINBOW -> writeAllRainbow(handler, PREFIX_MINI, config); case ALL_WAVE -> writeAllWave(handler, PREFIX_MINI, config); case ALL_BREATH -> writeAllBreath(handler, PREFIX_MINI, config); case CUSTOM -> { - var knobData = buildKnobData(serialNumber, PREFIX_MINI, config.getGlobalBrightness(), config.getKnobConfigs()); + var knobData = buildKnobData(serialNumber, PREFIX_MINI, config.getGlobalBrightness(), config.knobConfigs()); handler.sendMessage(new byte[][] { knobData }); } } @@ -101,35 +104,39 @@ private void sendLightingConfigMini(String serialNumber, LightingConfig config) private void sendLightingConfigPro(String serialNumber, LightingConfig config) { var handler = deviceScanner.getConnectedDevice(serialNumber); - var mode = config.getLightingMode(); + var mode = config.lightingMode(); + if (mode == null) { + log.error("Null lighting mode in sendLightingConfigPro, ignoring"); + return; + } switch (mode) { case ALL_COLOR -> writeAllColor(handler, PREFIX_PRO, (byte) 2, config); case ALL_RAINBOW -> writeAllRainbow(handler, PREFIX_PRO, config); case ALL_WAVE -> writeAllWave(handler, PREFIX_PRO, config); case ALL_BREATH -> writeAllBreath(handler, PREFIX_PRO, config); case CUSTOM -> { - var knobData = buildKnobData(serialNumber, PREFIX_PRO, config.getGlobalBrightness(), config.getKnobConfigs()); - var sliderLabelData = buildSliderLabelData(serialNumber, config.getGlobalBrightness(), config.getSliderLabelConfigs()); - var sliderData = buildSliderData(serialNumber, config.getGlobalBrightness(), config.getSliderConfigs()); - var logoData = buildLogoData(serialNumber, config.getGlobalBrightness(), config.getLogoConfig()); + var knobData = buildKnobData(serialNumber, PREFIX_PRO, config.getGlobalBrightness(), config.knobConfigs()); + var sliderLabelData = buildSliderLabelData(serialNumber, config.getGlobalBrightness(), config.sliderLabelConfigs()); + var sliderData = buildSliderData(serialNumber, config.getGlobalBrightness(), config.sliderConfigs()); + var logoData = buildLogoData(serialNumber, config.getGlobalBrightness(), config.logoConfig()); handler.sendMessage(knobData, sliderLabelData, sliderData, logoData); } } } private void writeAllColor(DeviceCommunicationHandler handler, byte prefix, byte secondPrefix, LightingConfig config) { - var c1 = Color.valueOf(config.getAllColor()); - var data = new ByteWriter(config.getGlobalBrightness()).append(prefix, MODE_LIGHT_ANIMATION, secondPrefix).append(c1).get(); + var c1 = config.allColor(); + var data = new ByteWriter(config.getGlobalBrightness()).append(prefix, MODE_LIGHT_ANIMATION, secondPrefix).appendHex(c1).get(); handler.sendMessage(new byte[][] { data }); } private void writeAllRainbow(DeviceCommunicationHandler handler, byte prefix, LightingConfig config) { - var data = new ByteWriter(config.getGlobalBrightness()).append(prefix, MODE_LIGHT_ANIMATION, (config.getRainbowVertical() == 1) ? ANIMATION_RAINBOW_VERTICAL : ANIMATION_RAINBOW_HORIZONTAL) - .append(config.getRainbowPhaseShift(), + var data = new ByteWriter(config.getGlobalBrightness()).append(prefix, MODE_LIGHT_ANIMATION, (config.rainbowVertical() == 1) ? ANIMATION_RAINBOW_VERTICAL : ANIMATION_RAINBOW_HORIZONTAL) + .append(config.rainbowPhaseShift(), -1) - .appendBrightness(config.getRainbowBrightness()) - .append(config.getRainbowSpeed(), - config.getRainbowReverse()) + .appendBrightness(config.rainbowBrightness()) + .append(config.rainbowSpeed(), + config.rainbowReverse()) .get(); handler.sendMessage(new byte[][] { data }); } @@ -137,22 +144,22 @@ private void writeAllRainbow(DeviceCommunicationHandler handler, byte prefix, Li private void writeAllWave(DeviceCommunicationHandler handler, byte prefix, LightingConfig config) { var data = new ByteWriter(config.getGlobalBrightness()) .append(prefix, MODE_LIGHT_ANIMATION, ANIMATION_WAVE) - .append(config.getWaveHue(), + .append(config.waveHue(), -1) - .appendBrightness(config.getWaveBrightness()) - .append(config.getWaveSpeed(), - config.getWaveReverse(), - config.getWaveBounce()); + .appendBrightness(config.waveBrightness()) + .append(config.waveSpeed(), + config.waveReverse(), + config.waveBounce()); handler.sendMessage(new byte[][] { data.get() }); } private void writeAllBreath(DeviceCommunicationHandler handler, byte prefix, LightingConfig config) { var data = new ByteWriter(config.getGlobalBrightness()) .append(prefix, MODE_LIGHT_ANIMATION, ANIMATION_BREATH) - .append(config.getBreathHue(), + .append(config.breathHue(), -1) - .appendBrightness(config.getBreathBrightness()) - .append(config.getBreathSpeed()); + .appendBrightness(config.breathBrightness()) + .append(config.breathSpeed()); handler.sendMessage(new byte[][] { data.get() }); } @@ -166,16 +173,16 @@ private byte[] buildKnobData(String deviceSerial, byte prefix, int brightness, S var ignored = switch (knobConfig.getMode()) { case NONE -> knobData; case STATIC -> { - var c1 = Color.valueOf(knobConfig.getColor1()); + var c1 = knobConfig.getColor1(); yield knobData.append(COLOR_STATIC) - .append(c1); + .appendHex(c1); } case VOLUME_GRADIENT -> { - var c1 = Color.valueOf(knobConfig.getColor1()); - var c2 = Color.valueOf(knobConfig.getColor2()); + var c1 = knobConfig.getColor1(); + var c2 = knobConfig.getColor2(); yield knobData.append(COLOR_GRADIENT) - .append(c1) - .append(c2); + .appendHex(c1) + .appendHex(c2); } }; knobData.skipFromMark(7); @@ -191,10 +198,10 @@ private byte[] buildSliderLabelData(String deviceSerial, int brightness, SingleS var ignored = switch (sliderLabelConfig.getMode()) { case NONE -> sliderLabelData; case STATIC -> { - var c1 = Color.valueOf(sliderLabelConfig.getColor()); + var c1 = sliderLabelConfig.getColor(); yield sliderLabelData.mark() .append(1) - .append(c1); + .appendHex(c1); } }; sliderLabelData.skipFromMark(7); @@ -211,17 +218,17 @@ private byte[] buildSliderData(String deviceSerial, int brightness, SingleSlider var ignored = switch (sliderConfig.getMode()) { case NONE -> sliderData; case STATIC -> { - var c1 = Color.valueOf(sliderConfig.getColor1()); + var c1 = sliderConfig.getColor1(); yield sliderData.append(1) - .append(c1) - .append(c1); + .appendHex(c1) + .appendHex(c1); } case STATIC_GRADIENT -> sliderData.append(1) - .append(Color.valueOf(sliderConfig.getColor1())) - .append(Color.valueOf(sliderConfig.getColor2())); + .appendHex(sliderConfig.getColor1()) + .appendHex(sliderConfig.getColor2()); case VOLUME_GRADIENT -> sliderData.append(3) - .append(Color.valueOf(sliderConfig.getColor1())) - .append(Color.valueOf(sliderConfig.getColor2())); + .appendHex(sliderConfig.getColor1()) + .appendHex(sliderConfig.getColor2()); }; sliderData.skipFromMark(7); } @@ -234,8 +241,8 @@ private byte[] buildLogoData(String deviceSerial, int brightness, SingleLogoLigh var ignored = switch (logoConfig.getMode()) { case NONE -> logoConfig; case STATIC -> { - var c1 = Color.valueOf(logoConfig.getColor()); - yield logoData.append(COLOR_STATIC).append(c1); + var c1 = logoConfig.getColor(); + yield logoData.append(COLOR_STATIC).appendHex(c1); } case RAINBOW -> logoData.append(LOGO_RAINBOW) .append(-1) @@ -251,18 +258,18 @@ private byte[] buildLogoData(String deviceSerial, int brightness, SingleLogoLigh } private void sendLightingConfigRGB(String serialNumber, LightingConfig config, boolean priority) { - var mode = config.getLightingMode(); + var mode = config.lightingMode(); if (mode == null) { log.error("unexpected lighting mode in deviceOutputHandler"); return; } switch (mode) { - case ALL_COLOR -> sendRGBAll(serialNumber, config.getGlobalBrightness(), Color.valueOf(config.getAllColor()), config.getVolumeBrightnessTrackingEnabled(), priority); - case SINGLE_COLOR -> sendFullLEDData(serialNumber, config.getGlobalBrightness(), config.getIndividualColors(), config.getVolumeBrightnessTrackingEnabled(), priority); - case ALL_RAINBOW -> sendRainbow(serialNumber, config.getRainbowPhaseShift(), (byte) -1, config.getRainbowBrightness(), config.getRainbowSpeed(), config.getRainbowReverse(), priority); - case ALL_WAVE -> sendWave(serialNumber, config.getWaveHue(), (byte) -1, config.getWaveBrightness(), config.getWaveSpeed(), config.getWaveReverse(), config.getWaveBounce(), priority); - case ALL_BREATH -> sendBreath(serialNumber, config.getBreathHue(), (byte) -1, config.getBreathBrightness(), config.getBreathSpeed(), priority); + case ALL_COLOR -> sendRGBAll(serialNumber, config.getGlobalBrightness(), config.allColor(), config.volumeBrightnessTrackingEnabled(), priority); + case SINGLE_COLOR -> sendFullLEDData(serialNumber, config.getGlobalBrightness(), config.individualColors(), config.volumeBrightnessTrackingEnabled(), priority); + case ALL_RAINBOW -> sendRainbow(serialNumber, config.rainbowPhaseShift(), (byte) -1, config.rainbowBrightness(), config.rainbowSpeed(), config.rainbowReverse(), priority); + case ALL_WAVE -> sendWave(serialNumber, config.waveHue(), (byte) -1, config.waveBrightness(), config.waveSpeed(), config.waveReverse(), config.waveBounce(), priority); + case ALL_BREATH -> sendBreath(serialNumber, config.breathHue(), (byte) -1, config.breathBrightness(), config.breathSpeed(), priority); default -> log.error("unexpected lighting mode in deviceOutputHandler"); } } @@ -304,8 +311,18 @@ public void sendBreath(String deviceSerialNumber, byte hue, byte saturation, byt } @SuppressWarnings("NumericCastThatLosesPrecision") - public void sendRGBAll(String deviceSerialNumber, int brightness, Color color, boolean[] bs, boolean priority) { - sendRGBAll(deviceSerialNumber, brightness, (int) (color.getRed() * MAX_BYTE), (int) (color.getGreen() * MAX_BYTE), (int) (color.getBlue() * MAX_BYTE), bs, priority); + public void sendRGBAll(String deviceSerialNumber, int brightness, String hexColor, boolean[] bs, boolean priority) { + int r = 0, g = 0, b = 0; + if (hexColor != null) { + try { + String hex = hexColor.startsWith("#") ? hexColor.substring(1) : hexColor; + r = Integer.parseInt(hex.substring(0, 2), 16); + g = Integer.parseInt(hex.substring(2, 4), 16); + b = Integer.parseInt(hex.substring(4, 6), 16); + } catch (Exception ignored) { + } + } + sendRGBAll(deviceSerialNumber, brightness, r, g, b, bs, priority); } public void sendRGBAll(String deviceSerialNumber, int brightness, int red, int green, int blue, boolean[] volumeTrack, boolean priority) { @@ -316,7 +333,7 @@ public void sendRGBAll(String deviceSerialNumber, int brightness, int red, int g throw new IllegalArgumentException("ints must be byte size"); var data = new ByteWriter(brightness, 6 + volumeTrack.length) .append(OUTPUT_CODE_RGB, OUTPUT_CODE_RGB_RGB, 0) - .append(Color.rgb(red, green, blue)); + .appendRGB(red, green, blue); for (var b : volumeTrack) data.append(b ? 1 : 0); if (priority) { diff --git a/src/main/java/com/getpcpanel/iconextract/IIconService.java b/src/main/java/com/getpcpanel/iconextract/IIconService.java index 770deafa..6fc7579c 100644 --- a/src/main/java/com/getpcpanel/iconextract/IIconService.java +++ b/src/main/java/com/getpcpanel/iconextract/IIconService.java @@ -3,24 +3,9 @@ import java.awt.image.BufferedImage; import java.io.File; -import javax.annotation.Nullable; - -import org.springframework.cache.annotation.Cacheable; - -import javafx.embed.swing.SwingFXUtils; -import javafx.scene.image.Image; +import jakarta.annotation.Nullable; public interface IIconService { - @Cacheable("icon") - BufferedImage getIconForFile(int width, int height, File file); - @Nullable - @Cacheable("icon") - default Image getIconImageForFile(int width, int height, File file) { - var image = getIconForFile(width, height, file); - if (image != null) { - return SwingFXUtils.toFXImage(image, null); - } - return null; - } + BufferedImage getIconForFile(int width, int height, File file); } diff --git a/src/main/java/com/getpcpanel/iconextract/IconServiceLinux.java b/src/main/java/com/getpcpanel/iconextract/IconServiceLinux.java index c83565c2..a084cd43 100644 --- a/src/main/java/com/getpcpanel/iconextract/IconServiceLinux.java +++ b/src/main/java/com/getpcpanel/iconextract/IconServiceLinux.java @@ -3,15 +3,16 @@ import java.awt.image.BufferedImage; import java.io.File; -import org.springframework.stereotype.Service; +import com.getpcpanel.platform.LinuxBuild; -import com.getpcpanel.spring.ConditionalOnLinux; +import jakarta.enterprise.context.ApplicationScoped; -@Service -@ConditionalOnLinux +@ApplicationScoped +@LinuxBuild public class IconServiceLinux implements IIconService { @Override public BufferedImage getIconForFile(int width, int height, File file) { return null; } } + diff --git a/src/main/java/com/getpcpanel/iconextract/IconServiceWindows.java b/src/main/java/com/getpcpanel/iconextract/IconServiceWindows.java index bf779ecd..5af655d6 100644 --- a/src/main/java/com/getpcpanel/iconextract/IconServiceWindows.java +++ b/src/main/java/com/getpcpanel/iconextract/IconServiceWindows.java @@ -3,15 +3,16 @@ import java.awt.image.BufferedImage; import java.io.File; -import org.springframework.stereotype.Service; +import com.getpcpanel.platform.WindowsBuild; -import com.getpcpanel.spring.ConditionalOnWindows; +import jakarta.enterprise.context.ApplicationScoped; -@Service -@ConditionalOnWindows +@ApplicationScoped +@WindowsBuild public class IconServiceWindows implements IIconService { @Override public BufferedImage getIconForFile(int width, int height, File file) { return JIconExtract.getIconForFile(width, height, file); } } + diff --git a/src/main/java/com/getpcpanel/mqtt/MqttDeviceColorService.java b/src/main/java/com/getpcpanel/mqtt/MqttDeviceColorService.java index 5486f4fd..a0cb1a10 100644 --- a/src/main/java/com/getpcpanel/mqtt/MqttDeviceColorService.java +++ b/src/main/java/com/getpcpanel/mqtt/MqttDeviceColorService.java @@ -6,6 +6,7 @@ import static com.getpcpanel.mqtt.MqttTopicHelper.ColorType.slider; import static com.getpcpanel.mqtt.MqttTopicHelper.ValueType.brightness; import static com.getpcpanel.util.Util.parseColor; +import static com.getpcpanel.util.Util.parseColorComponents; import java.util.function.Consumer; import java.util.function.Function; @@ -16,40 +17,52 @@ import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.math.NumberUtils; -import org.apache.logging.log4j.util.TriConsumer; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.core.annotation.Order; -import org.springframework.stereotype.Service; import com.getpcpanel.device.Device; -import com.getpcpanel.profile.LightingConfig; -import com.getpcpanel.profile.SingleKnobLightingConfig; -import com.getpcpanel.profile.SingleLogoLightingConfig; -import com.getpcpanel.profile.SingleSliderLabelLightingConfig; -import com.getpcpanel.profile.SingleSliderLightingConfig; -import com.getpcpanel.ui.HomePage; +import com.getpcpanel.device.GlobalBrightnessChangedEvent; +import com.getpcpanel.mqtt.MqttTopicHelper.ColorType; +import com.getpcpanel.mqtt.MqttTopicHelper.DeviceMqttTopicHelper; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.profile.dto.SingleKnobLightingConfig; +import com.getpcpanel.profile.dto.SingleKnobLightingConfig.SINGLE_KNOB_MODE; +import com.getpcpanel.profile.dto.SingleLogoLightingConfig; +import com.getpcpanel.profile.dto.SingleLogoLightingConfig.SINGLE_LOGO_MODE; +import com.getpcpanel.profile.dto.SingleSliderLabelLightingConfig; +import com.getpcpanel.profile.dto.SingleSliderLabelLightingConfig.SINGLE_SLIDER_LABEL_MODE; +import com.getpcpanel.profile.dto.SingleSliderLightingConfig; +import com.getpcpanel.profile.dto.SingleSliderLightingConfig.SINGLE_SLIDER_MODE; import com.getpcpanel.util.coloroverride.ColorOverrideHolder; import com.getpcpanel.util.coloroverride.IOverrideColorProvider; import com.getpcpanel.util.coloroverride.IOverrideColorProviderProvider; -import javafx.scene.paint.Color; -import lombok.RequiredArgsConstructor; +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; +import jakarta.inject.Inject; import lombok.extern.log4j.Log4j2; import one.util.streamex.EntryStream; import one.util.streamex.StreamEx; @Log4j2 -@Service -@Order(1) -@RequiredArgsConstructor +@ApplicationScoped +@Priority(1) public class MqttDeviceColorService implements IOverrideColorProviderProvider { + + @FunctionalInterface + interface TriFunction { + R apply(A a, B b, C c); + } + public static final String EFFECT_NONE = "none"; public static final String EFFECT_STOP_OVERRIDE = "stop_override"; - private final MqttService mqtt; - private final MqttTopicHelper mqttTopicHelper; + @Inject + MqttService mqtt; + @Inject + MqttTopicHelper mqttTopicHelper; private final ColorOverrideHolder colorOverrideHolder = new MqttColorOverrideHolder(); - private final ApplicationEventPublisher applicationEventPublisher; + @Inject + Event eventBus; @Override public IOverrideColorProvider getOverrideColorProvider() { @@ -59,10 +72,10 @@ public IOverrideColorProvider getOverrideColorProvider() { public void sendColor(String topic, @Nonnull String colorString, boolean immediate) { mqtt.send(topic, colorString, immediate); - var setColor = parseColor(colorString).orElse(Color.BLACK); - var r = Math.round(setColor.getRed() * 255); - var g = Math.round(setColor.getGreen() * 255); - var b = Math.round(setColor.getBlue() * 255); + var colorComponents = parseColorComponents(colorString); + int r = colorComponents != null ? colorComponents[0] : 0; + int g = colorComponents != null ? colorComponents[1] : 0; + int b = colorComponents != null ? colorComponents[2] : 0; // Home assistant seems to think that the brightness is the highest value of the RGB var brightness = Math.max(r, Math.max(g, b)); @@ -80,20 +93,23 @@ public void sendColor(String topic, @Nonnull String colorString, boolean immedia public void buildSubscriptions(Device device, LightingConfig lighting) { var topicHelper = mqttTopicHelper.device(device.getSerialNumber()); Runnable andThen = () -> device.setLighting(lighting, true); - TriConsumer knobOverride = (idx, payload, knob) -> { - colorOverrideHolder.setDialOverride(device.getSerialNumber(), idx, new SingleKnobLightingConfig().setMode(SingleKnobLightingConfig.SINGLE_KNOB_MODE.STATIC).setColor1(payload)); + TriFunction knobOverride = (idx, payload, knob) -> { + colorOverrideHolder.setDialOverride(device.getSerialNumber(), idx, new SingleKnobLightingConfig().setMode(SINGLE_KNOB_MODE.STATIC).setColor1(payload)); andThen.run(); + return null; }; - TriConsumer sliderOverride = (idx, payload, knob) -> { - colorOverrideHolder.setSliderOverride(device.getSerialNumber(), idx, new SingleSliderLightingConfig().setMode(SingleSliderLightingConfig.SINGLE_SLIDER_MODE.STATIC).setColor1(payload)); + TriFunction sliderOverride = (idx, payload, knob) -> { + colorOverrideHolder.setSliderOverride(device.getSerialNumber(), idx, new SingleSliderLightingConfig().setMode(SINGLE_SLIDER_MODE.STATIC).setColor1(payload)); andThen.run(); + return null; }; - TriConsumer sliderLabelOverride = (idx, payload, knob) -> { - colorOverrideHolder.setSliderLabelOverride(device.getSerialNumber(), idx, new SingleSliderLabelLightingConfig().setMode(SingleSliderLabelLightingConfig.SINGLE_SLIDER_LABEL_MODE.STATIC).setColor(payload)); + TriFunction sliderLabelOverride = (idx, payload, knob) -> { + colorOverrideHolder.setSliderLabelOverride(device.getSerialNumber(), idx, new SingleSliderLabelLightingConfig().setMode(SINGLE_SLIDER_LABEL_MODE.STATIC).setColor(payload)); andThen.run(); + return null; }; Consumer logoOverride = payload -> { - colorOverrideHolder.setLogoOverride(device.getSerialNumber(), new SingleLogoLightingConfig().setMode(SingleLogoLightingConfig.SINGLE_LOGO_MODE.STATIC).setColor(payload)); + colorOverrideHolder.setLogoOverride(device.getSerialNumber(), new SingleLogoLightingConfig().setMode(SINGLE_LOGO_MODE.STATIC).setColor(payload)); andThen.run(); }; @@ -101,20 +117,20 @@ public void buildSubscriptions(Device device, LightingConfig lighting) { var newBrightness = NumberUtils.toInt(payload, 100); lighting.setGlobalBrightness(newBrightness); andThen.run(); - applicationEventPublisher.publishEvent(new HomePage.GlobalBrightnessChangedEvent(this, device.getSerialNumber(), newBrightness)); + eventBus.fire(new GlobalBrightnessChangedEvent(device.getSerialNumber(), newBrightness)); }); - subscribeToColors(lighting.getKnobConfigs(), topicHelper, dial, knobOverride, idx -> device.getLightingConfig().getKnobConfigs()[idx].getColor1()); - subscribeToColors(lighting.getSliderConfigs(), topicHelper, slider, sliderOverride, idx -> device.getLightingConfig().getSliderConfigs()[idx].getColor1()); - subscribeToColors(lighting.getSliderLabelConfigs(), topicHelper, label, sliderLabelOverride, idx -> device.getLightingConfig().getSliderLabelConfigs()[idx].getColor()); - if (lighting.getLogoConfig() != null) { - subscribeToColor(topicHelper.lightTopic(logo, 0), logoOverride, () -> device.getLightingConfig().getLogoConfig().getColor()); + subscribeToColors(lighting.knobConfigs(), topicHelper, dial, knobOverride, idx -> device.lightingConfig().knobConfigs()[idx].getColor1()); + subscribeToColors(lighting.sliderConfigs(), topicHelper, slider, sliderOverride, idx -> device.lightingConfig().sliderConfigs()[idx].getColor1()); + subscribeToColors(lighting.sliderLabelConfigs(), topicHelper, label, sliderLabelOverride, idx -> device.lightingConfig().sliderLabelConfigs()[idx].getColor()); + if (lighting.logoConfig() != null) { + subscribeToColor(topicHelper.lightTopic(logo, 0), logoOverride, () -> device.lightingConfig().logoConfig().getColor()); } } - private void subscribeToColors(T[] items, MqttTopicHelper.DeviceMqttTopicHelper topicHelper, MqttTopicHelper.ColorType type, TriConsumer consumer, Function currentColorSupplier) { + private void subscribeToColors(T[] items, DeviceMqttTopicHelper topicHelper, ColorType type, TriFunction consumer, Function currentColorSupplier) { EntryStream.of(items).forKeyValue((idx, knob) -> { var topic = topicHelper.lightTopic(type, idx); - subscribeToColor(topic, payload -> consumer.accept(idx, payload, knob), () -> currentColorSupplier.apply(idx)); + subscribeToColor(topic, payload -> consumer.apply(idx, payload, knob), () -> currentColorSupplier.apply(idx)); }); } @@ -135,7 +151,7 @@ private void subscribeToColor(String baseTopic, Consumer colorOverrider, return; } - color.fromColor(setColor.get()); + color.fromHex(setColor.get()); colorOverrider.accept(publish); }); @@ -240,12 +256,17 @@ private static class MutableColor { int blue = 255; int brightness = 255; - @SuppressWarnings("NumericCastThatLosesPrecision") - public void fromColor(Color color) { - red = (int) Math.round(color.getRed() * 255); - green = (int) Math.round(color.getGreen() * 255); - blue = (int) Math.round(color.getBlue() * 255); - brightness = 255; + public void fromHex(String hex) { + if (hex == null) + return; + try { + String h = hex.startsWith("#") ? hex.substring(1) : hex; + red = Integer.parseInt(h.substring(0, 2), 16); + green = Integer.parseInt(h.substring(2, 4), 16); + blue = Integer.parseInt(h.substring(4, 6), 16); + brightness = 255; + } catch (Exception ignored) { + } } public String toColorString() { diff --git a/src/main/java/com/getpcpanel/mqtt/MqttDeviceService.java b/src/main/java/com/getpcpanel/mqtt/MqttDeviceService.java index e8024fe2..a80607ca 100644 --- a/src/main/java/com/getpcpanel/mqtt/MqttDeviceService.java +++ b/src/main/java/com/getpcpanel/mqtt/MqttDeviceService.java @@ -16,50 +16,55 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; -import org.springframework.context.event.EventListener; -import org.springframework.core.annotation.Order; -import org.springframework.stereotype.Service; - import com.getpcpanel.device.Device; +import com.getpcpanel.device.GlobalBrightnessChangedEvent; import com.getpcpanel.hid.ButtonClickEvent; -import com.getpcpanel.hid.DeviceCommunicationHandler; +import com.getpcpanel.hid.DeviceCommunicationHandler.ButtonPressEvent; +import com.getpcpanel.hid.DeviceCommunicationHandler.KnobRotateEvent; import com.getpcpanel.hid.DeviceHolder; import com.getpcpanel.hid.DeviceHolder.DeviceFullyConnectedEvent; import com.getpcpanel.mqtt.MqttTopicHelper.ColorType; import com.getpcpanel.mqtt.MqttTopicHelper.DeviceMqttTopicHelper; -import com.getpcpanel.profile.LightingConfig; import com.getpcpanel.profile.SaveService; -import com.getpcpanel.profile.SingleKnobLightingConfig; -import com.getpcpanel.profile.SingleSliderLabelLightingConfig; -import com.getpcpanel.profile.SingleSliderLightingConfig; -import com.getpcpanel.ui.HomePage.GlobalBrightnessChangedEvent; - -import lombok.RequiredArgsConstructor; +import com.getpcpanel.profile.SaveService.SaveEvent; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.profile.dto.LightingConfig.LightingMode; +import com.getpcpanel.profile.dto.SingleKnobLightingConfig; +import com.getpcpanel.profile.dto.SingleSliderLabelLightingConfig; +import com.getpcpanel.profile.dto.SingleSliderLightingConfig; + +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; import lombok.extern.log4j.Log4j2; import one.util.streamex.EntryStream; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public class MqttDeviceService { - private final MqttService mqtt; - private final SaveService saveService; - private final DeviceHolder deviceHolder; - private final MqttHomeAssistantHelper mqttHomeAssistantHelper; - private final MqttTopicHelper mqttTopicHelper; - private final MqttDeviceColorService deviceColorService; + @Inject + MqttService mqtt; + @Inject + SaveService saveService; + @Inject + DeviceHolder deviceHolder; + @Inject + MqttHomeAssistantHelper mqttHomeAssistantHelper; + @Inject + MqttTopicHelper mqttTopicHelper; + @Inject + MqttDeviceColorService deviceColorService; private final Set initializedDevices = new HashSet<>(); - @Order(ORDER_OF_SAVE + 1) // Ensure we are disconnected if the setting is turned off - @EventListener(SaveService.SaveEvent.class) - public void saveChanged() { + @Priority(ORDER_OF_SAVE + 1) // Ensure we are disconnected if the setting is turned off + public void saveChanged(@Observes SaveEvent event) { if (mqtt.isConnected()) { initialize(); } } - @EventListener - public void mqttConnected(MqttStatusEvent event) { + public void mqttConnected(@Observes MqttStatusEvent event) { if (!event.connected()) { return; } @@ -81,16 +86,14 @@ public boolean clear() { return false; } - @EventListener - public void deviceConnected(DeviceFullyConnectedEvent event) { + public void deviceConnected(@Observes DeviceFullyConnectedEvent event) { if (!mqtt.isConnected()) { return; } initialize(event.device()); } - @EventListener - public void dialAction(DeviceCommunicationHandler.KnobRotateEvent dial) { + public void dialAction(@Observes KnobRotateEvent dial) { if (!mqtt.isConnected()) { return; } @@ -101,8 +104,7 @@ public void dialAction(DeviceCommunicationHandler.KnobRotateEvent dial) { }); } - @EventListener - public void buttonPress(DeviceCommunicationHandler.ButtonPressEvent btn) { + public void buttonPress(@Observes ButtonPressEvent btn) { if (!mqtt.isConnected()) { return; } @@ -112,8 +114,7 @@ public void buttonPress(DeviceCommunicationHandler.ButtonPressEvent btn) { }); } - @EventListener - public void buttonPress(ButtonClickEvent btn) { + public void buttonPress(@Observes ButtonClickEvent btn) { if (!mqtt.isConnected()) { return; } @@ -123,12 +124,11 @@ public void buttonPress(ButtonClickEvent btn) { }); } - @EventListener - public void globalBrightnessChange(GlobalBrightnessChangedEvent event) { + public void globalBrightnessChange(@Observes GlobalBrightnessChangedEvent event) { if (!mqtt.isConnected()) { return; } - mqtt.send(mqttTopicHelper.valueTopic(event.serialNr(), brightness, 0), String.valueOf(event.brightness()), false); + mqtt.send(mqttTopicHelper.valueTopic(event.serialNum(), brightness, 0), String.valueOf(event.brightness()), false); } private void initialize(Device device) { @@ -137,8 +137,8 @@ private void initialize(Device device) { } initializedDevices.add(device); - var lighting = device.getLightingConfig(); - if (lighting.getLightingMode() != LightingConfig.LightingMode.CUSTOM) { + var lighting = device.lightingConfig(); + if (lighting.lightingMode() != LightingMode.CUSTOM) { log.debug("Only custom lighting will be written to mqtt"); return; } @@ -163,11 +163,11 @@ private void writeLighting(Device device, LightingConfig lighting) { var mqttHelper = mqttTopicHelper.device(device.getSerialNumber()); mqtt.send(mqttHelper.valueTopic(brightness, 0), String.valueOf(lighting.getGlobalBrightness()), false); - sendColors(lighting.getKnobConfigs(), mqttHelper, dial, SingleKnobLightingConfig::getColor1); - sendColors(lighting.getSliderConfigs(), mqttHelper, slider, SingleSliderLightingConfig::getColor1); - sendColors(lighting.getSliderLabelConfigs(), mqttHelper, label, SingleSliderLabelLightingConfig::getColor); - if (device.getDeviceType().isHasLogoLed() && lighting.getLogoConfig() != null) { - deviceColorService.sendColor(mqttHelper.lightTopic(logo, 0), toColorString(lighting.getLogoConfig().getColor()), false); + sendColors(lighting.knobConfigs(), mqttHelper, dial, SingleKnobLightingConfig::getColor1); + sendColors(lighting.sliderConfigs(), mqttHelper, slider, SingleSliderLightingConfig::getColor1); + sendColors(lighting.sliderLabelConfigs(), mqttHelper, label, SingleSliderLabelLightingConfig::getColor); + if (device.deviceType().isHasLogoLed() && lighting.logoConfig() != null) { + deviceColorService.sendColor(mqttHelper.lightTopic(logo, 0), toColorString(lighting.logoConfig().getColor()), false); } } @@ -178,12 +178,13 @@ private void sendColors(T[] items, DeviceMqttTopicHelper mqttHelper, ColorTy }); } - private @Nonnull String toColorString(@Nullable String color) { + @Nonnull + private String toColorString(@Nullable String color) { return color == null ? "#000000" : color; } private void writeButtons(Device device) { - for (var i = 0; i < device.getDeviceType().getButtonCount(); i++) { + for (var i = 0; i < device.deviceType().getButtonCount(); i++) { mqtt.send(mqttTopicHelper.buttonUpDownTopic(device.getSerialNumber(), button, i), "up", true); } } diff --git a/src/main/java/com/getpcpanel/mqtt/MqttHomeAssistantHelper.java b/src/main/java/com/getpcpanel/mqtt/MqttHomeAssistantHelper.java index 83dd4b2a..0ed6ea1d 100644 --- a/src/main/java/com/getpcpanel/mqtt/MqttHomeAssistantHelper.java +++ b/src/main/java/com/getpcpanel/mqtt/MqttHomeAssistantHelper.java @@ -6,25 +6,30 @@ import java.util.List; import javax.annotation.Nonnull; -import javax.annotation.Nullable; import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import javax.annotation.Nullable; import com.getpcpanel.device.Device; -import com.getpcpanel.profile.MqttSettings; +import com.getpcpanel.mqtt.MqttTopicHelper.ActionType; +import com.getpcpanel.mqtt.MqttTopicHelper.ColorType; +import com.getpcpanel.mqtt.MqttTopicHelper.ValueType; +import com.getpcpanel.profile.dto.MqttSettings; -import lombok.RequiredArgsConstructor; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import lombok.extern.log4j.Log4j2; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public class MqttHomeAssistantHelper { - private final MqttTopicHelper topicHelper; - private final MqttService mqttService; - @Value("${application.version}") private String version; + @Inject + MqttTopicHelper topicHelper; + @Inject + MqttService mqttService; + @ConfigProperty(name = "pcpanel.version") private String version; public void clearAll(MqttSettings settings) { var topic = StringUtils.joinWith("/", settings.homeAssistant().baseTopic(), "+", "pcpanel", "#"); @@ -47,19 +52,19 @@ private void addLights(MqttSettings settings, Device device, HomeAssistantDevice } private void addControlLights(MqttSettings settings, Device device, HomeAssistantDevice haDevice, @Nullable HomeAssistantAvailability availability) { - for (var i = 0; i < device.getDeviceType().getAnalogCount(); i++) { - var buttonCount = device.getDeviceType().getButtonCount(); - var type = i < buttonCount ? MqttTopicHelper.ColorType.dial : MqttTopicHelper.ColorType.slider; + for (var i = 0; i < device.deviceType().getAnalogCount(); i++) { + var buttonCount = device.deviceType().getButtonCount(); + var type = i < buttonCount ? ColorType.dial : ColorType.slider; var idx = i < buttonCount ? i : i - buttonCount; addControlLightConfig(settings, device, haDevice, availability, i, type, idx); - if (type == MqttTopicHelper.ColorType.slider) { + if (type == ColorType.slider) { addSliderLabelLightConfig(settings, device, haDevice, availability, idx, type); } } } - private void addControlLightConfig(MqttSettings settings, Device device, HomeAssistantDevice haDevice, @Nullable HomeAssistantAvailability availability, int i, MqttTopicHelper.ColorType type, int idx) { + private void addControlLightConfig(MqttSettings settings, Device device, HomeAssistantDevice haDevice, @Nullable HomeAssistantAvailability availability, int i, ColorType type, int idx) { var controlConfigTopic = lightTopicFor(settings, device, "control_" + i); var controlValueTopic = topicHelper.lightTopic(device.getSerialNumber(), type, idx); @@ -73,9 +78,9 @@ private void addControlLightConfig(MqttSettings settings, Device device, HomeAss mqttService.send(controlConfigTopic, config, false); } - private void addSliderLabelLightConfig(MqttSettings settings, Device device, HomeAssistantDevice haDevice, @Nullable HomeAssistantAvailability availability, int idx, MqttTopicHelper.ColorType type) { + private void addSliderLabelLightConfig(MqttSettings settings, Device device, HomeAssistantDevice haDevice, @Nullable HomeAssistantAvailability availability, int idx, ColorType type) { var labelConfigTopic = lightTopicFor(settings, device, "label_" + idx); - var labelValueTopic = topicHelper.lightTopic(device.getSerialNumber(), MqttTopicHelper.ColorType.label, idx); + var labelValueTopic = topicHelper.lightTopic(device.getSerialNumber(), ColorType.label, idx); var labelConfig = new HomeAssistantLightConfig( haDevice, availability, @@ -88,9 +93,9 @@ private void addSliderLabelLightConfig(MqttSettings settings, Device device, Hom } private void addAnalogValueConfigs(MqttSettings settings, Device device, HomeAssistantDevice haDevice, @Nullable HomeAssistantAvailability availability) { - for (var i = 0; i < device.getDeviceType().getAnalogCount(); i++) { + for (var i = 0; i < device.deviceType().getAnalogCount(); i++) { var configTopic = configTopicFor(settings, device, "number", "analog", i); - var valueTopic = topicHelper.valueTopic(device.getSerialNumber(), MqttTopicHelper.ValueType.analog, i); + var valueTopic = topicHelper.valueTopic(device.getSerialNumber(), ValueType.analog, i); var config = new HomeAssistantNumberConfig( haDevice, availability, @@ -107,7 +112,7 @@ private void addAnalogValueConfigs(MqttSettings settings, Device device, HomeAss private void addBrightnessDevice(MqttSettings settings, Device device, HomeAssistantDevice haDevice, @Nullable HomeAssistantAvailability availability) { var configTopic = configTopicFor(settings, device, "number", "brightness", 0); - var valueTopic = topicHelper.valueTopic(device.getSerialNumber(), MqttTopicHelper.ValueType.brightness, 0); + var valueTopic = topicHelper.valueTopic(device.getSerialNumber(), ValueType.brightness, 0); var config = new HomeAssistantNumberConfig( haDevice, availability, @@ -122,12 +127,12 @@ private void addBrightnessDevice(MqttSettings settings, Device device, HomeAssis } private void addLogoLight(MqttSettings settings, Device device, HomeAssistantDevice haDevice, @Nullable HomeAssistantAvailability availability) { - if (!device.getDeviceType().isHasLogoLed()) { + if (!device.deviceType().isHasLogoLed()) { return; } var configTopic = lightTopicFor(settings, device, "logo"); - var valueTopic = topicHelper.lightTopic(device.getSerialNumber(), MqttTopicHelper.ColorType.logo, 0); + var valueTopic = topicHelper.lightTopic(device.getSerialNumber(), ColorType.logo, 0); var config = new HomeAssistantLightConfig( haDevice, availability, @@ -140,7 +145,7 @@ private void addLogoLight(MqttSettings settings, Device device, HomeAssistantDev } private void addButtons(MqttSettings settings, Device device, HomeAssistantDevice haDevice, @Nullable HomeAssistantAvailability availability) { - for (var i = 0; i < device.getDeviceType().getButtonCount(); i++) { + for (var i = 0; i < device.deviceType().getButtonCount(); i++) { addButtonUpDown(settings, device, haDevice, availability, i); addButtonEvent(settings, device, haDevice, availability, i); } @@ -148,7 +153,7 @@ private void addButtons(MqttSettings settings, Device device, HomeAssistantDevic private void addButtonUpDown(MqttSettings settings, Device device, HomeAssistantDevice haDevice, @Nullable HomeAssistantAvailability availability, int i) { var configTopic = configTopicFor(settings, device, "binary_sensor", "button", i); - var valueTopic = topicHelper.buttonUpDownTopic(device.getSerialNumber(), MqttTopicHelper.ActionType.button, i); + var valueTopic = topicHelper.buttonUpDownTopic(device.getSerialNumber(), ActionType.button, i); var upDownConfig = new HomeAssistantButtonConfig( haDevice, availability, @@ -161,7 +166,7 @@ private void addButtonUpDown(MqttSettings settings, Device device, HomeAssistant private void addButtonEvent(MqttSettings settings, Device device, HomeAssistantDevice haDevice, @Nullable HomeAssistantAvailability availability, int i) { var eventConfigTopic = configTopicFor(settings, device, "event", "button", i); - var eventTopic = topicHelper.eventTopic(device.getSerialNumber(), MqttTopicHelper.ActionType.button, i); + var eventTopic = topicHelper.eventTopic(device.getSerialNumber(), ActionType.button, i); var eventConfig = new HomeAssistantButtonEventConfig( haDevice, availability, @@ -193,7 +198,7 @@ private String lightTopicFor(MqttSettings settings, Device device, String name) } private String determineAnalogIcon(Device device, int i) { - var buttonCount = device.getDeviceType().getButtonCount(); + var buttonCount = device.deviceType().getButtonCount(); if (i < buttonCount) { return "mdi:knob"; } @@ -201,7 +206,7 @@ private String determineAnalogIcon(Device device, int i) { } private String determineAnalogName(Device device, int i) { - var buttonCount = device.getDeviceType().getButtonCount(); + var buttonCount = device.deviceType().getButtonCount(); if (i < buttonCount) { return "Dial " + (i + 1); } @@ -325,14 +330,15 @@ private HomeAssistantDevice buildDevice(Device device) { version, List.of(device.getSerialNumber()), "PCPanel Holdings, LLC", - device.getDeviceType().getNiceName(), + device.deviceType().getNiceName(), device.getSerialNumber(), device.getSerialNumber(), "Office" ); } - private @Nullable HomeAssistantAvailability buildAvailability(MqttSettings settings) { + @Nullable + private HomeAssistantAvailability buildAvailability(MqttSettings settings) { if (settings.homeAssistant().availability()) { return new HomeAssistantAvailability( topicHelper.availabilityTopic(), diff --git a/src/main/java/com/getpcpanel/mqtt/MqttService.java b/src/main/java/com/getpcpanel/mqtt/MqttService.java index c31acb8f..1dc3d76d 100644 --- a/src/main/java/com/getpcpanel/mqtt/MqttService.java +++ b/src/main/java/com/getpcpanel/mqtt/MqttService.java @@ -13,35 +13,35 @@ import javax.annotation.Nullable; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.event.EventListener; -import org.springframework.core.annotation.Order; -import org.springframework.stereotype.Service; - import com.fasterxml.jackson.databind.ObjectMapper; -import com.getpcpanel.profile.MqttSettings; -import com.getpcpanel.profile.SaveService; +import com.getpcpanel.profile.SaveService.SaveEvent; +import com.getpcpanel.profile.dto.MqttSettings; import com.getpcpanel.util.Debouncer; import com.hivemq.client.mqtt.MqttClient; import com.hivemq.client.mqtt.MqttGlobalPublishFilter; import com.hivemq.client.mqtt.mqtt5.Mqtt5Client; import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5Publish; -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; import lombok.extern.log4j.Log4j2; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public class MqttService { static final int ORDER_OF_SAVE = 0; public static final String IGNORE_CORRELATION = "pcpanel"; - private final SaveService saveService; - private final ApplicationEventPublisher eventPublisher; - private final ObjectMapper objectMapper; - private final Debouncer debouncer; - private final MqttTopicHelper topicHelper; + @Inject + Event eventBus; + @Inject + ObjectMapper objectMapper; + @Inject + Debouncer debouncer; + @Inject + MqttTopicHelper topicHelper; private MqttSettings connectedSettings; @Nullable private Mqtt5Client mqttClient; @@ -140,14 +140,12 @@ private Pattern topicToRegex(String topic) { ); } - @Order(ORDER_OF_SAVE) - @PostConstruct - @EventListener(SaveService.SaveEvent.class) - public void saveChanged() { - var mqttSettings = saveService.get().getMqtt(); + @Priority(ORDER_OF_SAVE) + public void saveChanged(@Observes SaveEvent event) { + var mqttSettings = event.save().getMqtt(); if (mqttSettings == null || !mqttSettings.enabled()) { disconnect(); - eventPublisher.publishEvent(new MqttStatusEvent(false)); + eventBus.fire(new MqttStatusEvent(false)); connectedSettings = MqttSettings.DEFAULT; return; } @@ -158,11 +156,11 @@ public void saveChanged() { log.trace("Save changed, starting mqtt"); connect(mqttSettings); connectedSettings = mqttSettings; - eventPublisher.publishEvent(new MqttStatusEvent(true)); + eventBus.fire(new MqttStatusEvent(true)); } private void connect(MqttSettings mqttSettings) { - var availabilityTopic = topicHelper.availabilityTopic(); + var availabilityTopic = topicHelper.availabilityTopic(mqttSettings); var builder = MqttClient.builder() .identifier(UUID.randomUUID().toString()) .serverHost(mqttSettings.host()) diff --git a/src/main/java/com/getpcpanel/mqtt/MqttTopicHelper.java b/src/main/java/com/getpcpanel/mqtt/MqttTopicHelper.java index ca886132..14f8e0fb 100644 --- a/src/main/java/com/getpcpanel/mqtt/MqttTopicHelper.java +++ b/src/main/java/com/getpcpanel/mqtt/MqttTopicHelper.java @@ -1,19 +1,18 @@ package com.getpcpanel.mqtt; -import org.springframework.stereotype.Service; - -import com.getpcpanel.profile.MqttSettings; import com.getpcpanel.profile.SaveService; +import com.getpcpanel.profile.dto.MqttSettings; -import lombok.RequiredArgsConstructor; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import lombok.extern.log4j.Log4j2; import one.util.streamex.StreamEx; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped class MqttTopicHelper { - private final SaveService saveService; + @Inject + SaveService saveService; public DeviceMqttTopicHelper device(String deviceSerial) { return new DeviceMqttTopicHelper(deviceSerial); @@ -23,6 +22,10 @@ public String availabilityTopic() { return baseJoining("available"); } + public String availabilityTopic(MqttSettings settings) { + return baseJoining(settings, "available"); + } + public String baseTopicFilter() { return baseJoining("#"); } @@ -47,6 +50,10 @@ private String baseJoining(Object... parts) { return StreamEx.of(parts).prepend(getSettings().baseTopic()).joining("/"); } + private String baseJoining(MqttSettings settings, Object... parts) { + return StreamEx.of(parts).prepend(settings.baseTopic()).joining("/"); + } + private MqttSettings getSettings() { return saveService.get().getMqtt(); } @@ -67,10 +74,13 @@ enum ColorType { logo, } - @RequiredArgsConstructor class DeviceMqttTopicHelper { private final String deviceSerial; + DeviceMqttTopicHelper(String deviceSerial) { + this.deviceSerial = deviceSerial; + } + public String valueTopic(ValueType type, int index) { return MqttTopicHelper.this.valueTopic(deviceSerial, type, index); } diff --git a/src/main/java/com/getpcpanel/obs/OBS.java b/src/main/java/com/getpcpanel/obs/OBS.java index ade01086..21277fc4 100644 --- a/src/main/java/com/getpcpanel/obs/OBS.java +++ b/src/main/java/com/getpcpanel/obs/OBS.java @@ -1,362 +1,139 @@ package com.getpcpanel.obs; -import java.net.ConnectException; -import java.net.SocketTimeoutException; -import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import java.util.function.Consumer; -import java.util.function.Function; - -import javax.annotation.Nullable; - -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.math.NumberUtils; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.event.EventListener; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; +import com.fasterxml.jackson.databind.ObjectMapper; import com.getpcpanel.profile.SaveService; -import com.getpcpanel.util.Util; -import io.obswebsocket.community.client.OBSRemoteController; -import io.obswebsocket.community.client.OBSRemoteControllerBuilder; -import io.obswebsocket.community.client.listener.lifecycle.ReasonThrowable; -import io.obswebsocket.community.client.message.event.inputs.InputMuteStateChangedEvent; -import io.obswebsocket.community.client.message.request.RequestBatch; -import io.obswebsocket.community.client.message.request.inputs.GetInputMuteRequest; -import io.obswebsocket.community.client.message.response.RequestBatchResponse; -import io.obswebsocket.community.client.message.response.RequestResponse; -import io.obswebsocket.community.client.message.response.inputs.GetInputMuteResponse; -import io.obswebsocket.community.client.model.Input; -import io.obswebsocket.community.client.model.Scene; +import io.quarkus.scheduler.Scheduled; import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; +import jakarta.inject.Inject; import lombok.extern.log4j.Log4j2; -import one.util.streamex.StreamEx; +/** + * OBS integration via a custom OBS WebSocket 5 client. + * + * Connects automatically when {@code obsEnabled=true} in the user's settings. + * Reconnects every 30 s if the connection is lost. + * Fires {@link OBSConnectEvent} and {@link OBSMuteEvent} CDI events. + */ @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public final class OBS { - private static final long WAIT_TIME_MS = 1000L; - private static final ObsIdHelper OBS_ID_HELPER = new ObsIdHelper(); + private static final long CONNECT_TIMEOUT_MS = 5_000; + + @Inject SaveService save; + @Inject Event connectEvent; + @Inject Event muteEvent; + @Inject ObjectMapper objectMapper; - private final SaveService save; - private final ApplicationEventPublisher eventPublisher; - private List previousSettings = List.of(); - private boolean connected; - private boolean shuttingDown; - @Nullable private OBSRemoteController controller; + private ObsWebSocketClient client; @PostConstruct public void init() { - Runtime.getRuntime().addShutdownHook(new Thread(this::applicationEnding, "OBS Shutdown hook")); + reconnectIfNeeded(); } - @Scheduled(fixedRateString = "${pcpanel.obs.rate:2500}") - public void connect() { - if (!connected && !shuttingDown) { - buildAndConnectObsController(); + @PreDestroy + public void destroy() { + if (client != null) { + client.disconnect(); } } - private void applicationEnding() { - shuttingDown = true; - disconnectController(); - } - - private void buildAndConnectObsController() { - var save = this.save.get(); - if (!save.isObsEnabled() || (connected && !settingsStillSame(false)) || shuttingDown) { - log.trace("Obs is disabled({})/already connected({})/we are shutting down({})", save.isObsEnabled(), connected, shuttingDown); - disconnectController(); + @Scheduled(every = "30s") + public void reconnectIfNeeded() { + var settings = save.get(); + if (!settings.isObsEnabled()) { return; } - - try { - doBuildAndConnectObsController(); - } catch (Exception e) { - doConnected(false); - connected = false; - log.debug("Connecting failed", e); - } - } - - private void doBuildAndConnectObsController() { - var save = this.save.get(); - log.debug("Connecting to OBS"); - if (settingsStillSame(true) && controller != null) { - if (connected) { - return; - } - connected = true; - controller.connect(); + if (client != null && client.isConnected()) { return; } - - disconnectController(); - connected = true; - var port = NumberUtils.toInt(save.getObsPort(), -1); - var address = save.getObsAddress(); - var password = StringUtils.trimToNull(save.getObsPassword()); - - if (port != -1 && StringUtils.isNotBlank(address)) { - var currentIdx = OBS_ID_HELPER.incAndGet(); - controller = buildController(address, port, password).lifecycle() - .onReady(this::connected) - .onDisconnect(() -> OBS_ID_HELPER.runIfIdEq(currentIdx, () -> { - doConnected(false); - connected = false; - })) - .onControllerError(e -> OBS_ID_HELPER.runIfIdEq(currentIdx, () -> onError(e))) - .and() - .registerEventListener(InputMuteStateChangedEvent.class, this::onMuteChanged) - .build(); - controller.connect(); - } else { - doConnected(false); - connected = false; - } - } - - private void onMuteChanged(InputMuteStateChangedEvent t) { - eventPublisher.publishEvent(new OBSMuteEvent(t.getMessageData().getEventData().getInputName(), t.getMessageData().getEventData().getInputMuted())); - } - - private void disconnectController() { - doConnected(false); - connected = false; - if (controller != null) { - controller.disconnect(); - controller.stop(); - controller = null; - } + connect(settings.getObsAddress(), parsePort(settings.getObsPort()), settings.getObsPassword()); } - @Nullable - public String test(String address, int port, String password, long timeout) { - var latch = new CountDownLatch(1); - var result = new String[1]; - Consumer doResult = str -> { - result[0] = str; - latch.countDown(); - }; - - var controller = buildController(address, port, password).lifecycle() - .onReady(() -> doResult.accept(null)) - .onDisconnect(latch::countDown) - .onControllerError(e -> doResult.accept(e.getReason())) - .onCommunicatorError(e -> doResult.accept(e.getReason())) - .onClose(e -> doResult.accept(e.name())) - .and().build(); - controller.connect(); - + private void connect(String host, int port, String password) { try { - var waitSuccess = latch.await(timeout, TimeUnit.MILLISECONDS); - var message = waitSuccess && result[0] == null ? null : result[0]; - controller.disconnect(); - controller.stop(); - return message; - } catch (InterruptedException e) { - log.warn("Unable to wait for the latch"); - } - return null; - } - - private OBSRemoteControllerBuilder buildController(String address, int port, String password) { - return OBSRemoteController.builder() - .autoConnect(false) - .host(address) - .port(port) - .password(password) - .lifecycle() - .withControllerDefaultLogging(false) - .withCommunicatorDefaultLogging(false) - .and(); - } - - private void onError(ReasonThrowable reasonThrowable) { - var exception = reasonThrowable.getThrowable(); - if (exception instanceof ExecutionException exEx) { - exception = exEx.getCause(); - } - - if (exception instanceof SocketTimeoutException || exception instanceof TimeoutException || exception instanceof ConnectException) { - log.debug("Timeout/connect exception occurred", exception); - } else { - log.warn("Unknown OBS error, stack is logged in debug"); - log.debug("Unknown OBS error", exception); + if (client != null) { + client.disconnect(); + } + client = new ObsWebSocketClient(objectMapper, password, + connected -> connectEvent.fire(new OBSConnectEvent(connected)), + event -> muteEvent.fire(event)); + client.connect(host, port, CONNECT_TIMEOUT_MS); + log.info("OBS: connecting to {}:{}", host, port); + } catch (Exception e) { + log.debug("OBS: connection attempt failed: {}", e.getMessage()); } - doConnected(false); - connected = false; } - private boolean settingsStillSame(boolean updatePrevious) { - var port = NumberUtils.toInt(save.get().getObsPort(), -1); - var address = save.get().getObsAddress(); - var password = StringUtils.trimToNull(save.get().getObsPassword()); - var settings = List.of(port, Objects.requireNonNullElse(address, "-"), Objects.requireNonNullElse(password, "-")); - if (settings.equals(previousSettings)) { - return true; - } - if (updatePrevious) { - previousSettings = settings; + private static int parsePort(String port) { + try { + return Integer.parseInt(port); + } catch (NumberFormatException e) { + return 4455; } - return false; } - private void connected() { - log.info("Connected to OBS"); - doConnected(true); - connected = true; - } + // --- Public API used by command classes --- - @EventListener(SaveService.SaveEvent.class) - public void saveUpdated() { - buildAndConnectObsController(); + public boolean isConnected() { + return client != null && client.isConnected(); } public List getSourcesWithAudio() { - var nameToMute = getSourcesWithMuteState(); - return new ArrayList<>(nameToMute.keySet()); + return isConnected() ? client.getSourcesWithAudio() : List.of(); } public Map getSourcesWithMuteState() { - if (!isConnected() || controller == null) { - return Map.of(); - } - var result = controller.getInputList(null, WAIT_TIME_MS); - if (result == null) { - return Map.of(); - } - var sources = result.getInputs(); - return getNameToMuteState(sources); + return isConnected() ? client.getSourcesWithMuteState() : Map.of(); } public List getScenes() { - if (!isConnected() || controller == null) { - return List.of(); - } - - return Optional.ofNullable(controller.getSceneList(WAIT_TIME_MS)) - .map(ss -> StreamEx.of(ss.getScenes()).map(Scene::getSceneName).toList()) - .orElse(List.of()); + return isConnected() ? client.getScenes() : List.of(); } public void setSourceVolume(String sourceName, int vol) { - if (!isConnected() || controller == null) { - return; - } - try { - var decimal = (float) Util.map(vol, 0, 100, -97, 0); - controller.setInputVolume(sourceName, null, decimal, WAIT_TIME_MS); - } catch (Exception e) { - log.error("Unable to get source volume", e); + if (isConnected()) { + client.setSourceVolume(sourceName, vol); } } public void toggleSourceMute(String sourceName) { - if (!isConnected() || controller == null) { - return; - } - try { - controller.toggleInputMute(sourceName, WAIT_TIME_MS); - } catch (Exception e) { - log.error("Unable to toggle source mute {}", sourceName, e); + if (isConnected()) { + client.toggleSourceMute(sourceName); } } public void setSourceMute(String sourceName, boolean mute) { - if (!isConnected() || controller == null) { - return; - } - try { - controller.setInputMute(sourceName, mute, WAIT_TIME_MS); - } catch (Exception e) { - log.error("Unable to set source mute {} {}", sourceName, mute, e); + if (isConnected()) { + client.setSourceMute(sourceName, mute); } } public void setCurrentScene(String sceneName) { - if (!isConnected() || controller == null) { - return; - } - try { - controller.setCurrentProgramScene(sceneName, WAIT_TIME_MS); - } catch (Exception e) { - log.error("Unable to set current scene to {}", sceneName, e); + if (isConnected()) { + client.setCurrentScene(sceneName); } } - public boolean isConnected() { - return save.get().isObsEnabled() && controller != null && connected; - } - - private void doConnected(boolean connected) { - new Thread(() -> { - eventPublisher.publishEvent(new OBSConnectEvent(connected)); - if (connected) { - getSourcesWithMuteState(); - } - }).start(); - } - - private Map getNameToMuteState(List sources) { - if (controller == null) { - return Map.of(); - } - record RequestAndName(GetInputMuteRequest request, String name) { - } - - var muteRequests = StreamEx.of(sources) - .map(source -> { - var req = GetInputMuteRequest.builder().inputName(source.getInputName()).build(); - return new RequestAndName(req, source.getInputName()); - }) - .mapToEntry(rn -> rn.request.getRequestId(), Function.identity()) - .toMap(); - - var latch = new ArrayBlockingQueue(1); - controller.sendRequestBatch(RequestBatch.builder().requests(StreamEx.ofValues(muteRequests).map(RequestAndName::request).toList()).build(), latch::offer); - + /** Returns null on success, or an error message on failure. */ + public String test(String address, int port, String password, long timeout) { + var tester = new ObsWebSocketClient(objectMapper, password, c -> {}, e -> {}); try { - var result = latch.poll(WAIT_TIME_MS, TimeUnit.MILLISECONDS); - if (result == null) { - return Map.of(); - } - return StreamEx.of(result.getData().getResults()) - .mapToEntry(rs -> muteRequests.get(rs.getRequestId()), RequestResponse.Data::getResponseData) - .nonNullKeys().mapKeys(rn -> rn.name) - .nonNullValues() - .selectValues(GetInputMuteResponse.SpecificData.class) - .mapValues(GetInputMuteResponse.SpecificData::getInputMuted) - .toMap(); - } catch (InterruptedException e) { - return Map.of(); - } - } - - static class ObsIdHelper { - private int activeIdx; - - private int incAndGet() { - activeIdx++; - return activeIdx; - } - - private void runIfIdEq(int id, Runnable toRun) { - if (activeIdx == id) { - toRun.run(); - } + tester.connect(address, port, timeout); + Thread.sleep(500); // allow hello/identify exchange + return tester.isConnected() ? null : "Connected but not authenticated"; + } catch (Exception e) { + return e.getMessage(); + } finally { + tester.disconnect(); } } } + diff --git a/src/main/java/com/getpcpanel/obs/ObsConnectedVolumeService.java b/src/main/java/com/getpcpanel/obs/ObsConnectedVolumeService.java index 00e0f57c..4570c183 100644 --- a/src/main/java/com/getpcpanel/obs/ObsConnectedVolumeService.java +++ b/src/main/java/com/getpcpanel/obs/ObsConnectedVolumeService.java @@ -1,27 +1,22 @@ package com.getpcpanel.obs; import java.util.function.Function; - -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; - -import com.getpcpanel.commands.AbstractNewXVolumeService; import com.getpcpanel.commands.command.CommandObsSetSourceVolume; import com.getpcpanel.hid.DeviceHolder; -import com.getpcpanel.spring.ConditionalOnWindows; +import com.getpcpanel.platform.WindowsBuild; -@Service -@ConditionalOnWindows -public class ObsConnectedVolumeService extends AbstractNewXVolumeService { - public ObsConnectedVolumeService(DeviceHolder devices, ApplicationEventPublisher eventPublisher) { - super(devices, eventPublisher); - } +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; + +@ApplicationScoped +@WindowsBuild +public class ObsConnectedVolumeService { + @Inject DeviceHolder devices; - @EventListener - public void onVoiceMeeterConnected(OBSConnectEvent event) { + public void onVoiceMeeterConnected(@Observes OBSConnectEvent event) { if (event.connected()) { - triggerCommandsOf(CommandObsSetSourceVolume.class, Function.identity()); + devices.triggerCommandsOf(CommandObsSetSourceVolume.class, Function.identity()); } } } diff --git a/src/main/java/com/getpcpanel/obs/ObsWebSocketClient.java b/src/main/java/com/getpcpanel/obs/ObsWebSocketClient.java new file mode 100644 index 00000000..bae473cd --- /dev/null +++ b/src/main/java/com/getpcpanel/obs/ObsWebSocketClient.java @@ -0,0 +1,284 @@ +package com.getpcpanel.obs; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import lombok.extern.log4j.Log4j2; + +/** + * OBS WebSocket protocol 5 client built on java.net.http.WebSocket. + * + * Handles: hello/identify handshake, optional SHA-256 password auth, + * request/response correlation, InputMuteStateChanged events. + */ +@Log4j2 +public class ObsWebSocketClient implements WebSocket.Listener { + + // OBS WebSocket 5 opcodes + private static final int OP_HELLO = 0; + private static final int OP_IDENTIFY = 1; + private static final int OP_IDENTIFIED = 2; + private static final int OP_EVENT = 5; + private static final int OP_REQUEST = 6; + private static final int OP_REQUEST_RESPONSE = 7; + + // EventSubscriptions bit: Inputs (for InputMuteStateChanged) + private static final int EVENT_SUB_INPUTS = 1 << 3; + + private final ObjectMapper mapper; + private final String password; + private final Consumer onConnected; + private final Consumer onMuteChange; + + private WebSocket webSocket; + private final ConcurrentHashMap> pending = new ConcurrentHashMap<>(); + private final StringBuilder textBuffer = new StringBuilder(); + private volatile boolean connected = false; + + public ObsWebSocketClient(ObjectMapper mapper, String password, + Consumer onConnected, Consumer onMuteChange) { + this.mapper = mapper; + this.password = password; + this.onConnected = onConnected; + this.onMuteChange = onMuteChange; + } + + public void connect(String host, int port, long timeoutMs) throws Exception { + var uri = URI.create("ws://" + host + ":" + port); + webSocket = HttpClient.newHttpClient() + .newWebSocketBuilder() + .buildAsync(uri, this) + .get(timeoutMs, TimeUnit.MILLISECONDS); + } + + public void disconnect() { + connected = false; + if (webSocket != null) { + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "bye"); + } + } + + public boolean isConnected() { + return connected; + } + + // --- WebSocket.Listener --- + + @Override + public CompletionStage> onText(WebSocket ws, CharSequence data, boolean last) { + textBuffer.append(data); + ws.request(1); + if (!last) { + return null; + } + var text = textBuffer.toString(); + textBuffer.setLength(0); + try { + handleMessage(mapper.readTree(text)); + } catch (Exception e) { + log.warn("OBS: failed to handle message", e); + } + return null; + } + + @Override + public CompletionStage> onClose(WebSocket ws, int statusCode, String reason) { + if (connected) { + connected = false; + onConnected.accept(false); + } + return null; + } + + @Override + public void onError(WebSocket ws, Throwable error) { + log.warn("OBS WebSocket error: {}", error.getMessage()); + if (connected) { + connected = false; + onConnected.accept(false); + } + pending.values().forEach(f -> f.completeExceptionally(error)); + pending.clear(); + } + + // --- Protocol handling --- + + private void handleMessage(JsonNode msg) throws Exception { + var op = msg.path("op").asInt(-1); + var d = msg.path("d"); + switch (op) { + case OP_HELLO -> identify(d); + case OP_IDENTIFIED -> { + connected = true; + log.info("OBS: connected and authenticated"); + onConnected.accept(true); + } + case OP_EVENT -> handleEvent(d); + case OP_REQUEST_RESPONSE -> { + var id = d.path("requestId").asText(null); + var future = id != null ? pending.remove(id) : null; + if (future != null) { + future.complete(d.path("responseData")); + } + } + default -> log.trace("OBS: unhandled opcode {}", op); + } + } + + private void identify(JsonNode hello) throws Exception { + var msg = mapper.createObjectNode(); + msg.put("op", OP_IDENTIFY); + var data = msg.putObject("d"); + data.put("rpcVersion", 1); + data.put("eventSubscriptions", EVENT_SUB_INPUTS); + var authNode = hello.path("authentication"); + if (!authNode.isMissingNode() && !authNode.isNull() && password != null && !password.isBlank()) { + data.put("authentication", computeAuth(password, + authNode.path("salt").asText(), + authNode.path("challenge").asText())); + } + send(msg); + } + + private void handleEvent(JsonNode d) { + var type = d.path("eventType").asText(); + if ("InputMuteStateChanged".equals(type)) { + var data = d.path("eventData"); + onMuteChange.accept(new OBSMuteEvent( + data.path("inputName").asText(), + data.path("inputMuted").asBoolean())); + } + } + + private static String computeAuth(String password, String salt, String challenge) throws Exception { + var md = MessageDigest.getInstance("SHA-256"); + var secret = Base64.getEncoder().encodeToString( + md.digest((password + salt).getBytes(StandardCharsets.UTF_8))); + md.reset(); + return Base64.getEncoder().encodeToString( + md.digest((secret + challenge).getBytes(StandardCharsets.UTF_8))); + } + + private void send(Object obj) { + try { + webSocket.sendText(mapper.writeValueAsString(obj), true); + } catch (Exception e) { + log.warn("OBS: failed to send message", e); + } + } + + private JsonNode request(String type, ObjectNode fields) throws Exception { + var id = UUID.randomUUID().toString(); + var msg = mapper.createObjectNode(); + msg.put("op", OP_REQUEST); + var d = msg.putObject("d"); + d.put("requestType", type); + d.put("requestId", id); + if (fields != null) { + d.set("requestData", fields); + } + var future = new CompletableFuture(); + pending.put(id, future); + send(msg); + return future.get(5, TimeUnit.SECONDS); + } + + // --- High-level OBS operations --- + + public List getSourcesWithAudio() { + try { + var resp = request("GetInputList", null); + var list = new ArrayList(); + resp.path("inputs").forEach(n -> list.add(n.path("inputName").asText())); + return list; + } catch (Exception e) { + log.warn("OBS: GetInputList failed: {}", e.getMessage()); + return List.of(); + } + } + + public Map getSourcesWithMuteState() { + var map = new LinkedHashMap(); + for (var source : getSourcesWithAudio()) { + try { + var fields = mapper.createObjectNode().put("inputName", source); + var resp = request("GetInputMute", fields); + map.put(source, resp.path("inputMuted").asBoolean()); + } catch (Exception e) { + log.trace("OBS: GetInputMute failed for {}: {}", source, e.getMessage()); + } + } + return map; + } + + public List getScenes() { + try { + var resp = request("GetSceneList", null); + var list = new ArrayList(); + resp.path("scenes").forEach(n -> list.add(n.path("sceneName").asText())); + return list; + } catch (Exception e) { + log.warn("OBS: GetSceneList failed: {}", e.getMessage()); + return List.of(); + } + } + + /** vol is 0–100; converted to OBS volume multiplier 0.0–1.0. */ + public void setSourceVolume(String sourceName, int vol) { + try { + var fields = mapper.createObjectNode() + .put("inputName", sourceName) + .put("inputVolumeMultiplier", vol / 100.0); + request("SetInputVolume", fields); + } catch (Exception e) { + log.warn("OBS: SetInputVolume failed: {}", e.getMessage()); + } + } + + public void toggleSourceMute(String sourceName) { + try { + var fields = mapper.createObjectNode().put("inputName", sourceName); + request("ToggleInputMute", fields); + } catch (Exception e) { + log.warn("OBS: ToggleInputMute failed: {}", e.getMessage()); + } + } + + public void setSourceMute(String sourceName, boolean mute) { + try { + var fields = mapper.createObjectNode() + .put("inputName", sourceName) + .put("inputMuted", mute); + request("SetInputMute", fields); + } catch (Exception e) { + log.warn("OBS: SetInputMute failed: {}", e.getMessage()); + } + } + + public void setCurrentScene(String sceneName) { + try { + var fields = mapper.createObjectNode().put("sceneName", sceneName); + request("SetCurrentProgramScene", fields); + } catch (Exception e) { + log.warn("OBS: SetCurrentProgramScene failed: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/com/getpcpanel/osc/OSCService.java b/src/main/java/com/getpcpanel/osc/OSCService.java index 0b01a9c6..77e498c0 100644 --- a/src/main/java/com/getpcpanel/osc/OSCService.java +++ b/src/main/java/com/getpcpanel/osc/OSCService.java @@ -10,14 +10,11 @@ import javax.annotation.Nonnull; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; -import org.springframework.util.CollectionUtils; - -import com.getpcpanel.hid.DeviceCommunicationHandler; -import com.getpcpanel.profile.OSCBinding; -import com.getpcpanel.profile.OSCConnectionInfo; +import com.getpcpanel.hid.DeviceCommunicationHandler.ButtonPressEvent; +import com.getpcpanel.hid.DeviceCommunicationHandler.KnobRotateEvent; import com.getpcpanel.profile.SaveService; +import com.getpcpanel.profile.dto.OSCBinding; +import com.getpcpanel.profile.dto.OSCConnectionInfo; import com.getpcpanel.util.Util; import com.illposed.osc.OSCBadDataEvent; import com.illposed.osc.OSCBundle; @@ -29,16 +26,18 @@ import com.illposed.osc.transport.OSCPortOut; import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; import lombok.Getter; -import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import one.util.streamex.StreamEx; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public class OSCService { - private final SaveService saveService; + @Inject + SaveService saveService; private OSCPortIn portIn; private List ports = List.of(); private Integer prevListenPort; @@ -46,7 +45,6 @@ public class OSCService { @Getter private final Set addresses = new HashSet<>(); @PostConstruct - @EventListener(SaveService.SaveEvent.class) public void saveChanged() { log.trace("Save changed, restarting OSC"); initSend(); @@ -105,14 +103,13 @@ private void stopPortIn() { } } - @EventListener - public void dialAction(DeviceCommunicationHandler.KnobRotateEvent dial) { - if (dial.initial() || CollectionUtils.isEmpty(ports)) { + public void dialAction(@Observes KnobRotateEvent dial) { + if (dial.initial() || ports == null || ports.isEmpty()) { return; } saveService.getProfile(dial.serialNum()).ifPresent(profile -> { - var knobLength = profile.getLightingConfig().getKnobConfigs().length; + var knobLength = profile.lightingConfig().knobConfigs().length; var idx = dial.knob() < knobLength ? dial.knob() * 2 : dial.knob() + knobLength; var target = profile.getOscBinding().get(idx); @@ -122,9 +119,8 @@ public void dialAction(DeviceCommunicationHandler.KnobRotateEvent dial) { }); } - @EventListener - public void dialAction(DeviceCommunicationHandler.ButtonPressEvent button) { - if (CollectionUtils.isEmpty(ports)) { + public void dialAction(@Observes ButtonPressEvent button) { + if (ports == null || ports.isEmpty()) { return; } var idx = button.button() * 2 + 1; @@ -152,7 +148,8 @@ private float determineValue(@Nonnull OSCBinding target, float val) { return Util.map(val, 0, 1, target.min(), target.max()); } - private static @Nonnull OSCMessage buildMessage(OSCBinding target, String defaultTarget, float val) { + @Nonnull + private static OSCMessage buildMessage(OSCBinding target, String defaultTarget, float val) { var targetString = target == null ? defaultTarget : target.address(); try { return new OSCMessage(targetString, List.of(val)); diff --git a/src/main/java/com/getpcpanel/overlay/Overlay.java b/src/main/java/com/getpcpanel/overlay/Overlay.java new file mode 100644 index 00000000..aa6a72bc --- /dev/null +++ b/src/main/java/com/getpcpanel/overlay/Overlay.java @@ -0,0 +1,122 @@ +package com.getpcpanel.overlay; + +import java.awt.Image; +import java.awt.Toolkit; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import javax.swing.SwingUtilities; + +import com.getpcpanel.commands.Commands; +import com.getpcpanel.commands.IconService; +import com.getpcpanel.commands.PCPanelControlEvent; +import com.getpcpanel.commands.command.ButtonAction; +import com.getpcpanel.commands.command.DialAction; +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.profile.SaveService.SaveEvent; +import com.getpcpanel.profile.dto.OverlayPosition; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import lombok.RequiredArgsConstructor; +import one.util.streamex.StreamEx; + +@ApplicationScoped +@RequiredArgsConstructor +public class Overlay { + private final SaveService save; + private final IconService iconService; + private VolumeOverlay overlay = new VolumeOverlay(); + + // @PostConstruct + // public void init() { + // SwingUtilities.invokeLater(() -> { + // overlay; + // }); + // } + + public void updateSaveValues(@Observes SaveEvent event) { + updateStyle(null); + determinePosition(); + } + + private void determinePosition() { + var window = Toolkit.getDefaultToolkit().getScreenSize(); + var x = window.width; + var y = window.height; + var width = overlay.getWidth(); + var height = overlay.getHeight(); + + var position = save == null ? OverlayPosition.topLeft : save.get().getOverlayPosition(); + var padding = save == null ? 0 : save.get().getOverlayPadding(); + var newY = switch (position) { + case topLeft, topMiddle, topRight -> padding; + case middleLeft, middleMiddle, middleRight -> y / 2 - height / 2; + case bottomLeft, bottomMiddle, bottomRight -> y - overlay.getHeight() - padding; + }; + var newX = switch (position) { + case topLeft, middleLeft, bottomLeft -> padding; + case topMiddle, middleMiddle, bottomMiddle -> x / 2 - width / 2; + case topRight, middleRight, bottomRight -> x - width - padding; + }; + setXY(newX, newY); + } + + private void setXY(int x, int y) { + var b = overlay.getBounds(); + b.x = x; + b.y = y; + overlay.setBounds(b); + } + + public void show(float value) { + showDebounced(value, () -> CommandAndIcon.DEFAULT, x -> true); + } + + public void updateStyle(@Nullable @Observes SaveEvent event) { + SwingUtilities.invokeLater(() -> overlay.setStyles(save.get())); + } + + public void handleControl(@Observes PCPanelControlEvent event) { + if (event.initial()) { + return; + } + var vol = event.vol(); + var value = vol == null ? -1 : save.get().isOverlayUseLog() ? vol.getValue(null, 0, 1) : vol.value() / 255f; + showDebounced(value, () -> determineIconImage(event), command -> true); + } + + private void showDebounced(float value, Supplier pre, Predicate pred) { + if (!save.get().isOverlayEnabled()) { + return; + } + SwingUtilities.invokeLater(() -> { + var cai = pre.get(); + if (hasOverlay(cai.command) && pred.test(cai.command)) { + overlay.show(value, cai.icon); + } + }); + } + + private boolean hasOverlay(Commands commands) { + return Commands.hasCommands(commands) && + StreamEx.of(commands.getCommands()).anyMatch(command -> command instanceof DialAction da && da.hasOverlay() + || command instanceof ButtonAction ba && ba.hasOverlay()); + } + + @Nonnull + private CommandAndIcon determineIconImage(PCPanelControlEvent event) { + return save.getProfile(event.serialNum()).map(profile -> { + var data = event.cmd(); + var setting = event.vol() == null ? null : profile.getKnobSettings(event.knob()); + return new CommandAndIcon(data, iconService.getImageFrom(data, setting)); + }).orElse(CommandAndIcon.DEFAULT); + } + + private record CommandAndIcon(Commands command, Image icon) { + static final CommandAndIcon DEFAULT = new CommandAndIcon(Commands.EMPTY, null); + } +} diff --git a/src/main/java/com/getpcpanel/overlay/VolumeOverlay.java b/src/main/java/com/getpcpanel/overlay/VolumeOverlay.java new file mode 100644 index 00000000..8e9fe608 --- /dev/null +++ b/src/main/java/com/getpcpanel/overlay/VolumeOverlay.java @@ -0,0 +1,265 @@ +package com.getpcpanel.overlay; + +import java.awt.Color; +import java.awt.Font; +import java.awt.GradientPaint; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.RenderingHints; +import java.awt.Toolkit; +import java.awt.geom.Ellipse2D; +import java.awt.geom.RoundRectangle2D; +import java.util.regex.Pattern; + +import javax.swing.JPanel; +import javax.swing.JWindow; +import javax.swing.Timer; +import javax.swing.UIManager; + +import com.getpcpanel.profile.Save; + +public class VolumeOverlay extends JWindow { + // Install the cross-platform (Metal) Look and Feel before any Swing + // component is constructed. This static block runs when VolumeOverlay is + // first loaded – before the implicit JWindow() super-constructor call – + // so JPanel / JRootPane can find their ComponentUI delegates. + static { + try { + UIManager.setLookAndFeel(UIManager.getCrossPlatformLookAndFeelClassName()); + } catch (Exception ignored) { + // If Metal L&F is unavailable in native image, swallow and continue. + } + } + + private static final int WIDTH = 340; + private static final int DEFAULT_HEIGHT = 56; + private static final int DEFAULT_CORNER_RADIUS = 28; + private static final int CONTENT_PADDING = 10; + private static final int ICON_SIZE = 36; + private static final int DEFAULT_BAR_HEIGHT = 10; + private static final int DEFAULT_BAR_CORNER_RADIUS = DEFAULT_BAR_HEIGHT; + private static final int VALUE_LABEL_WIDTH = 36; + private static final int VALUE_GAP = 8; + private static final int DISMISS_MS = 2000; // auto-hide after 2 s + private static final Pattern RGB_PATTERN = Pattern.compile("rgba?\\(([^)]+)\\)", Pattern.CASE_INSENSITIVE); + private static final Pattern COLOR_COMPONENT_SEPARATOR = Pattern.compile("\\s*,\\s*"); + + private static final Color DEFAULT_BG_COLOR = new Color(80, 80, 90, 210); + private static final Color DEFAULT_BAR_COLOR = new Color(0, 200, 230, 255); + private static final Color DEFAULT_BAR_TRACK_COLOR = new Color(255, 255, 255, 50); + private static final Color DEFAULT_TEXT_COLOR = new Color(230, 230, 230, 255); + + private int value; + private final Timer dismissTimer; + private Image icon; + private boolean showNumber = true; + private int windowCornerRadius = DEFAULT_CORNER_RADIUS; + private int barHeight = DEFAULT_BAR_HEIGHT; + private int barCornerRadius = DEFAULT_BAR_CORNER_RADIUS; + private Color backgroundColor = DEFAULT_BG_COLOR; + private Color barColor = DEFAULT_BAR_COLOR; + private Color barTrackColor = DEFAULT_BAR_TRACK_COLOR; + private Color textColor = DEFAULT_TEXT_COLOR; + + VolumeOverlay() { + setAlwaysOnTop(true); + setSize(WIDTH, DEFAULT_HEIGHT); + setBackground(new Color(0, 0, 0, 0)); + + JPanel panel = new OverlayPanel(); + panel.setOpaque(false); + setContentPane(panel); + + var screen = Toolkit.getDefaultToolkit().getScreenSize(); + setLocation((screen.width - WIDTH) / 2, 48); + + dismissTimer = new Timer(DISMISS_MS, _ -> setVisible(false)); + dismissTimer.setRepeats(false); + } + + public void show(float v, Image icon) { + this.icon = icon; + update(Math.round(v * 100f)); + } + + public void setStyles(Save save) { + showNumber = save.isOverlayShowNumber(); + backgroundColor = parseColor(save.getOverlayBackgroundColor(), DEFAULT_BG_COLOR); + textColor = parseColor(save.getOverlayTextColor(), DEFAULT_TEXT_COLOR); + barColor = parseColor(save.getOverlayBarColor(), DEFAULT_BAR_COLOR); + barTrackColor = parseColor(save.getOverlayBarBackgroundColor(), DEFAULT_BAR_TRACK_COLOR); + windowCornerRadius = Math.max(0, save.getOverlayWindowCornerRounding()); + barHeight = Math.max(2, save.getOverlayBarHeight()); + barCornerRadius = Math.max(0, save.getOverlayBarCornerRounding()); + + var computedHeight = Math.max(DEFAULT_HEIGHT, CONTENT_PADDING * 2 + Math.max(ICON_SIZE, barHeight)); + setSize(WIDTH, computedHeight); + revalidate(); + repaint(); + } + + private void update(int v) { + value = Math.clamp(v, 0, 100); + repaint(); + setVisible(true); + dismissTimer.restart(); + } + + private class OverlayPanel extends JPanel { + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + var g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_LCD_HRGB); + + var w = getWidth(); + var h = getHeight(); + var windowArc = Math.min(windowCornerRadius, Math.min(w, h)); + var barArc = Math.min(barCornerRadius, barHeight); + + // ── Background pill ────────────────────────────────────────── + g2.setColor(backgroundColor); + g2.fill(new RoundRectangle2D.Float(0, 0, w, h, windowArc, windowArc)); + + // Subtle top highlight (glass shimmer) + var gloss = new GradientPaint( + 0, 0, withAlpha(Color.WHITE, Math.clamp(backgroundColor.getAlpha() / 4, 18, 60)), + 0, h / 2f, withAlpha(Color.WHITE, 0)); + g2.setPaint(gloss); + g2.fill(new RoundRectangle2D.Float(0, 0, w, h / 2f, windowArc, windowArc)); + + // ── Icon area ──────────────────────────────────────────────── + var iconX = CONTENT_PADDING + 2; + var iconY = (h - ICON_SIZE) / 2; + if (icon != null) { + g2.drawImage(icon, iconX, iconY, ICON_SIZE, ICON_SIZE, null); + } + + // ── Layout constants ───────────────────────────────────────── + var afterIcon = iconX + ICON_SIZE + CONTENT_PADDING; + var valueWidth = showNumber ? VALUE_LABEL_WIDTH : 0; + var barEndX = w - CONTENT_PADDING - valueWidth - (showNumber ? VALUE_GAP : 0); + var barY = (h - barHeight) / 2; + var barWidth = barEndX - afterIcon; + + // ── Progress bar track ─────────────────────────────────────── + g2.setColor(barTrackColor); + g2.fill(new RoundRectangle2D.Float(afterIcon, barY, barWidth, barHeight, barArc, barArc)); + + // ── Progress bar fill ──────────────────────────────────────── + var fillWidth = Math.round(barWidth * (value / 100f)); + if (fillWidth > 0) { + var fillGrad = new GradientPaint( + afterIcon, 0, scaleColor(barColor, 1.15f), + afterIcon + fillWidth, 0, scaleColor(barColor, 0.82f)); + g2.setPaint(fillGrad); + g2.fill(new RoundRectangle2D.Float(afterIcon, barY, fillWidth, barHeight, barArc, barArc)); + + // Bright leading cap + if (fillWidth >= barHeight) { + g2.setColor(withAlpha(scaleColor(barColor, 1.35f), Math.clamp(barColor.getAlpha(), 120, 220))); + var capX = afterIcon + fillWidth - barHeight; + g2.fill(new Ellipse2D.Float(capX, barY, barHeight, barHeight)); + } + } + + // ── Value label ────────────────────────────────────────────── + if (showNumber) { + g2.setColor(textColor); + g2.setFont(new Font("SF Pro Display", Font.BOLD, 16)); + // Fallback font chain + if (!g2.getFont().getFamily().equals("SF Pro Display")) { + g2.setFont(new Font("Segoe UI", Font.BOLD, 16)); + } + var label = String.valueOf(value); + var fm = g2.getFontMetrics(); + var labelX = w - CONTENT_PADDING - valueWidth + (valueWidth - fm.stringWidth(label)) / 2; + var labelY = (h + fm.getAscent() - fm.getDescent()) / 2; + g2.drawString(label, labelX, labelY); + } + + g2.dispose(); + } + } + + private static Color parseColor(String value, Color fallback) { + if (value == null || value.isBlank()) { + return fallback; + } + + try { + var trimmed = value.trim(); + if (trimmed.startsWith("#")) { + return parseHexColor(trimmed); + } + + var matcher = RGB_PATTERN.matcher(trimmed); + if (matcher.matches()) { + var parts = COLOR_COMPONENT_SEPARATOR.split(matcher.group(1)); + if (parts.length == 3 || parts.length == 4) { + var red = clampChannel(Integer.parseInt(parts[0])); + var green = clampChannel(Integer.parseInt(parts[1])); + var blue = clampChannel(Integer.parseInt(parts[2])); + var alpha = parts.length == 4 ? parseAlpha(parts[3]) : 255; + return new Color(red, green, blue, alpha); + } + } + } catch (RuntimeException ignored) { + // Fall back to default styling for invalid persisted values. + } + + return fallback; + } + + private static Color parseHexColor(String value) { + var hex = value.substring(1); + return switch (hex.length()) { + case 3 -> new Color( + Integer.parseInt(hex.substring(0, 1).repeat(2), 16), + Integer.parseInt(hex.substring(1, 2).repeat(2), 16), + Integer.parseInt(hex.substring(2, 3).repeat(2), 16)); + case 4 -> new Color( + Integer.parseInt(hex.substring(0, 1).repeat(2), 16), + Integer.parseInt(hex.substring(1, 2).repeat(2), 16), + Integer.parseInt(hex.substring(2, 3).repeat(2), 16), + Integer.parseInt(hex.substring(3, 4).repeat(2), 16)); + case 6 -> new Color( + Integer.parseInt(hex.substring(0, 2), 16), + Integer.parseInt(hex.substring(2, 4), 16), + Integer.parseInt(hex.substring(4, 6), 16)); + case 8 -> new Color( + Integer.parseInt(hex.substring(0, 2), 16), + Integer.parseInt(hex.substring(2, 4), 16), + Integer.parseInt(hex.substring(4, 6), 16), + Integer.parseInt(hex.substring(6, 8), 16)); + default -> throw new IllegalArgumentException("Unsupported color format: " + value); + }; + } + + private static int parseAlpha(String value) { + var alpha = Double.parseDouble(value); + return clampChannel(roundToInt(alpha <= 1 ? alpha * 255 : alpha)); + } + + private static int roundToInt(double value) { + return Long.valueOf(Math.round(value)).intValue(); + } + + private static int clampChannel(int value) { + return Math.clamp(value, 0, 255); + } + + private static Color withAlpha(Color color, int alpha) { + return new Color(color.getRed(), color.getGreen(), color.getBlue(), clampChannel(alpha)); + } + + private static Color scaleColor(Color color, float factor) { + return new Color( + clampChannel(Math.round(color.getRed() * factor)), + clampChannel(Math.round(color.getGreen() * factor)), + clampChannel(Math.round(color.getBlue() * factor)), + color.getAlpha()); + } +} diff --git a/src/main/java/com/getpcpanel/platform/LinuxBuild.java b/src/main/java/com/getpcpanel/platform/LinuxBuild.java new file mode 100644 index 00000000..52690ac5 --- /dev/null +++ b/src/main/java/com/getpcpanel/platform/LinuxBuild.java @@ -0,0 +1,19 @@ +package com.getpcpanel.platform; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import io.quarkus.arc.properties.IfBuildProperty; +import jakarta.enterprise.inject.Stereotype; + +@Stereotype +@IfBuildProperty(name = "pcpanel.build.os", stringValue = "linux") +@Retention(RUNTIME) +@Target(TYPE) +public @interface LinuxBuild { +} + + diff --git a/src/main/java/com/getpcpanel/platform/WindowsBuild.java b/src/main/java/com/getpcpanel/platform/WindowsBuild.java new file mode 100644 index 00000000..13319075 --- /dev/null +++ b/src/main/java/com/getpcpanel/platform/WindowsBuild.java @@ -0,0 +1,18 @@ +package com.getpcpanel.platform; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import io.quarkus.arc.properties.IfBuildProperty; +import jakarta.enterprise.inject.Stereotype; + +@Stereotype +@IfBuildProperty(name = "pcpanel.build.os", stringValue = "windows") +@Retention(RUNTIME) +@Target(TYPE) +public @interface WindowsBuild { +} + diff --git a/src/main/java/com/getpcpanel/profile/DeviceSave.java b/src/main/java/com/getpcpanel/profile/DeviceSave.java index 10411cf4..9dc517e8 100644 --- a/src/main/java/com/getpcpanel/profile/DeviceSave.java +++ b/src/main/java/com/getpcpanel/profile/DeviceSave.java @@ -46,14 +46,14 @@ public Optional getProfile(@Nullable String name) { if (name == null) { return Optional.empty(); } - return StreamEx.of(getProfiles()).findFirst(p -> p.getName().equals(name)); + return StreamEx.of(profiles).findFirst(p -> p.getName().equals(name)); } @JsonIgnore private Optional getCurrentProfile() { var p = getProfile(currentProfileName); if (!profiles.isEmpty() && p.isEmpty()) { - return Optional.of(getProfiles().get(0)); + return Optional.of(profiles.get(0)); } return p; } diff --git a/src/main/java/com/getpcpanel/profile/KnobSettingMapDeserializer.java b/src/main/java/com/getpcpanel/profile/KnobSettingMapDeserializer.java index 207c60b1..3f214743 100644 --- a/src/main/java/com/getpcpanel/profile/KnobSettingMapDeserializer.java +++ b/src/main/java/com/getpcpanel/profile/KnobSettingMapDeserializer.java @@ -9,6 +9,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; +import com.getpcpanel.profile.dto.KnobSetting; public class KnobSettingMapDeserializer extends JsonDeserializer> { @Override diff --git a/src/main/java/com/getpcpanel/profile/LightingChangedToDefaultEvent.java b/src/main/java/com/getpcpanel/profile/LightingChangedToDefaultEvent.java new file mode 100644 index 00000000..e356d4eb --- /dev/null +++ b/src/main/java/com/getpcpanel/profile/LightingChangedToDefaultEvent.java @@ -0,0 +1,8 @@ +package com.getpcpanel.profile; + +/** + * Fired when lighting is changed back to its default/profile setting. + * Moved from com.getpcpanel.ui to profile package as part of Quarkus migration. + */ +public record LightingChangedToDefaultEvent(String serialNum) { +} diff --git a/src/main/java/com/getpcpanel/profile/Profile.java b/src/main/java/com/getpcpanel/profile/Profile.java index 42196830..8176e6c8 100644 --- a/src/main/java/com/getpcpanel/profile/Profile.java +++ b/src/main/java/com/getpcpanel/profile/Profile.java @@ -9,6 +9,9 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.getpcpanel.commands.Commands; import com.getpcpanel.device.DeviceType; +import com.getpcpanel.profile.dto.KnobSetting; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.profile.dto.OSCBinding; import lombok.Data; @@ -34,7 +37,7 @@ public Profile(String name, DeviceType dt) { protected Profile() { } - public LightingConfig getLightingConfig() { + public LightingConfig lightingConfig() { return lightingConfig.deepCopy(); } diff --git a/src/main/java/com/getpcpanel/profile/ProfileWindowFocusService.java b/src/main/java/com/getpcpanel/profile/ProfileWindowFocusService.java index ffb4064e..fc95107c 100644 --- a/src/main/java/com/getpcpanel/profile/ProfileWindowFocusService.java +++ b/src/main/java/com/getpcpanel/profile/ProfileWindowFocusService.java @@ -1,21 +1,21 @@ package com.getpcpanel.profile; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; +import jakarta.inject.Inject; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.context.ApplicationScoped; import com.getpcpanel.cpp.windows.WindowFocusChangedEvent; import com.getpcpanel.hid.DeviceHolder; import lombok.RequiredArgsConstructor; -@Service -@RequiredArgsConstructor +@ApplicationScoped public class ProfileWindowFocusService { - private final DeviceHolder devices; + @Inject + DeviceHolder devices; private String previousApplication = ""; - @EventListener - public void onFocusChanged(WindowFocusChangedEvent event) { + public void onFocusChanged(@Observes WindowFocusChangedEvent event) { devices.values().forEach(d -> d.focusChanged(previousApplication, event.application())); previousApplication = event.application(); } diff --git a/src/main/java/com/getpcpanel/profile/Save.java b/src/main/java/com/getpcpanel/profile/Save.java index df9bb4a1..b8791689 100644 --- a/src/main/java/com/getpcpanel/profile/Save.java +++ b/src/main/java/com/getpcpanel/profile/Save.java @@ -9,7 +9,10 @@ import javax.annotation.Nullable; import com.getpcpanel.device.DeviceType; -import com.getpcpanel.ui.OverlayPosition; +import com.getpcpanel.profile.dto.MqttSettings; +import com.getpcpanel.profile.dto.OSCConnectionInfo; +import com.getpcpanel.profile.dto.OverlayPosition; +import com.getpcpanel.profile.dto.WaveLinkSettings; import lombok.Data; import lombok.extern.log4j.Log4j2; @@ -53,7 +56,7 @@ public class Save { private String overlayTextColor = DEFAULT_OVERLAY_TEXT_COLOR; private String overlayBarColor = DEFAULT_OVERLAY_BAR_COLOR; private String overlayBarBackgroundColor = DEFAULT_OVERLAY_BAR_BACKGROUND_COLOR; - @Nullable private Integer overlayWindowCornerRounding = 0; + private int overlayWindowCornerRounding; @Nullable private Integer overlayBarHeight = DEFAULT_OVERLAY_BAR_HEIGHT; @Nullable private Integer overlayBarCornerRounding = 0; @Nullable private OverlayPosition overlayPosition = DEFAULT_OVERLAY_POSITION; @@ -85,10 +88,6 @@ public void setSendOnlyIfDelta(Integer sendOnlyIfDelta) { this.sendOnlyIfDelta = sendOnlyIfDelta == null || sendOnlyIfDelta == 0 ? null : sendOnlyIfDelta; } - public int getOverlayWindowCornerRounding() { - return overlayWindowCornerRounding == null ? 0 : overlayWindowCornerRounding; - } - public int getOverlayBarCornerRounding() { return overlayBarCornerRounding == null ? 0 : overlayBarCornerRounding; } diff --git a/src/main/java/com/getpcpanel/profile/SaveService.java b/src/main/java/com/getpcpanel/profile/SaveService.java index 773029b5..a77309bf 100644 --- a/src/main/java/com/getpcpanel/profile/SaveService.java +++ b/src/main/java/com/getpcpanel/profile/SaveService.java @@ -11,35 +11,35 @@ import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.annotation.Lazy; -import org.springframework.stereotype.Service; import com.getpcpanel.Json; import com.getpcpanel.hid.DeviceHolder; import com.getpcpanel.util.Debouncer; import com.getpcpanel.util.FileUtil; +import io.quarkus.runtime.StartupEvent; import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import lombok.Setter; +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; import lombok.extern.log4j.Log4j2; import one.util.streamex.StreamEx; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public class SaveService { private static final String saveFileName = "profiles.json"; - private final ApplicationEventPublisher eventPublisher; - private final FileUtil fileUtil; - private final Json json; - private final Debouncer debouncer; - @Autowired @Lazy @Setter private DeviceHolder devices; + @Inject Event eventBus; + @Inject FileUtil fileUtil; + @Inject Json json; + @Inject Debouncer debouncer; + @Inject DeviceHolder devices; @SuppressWarnings("StaticNonFinalField") private static String oldVersionEncountered; private Save save; + private boolean isNew = false; public Save get() { return save; @@ -54,27 +54,45 @@ public void load() { if (!saveFile.exists()) { log.info("No save file found, creating new one"); save = new Save(); - eventPublisher.publishEvent(new SaveEvent(save, true)); + isNew = true; return; } try { save = json.read(FileUtils.readFileToString(saveFile, Charset.defaultCharset()), Save.class); handleOldVersionEncountered(); - StreamEx.ofValues(save.getDevices()).forEach(d -> StreamEx.of(d.getProfiles()).findFirst(Profile::isMainProfile).ifPresent(p -> d.setCurrentProfile(p.getName()))); - eventPublisher.publishEvent(new SaveEvent(save, false)); + StreamEx.ofValues(save.getDevices()).forEach(d -> StreamEx.of(d.getProfiles()).findFirst(p -> p.isMainProfile()).ifPresent(p -> d.setCurrentProfile(p.getName()))); } catch (Exception e) { log.error("Unable to read file", e); save = new Save(); + isNew = true; } } + /** + * Fire the initial SaveEvent after all beans are fully initialized. + * Using @Priority(1) to ensure this runs before DeviceScanner.onStart() (default priority ~2000). + */ + @Priority(1) + public void onStart(@Observes StartupEvent ev) { + eventBus.fire(new SaveEvent(save, isNew)); + } + private void handleOldVersionEncountered() { if (StringUtils.isBlank(oldVersionEncountered)) { return; } backup(); - save(); + writeToFile(); // write file only, SaveEvent will be fired from onStart() + } + + private void writeToFile() { + var saveFile = fileUtil.getFile(saveFileName); + try { + FileUtils.writeStringToFile(saveFile, json.writePretty(save), Charset.defaultCharset()); + } catch (IOException e) { + log.error("Unable to save file", e); + } } private void backup() { @@ -108,14 +126,8 @@ private void tryMigrate(File saveFile) { } public void save() { - var saveFile = fileUtil.getFile(saveFileName); - try { - FileUtils.writeStringToFile(saveFile, json.writePretty(save), Charset.defaultCharset()); - } catch (IOException e) { - log.error("Unable to save file", e); - } - - eventPublisher.publishEvent(new SaveEvent(save, false)); + writeToFile(); + eventBus.fire(new SaveEvent(save, false)); } public void debouncedSave() { @@ -123,10 +135,9 @@ public void debouncedSave() { } public Optional getProfile(String serialNum) { - return devices.getDevice(serialNum).map(device -> get().getDeviceSave(serialNum).ensureCurrentProfile(device.getDeviceType())); + return devices.getDevice(serialNum).map(device -> get().getDeviceSave(serialNum).ensureCurrentProfile(device.deviceType())); } public record SaveEvent(Save save, boolean isNew) { } } - diff --git a/src/main/java/com/getpcpanel/profile/KnobSetting.java b/src/main/java/com/getpcpanel/profile/dto/KnobSetting.java similarity index 85% rename from src/main/java/com/getpcpanel/profile/KnobSetting.java rename to src/main/java/com/getpcpanel/profile/dto/KnobSetting.java index 03706ff6..052b76cb 100644 --- a/src/main/java/com/getpcpanel/profile/KnobSetting.java +++ b/src/main/java/com/getpcpanel/profile/dto/KnobSetting.java @@ -1,4 +1,4 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; import lombok.Data; diff --git a/src/main/java/com/getpcpanel/profile/LightingConfig.java b/src/main/java/com/getpcpanel/profile/dto/LightingConfig.java similarity index 77% rename from src/main/java/com/getpcpanel/profile/LightingConfig.java rename to src/main/java/com/getpcpanel/profile/dto/LightingConfig.java index eb0a8f86..4d1dd77f 100644 --- a/src/main/java/com/getpcpanel/profile/LightingConfig.java +++ b/src/main/java/com/getpcpanel/profile/dto/LightingConfig.java @@ -1,11 +1,11 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; import java.util.Arrays; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; import com.getpcpanel.device.DeviceType; -import com.getpcpanel.util.Util; -import javafx.scene.paint.Color; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -13,6 +13,7 @@ @AllArgsConstructor @Builder(toBuilder = true) +@JsonAutoDetect(fieldVisibility = Visibility.ANY) public class LightingConfig { private LightingMode lightingMode; private String[] individualColors = {}; @@ -89,31 +90,6 @@ public static LightingConfig defaultLightingConfig(DeviceType dt) { throw new IllegalArgumentException("unknown deviceType"); } - public static LightingConfig createSingleColor(Color[] color, boolean[] volumeTracking) { - var lc = new LightingConfig(); - lc.lightingMode = LightingMode.SINGLE_COLOR; - lc.individualColors = new String[color.length]; - for (var i = 0; i < color.length; i++) - lc.individualColors[i] = Util.formatHexString(color[i]); - lc.volumeBrightnessTrackingEnabled = volumeTracking; - return lc; - } - - public static LightingConfig createAllColor(Color color, boolean[] volumeTracking) { - var lc = new LightingConfig(); - lc.lightingMode = LightingMode.ALL_COLOR; - lc.allColor = Util.formatHexString(color); - lc.volumeBrightnessTrackingEnabled = volumeTracking; - return lc; - } - - public static LightingConfig createAllColor(Color color) { - var lc = new LightingConfig(); - lc.lightingMode = LightingMode.ALL_COLOR; - lc.allColor = Util.formatHexString(color); - return lc; - } - public static LightingConfig createRainbowAnimation(byte phaseShift, byte brightness, byte speed, boolean reverse) { var lc = new LightingConfig(); lc.lightingMode = LightingMode.ALL_RAINBOW; @@ -155,7 +131,14 @@ public static LightingConfig createBreathAnimation(byte hue, byte brightness, by return lc; } - public LightingMode getLightingMode() { + public static LightingConfig createAllColor(String color) { + var lc = new LightingConfig(); + lc.lightingMode = LightingMode.ALL_COLOR; + lc.allColor = color; + return lc; + } + + public LightingMode lightingMode() { return lightingMode; } @@ -163,37 +146,37 @@ public void setLightingMode(LightingMode lightingMode) { this.lightingMode = lightingMode; } - public String[] getIndividualColors() { + public String[] individualColors() { return individualColors; } - public String getAllColor() { + public String allColor() { return allColor; } - public boolean[] getVolumeBrightnessTrackingEnabled() { + public boolean[] volumeBrightnessTrackingEnabled() { if (volumeBrightnessTrackingEnabled == null) volumeBrightnessTrackingEnabled = new boolean[0]; return volumeBrightnessTrackingEnabled; } - public byte getRainbowPhaseShift() { + public byte rainbowPhaseShift() { return rainbowPhaseShift; } - public byte getRainbowBrightness() { + public byte rainbowBrightness() { return rainbowBrightness; } - public byte getRainbowSpeed() { + public byte rainbowSpeed() { return rainbowSpeed; } - public byte getRainbowReverse() { + public byte rainbowReverse() { return rainbowReverse; } - public byte getRainbowVertical() { + public byte rainbowVertical() { return rainbowVertical; } @@ -209,52 +192,54 @@ public void setRainbowSpeed(byte rainbowSpeed) { this.rainbowSpeed = rainbowSpeed; } - public byte getWaveHue() { + public byte waveHue() { return waveHue; } - public byte getWaveBrightness() { + public byte waveBrightness() { return waveBrightness; } - public byte getWaveSpeed() { + public byte waveSpeed() { return waveSpeed; } - public byte getWaveReverse() { + public byte waveReverse() { return waveReverse; } - public byte getWaveBounce() { + public byte waveBounce() { return waveBounce; } - public byte getBreathHue() { + public byte breathHue() { return breathHue; } - public byte getBreathBrightness() { + public byte breathBrightness() { return breathBrightness; } - public byte getBreathSpeed() { + public byte breathSpeed() { return breathSpeed; } - public SingleKnobLightingConfig[] getKnobConfigs() { + public SingleKnobLightingConfig[] knobConfigs() { return knobConfigs; } - public SingleSliderLabelLightingConfig[] getSliderLabelConfigs() { + public SingleSliderLabelLightingConfig[] sliderLabelConfigs() { return sliderLabelConfigs; } - public SingleSliderLightingConfig[] getSliderConfigs() { + public SingleSliderLightingConfig[] sliderConfigs() { return sliderConfigs; } - public SingleLogoLightingConfig getLogoConfig() { + public SingleLogoLightingConfig logoConfig() { + if (logoConfig == null) { + logoConfig = new SingleLogoLightingConfig(); + } return logoConfig; } } - diff --git a/src/main/java/com/getpcpanel/profile/MqttSettings.java b/src/main/java/com/getpcpanel/profile/dto/MqttSettings.java similarity index 96% rename from src/main/java/com/getpcpanel/profile/MqttSettings.java rename to src/main/java/com/getpcpanel/profile/dto/MqttSettings.java index ca89202c..9284dd70 100644 --- a/src/main/java/com/getpcpanel/profile/MqttSettings.java +++ b/src/main/java/com/getpcpanel/profile/dto/MqttSettings.java @@ -1,4 +1,4 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; public record MqttSettings(boolean enabled, String host, Integer port, String username, String password, boolean secure, String baseTopic, diff --git a/src/main/java/com/getpcpanel/profile/OSCBinding.java b/src/main/java/com/getpcpanel/profile/dto/OSCBinding.java similarity index 81% rename from src/main/java/com/getpcpanel/profile/OSCBinding.java rename to src/main/java/com/getpcpanel/profile/dto/OSCBinding.java index 8ed7249e..6b0c4b69 100644 --- a/src/main/java/com/getpcpanel/profile/OSCBinding.java +++ b/src/main/java/com/getpcpanel/profile/dto/OSCBinding.java @@ -1,4 +1,4 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; public record OSCBinding(String address, float min, float max, boolean toggle) { public static final OSCBinding EMPTY = new OSCBinding("", 0, 1, false); diff --git a/src/main/java/com/getpcpanel/profile/OSCConnectionInfo.java b/src/main/java/com/getpcpanel/profile/dto/OSCConnectionInfo.java similarity index 62% rename from src/main/java/com/getpcpanel/profile/OSCConnectionInfo.java rename to src/main/java/com/getpcpanel/profile/dto/OSCConnectionInfo.java index 059301ce..d522db31 100644 --- a/src/main/java/com/getpcpanel/profile/OSCConnectionInfo.java +++ b/src/main/java/com/getpcpanel/profile/dto/OSCConnectionInfo.java @@ -1,4 +1,4 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; public record OSCConnectionInfo(String host, int port) { } diff --git a/src/main/java/com/getpcpanel/profile/dto/OverlayPosition.java b/src/main/java/com/getpcpanel/profile/dto/OverlayPosition.java new file mode 100644 index 00000000..8170d8b5 --- /dev/null +++ b/src/main/java/com/getpcpanel/profile/dto/OverlayPosition.java @@ -0,0 +1,9 @@ +package com.getpcpanel.profile.dto; + +/** + * Position options for the on-screen overlay. + * Moved from com.getpcpanel.ui to profile package as part of Quarkus migration. + */ +public enum OverlayPosition { + topLeft, topMiddle, topRight, middleLeft, middleMiddle, middleRight, bottomLeft, bottomMiddle, bottomRight +} diff --git a/src/main/java/com/getpcpanel/profile/SingleKnobLightingConfig.java b/src/main/java/com/getpcpanel/profile/dto/SingleKnobLightingConfig.java similarity index 50% rename from src/main/java/com/getpcpanel/profile/SingleKnobLightingConfig.java rename to src/main/java/com/getpcpanel/profile/dto/SingleKnobLightingConfig.java index 1234b62b..133605aa 100644 --- a/src/main/java/com/getpcpanel/profile/SingleKnobLightingConfig.java +++ b/src/main/java/com/getpcpanel/profile/dto/SingleKnobLightingConfig.java @@ -1,11 +1,7 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; import javax.annotation.Nullable; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.getpcpanel.util.Util; - -import javafx.scene.paint.Color; import lombok.Data; @Data @@ -24,25 +20,6 @@ public enum SINGLE_KNOB_MODE { NONE, STATIC, VOLUME_GRADIENT } - @JsonIgnore - public void setColor1FromColor(Color color1) { - this.color1 = Util.formatHexString(color1); - } - - @JsonIgnore - public void setColor2FromColor(Color color2) { - this.color2 = Util.formatHexString(color2); - } - - @JsonIgnore - public void setMuteOverrideColorFromColor(Color color) { - if (color == null) { - muteOverrideColor = null; - } else { - muteOverrideColor = Util.formatHexString(color); - } - } - public void set(SingleKnobLightingConfig c) { color1 = c.color1; color2 = c.color2; @@ -50,4 +27,3 @@ public void set(SingleKnobLightingConfig c) { mode = c.mode; } } - diff --git a/src/main/java/com/getpcpanel/profile/SingleLogoLightingConfig.java b/src/main/java/com/getpcpanel/profile/dto/SingleLogoLightingConfig.java similarity index 68% rename from src/main/java/com/getpcpanel/profile/SingleLogoLightingConfig.java rename to src/main/java/com/getpcpanel/profile/dto/SingleLogoLightingConfig.java index 105bc463..ea6935a3 100644 --- a/src/main/java/com/getpcpanel/profile/SingleLogoLightingConfig.java +++ b/src/main/java/com/getpcpanel/profile/dto/SingleLogoLightingConfig.java @@ -1,8 +1,5 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; -import com.getpcpanel.util.Util; - -import javafx.scene.paint.Color; import lombok.Data; @Data @@ -21,11 +18,6 @@ public enum SINGLE_LOGO_MODE { NONE, STATIC, RAINBOW, BREATH } - public SingleLogoLightingConfig setColor(Color color) { - this.color = Util.formatHexString(color); - return this; - } - /** * Used by Jackson */ @@ -34,4 +26,3 @@ public SingleLogoLightingConfig setColor(String color) { return this; } } - diff --git a/src/main/java/com/getpcpanel/profile/SingleSliderLabelLightingConfig.java b/src/main/java/com/getpcpanel/profile/dto/SingleSliderLabelLightingConfig.java similarity index 55% rename from src/main/java/com/getpcpanel/profile/SingleSliderLabelLightingConfig.java rename to src/main/java/com/getpcpanel/profile/dto/SingleSliderLabelLightingConfig.java index f3ad63cc..4f934176 100644 --- a/src/main/java/com/getpcpanel/profile/SingleSliderLabelLightingConfig.java +++ b/src/main/java/com/getpcpanel/profile/dto/SingleSliderLabelLightingConfig.java @@ -1,9 +1,5 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.getpcpanel.util.Util; - -import javafx.scene.paint.Color; import lombok.Data; @Data @@ -22,19 +18,8 @@ public enum SINGLE_SLIDER_LABEL_MODE { NONE, STATIC } - @JsonIgnore - public void setColorFromColor(Color color) { - this.color = Util.formatHexString(color); - } - - @JsonIgnore - public void setMuteOverrideColorFromColor(Color color) { - muteOverrideColor = Util.formatHexString(color); - } - public void set(SingleSliderLabelLightingConfig c) { color = c.color; mode = c.mode; } } - diff --git a/src/main/java/com/getpcpanel/profile/SingleSliderLightingConfig.java b/src/main/java/com/getpcpanel/profile/dto/SingleSliderLightingConfig.java similarity index 51% rename from src/main/java/com/getpcpanel/profile/SingleSliderLightingConfig.java rename to src/main/java/com/getpcpanel/profile/dto/SingleSliderLightingConfig.java index d3f76174..98047c7c 100644 --- a/src/main/java/com/getpcpanel/profile/SingleSliderLightingConfig.java +++ b/src/main/java/com/getpcpanel/profile/dto/SingleSliderLightingConfig.java @@ -1,9 +1,5 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.getpcpanel.util.Util; - -import javafx.scene.paint.Color; import lombok.Data; @Data @@ -22,25 +18,9 @@ public enum SINGLE_SLIDER_MODE { NONE, STATIC, STATIC_GRADIENT, VOLUME_GRADIENT } - @JsonIgnore - public void setColor1FromColor(Color color1) { - this.color1 = Util.formatHexString(color1); - } - - @JsonIgnore - public void setColor2FromColor(Color color2) { - this.color2 = Util.formatHexString(color2); - } - - @JsonIgnore - public void setMuteOverrideColorFromColor(Color color) { - muteOverrideColor = Util.formatHexString(color); - } - public void set(SingleSliderLightingConfig c) { color1 = c.color1; color2 = c.color2; mode = c.mode; } } - diff --git a/src/main/java/com/getpcpanel/profile/WaveLinkSettings.java b/src/main/java/com/getpcpanel/profile/dto/WaveLinkSettings.java similarity index 78% rename from src/main/java/com/getpcpanel/profile/WaveLinkSettings.java rename to src/main/java/com/getpcpanel/profile/dto/WaveLinkSettings.java index cae6a94e..cf5a3965 100644 --- a/src/main/java/com/getpcpanel/profile/WaveLinkSettings.java +++ b/src/main/java/com/getpcpanel/profile/dto/WaveLinkSettings.java @@ -1,4 +1,4 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; public record WaveLinkSettings(boolean enabled) { public static final WaveLinkSettings DEFAULT = new WaveLinkSettings(false); diff --git a/src/main/java/com/getpcpanel/rest/AudioResource.java b/src/main/java/com/getpcpanel/rest/AudioResource.java new file mode 100644 index 00000000..5317e3bb --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/AudioResource.java @@ -0,0 +1,56 @@ +package com.getpcpanel.rest; + +import java.util.Collection; +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import com.getpcpanel.cpp.AudioDevice; +import com.getpcpanel.cpp.AudioSession; +import com.getpcpanel.cpp.ISndCtrl; + +import one.util.streamex.StreamEx; + +@Path("/api/audio") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class AudioResource { + @Inject ISndCtrl sndCtrl; + + @GET + @Path("/devices") + public Collection listAudioDevices() { + return sndCtrl.devices(); + } + + @GET + @Path("/devices/output") + public List listOutputDevices() { + return StreamEx.of(sndCtrl.devices()).filter(AudioDevice::isOutput).toList(); + } + + @GET + @Path("/devices/input") + public List listInputDevices() { + return StreamEx.of(sndCtrl.devices()).filter(AudioDevice::isInput).toList(); + } + + @GET + @Path("/sessions") + public Collection listAudioSessions() { + return sndCtrl.getAllSessions(); + } + + @GET + @Path("/applications") + public List listRunningApplications() { + return sndCtrl.getRunningApplications(); + } +} diff --git a/src/main/java/com/getpcpanel/rest/CommandsResource.java b/src/main/java/com/getpcpanel/rest/CommandsResource.java new file mode 100644 index 00000000..e35e1cdc --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/CommandsResource.java @@ -0,0 +1,98 @@ +package com.getpcpanel.rest; + +import java.util.Collection; +import java.util.List; + +import com.getpcpanel.commands.command.CommandBrightness; +import com.getpcpanel.commands.command.CommandEndProgram; +import com.getpcpanel.commands.command.CommandKeystroke; +import com.getpcpanel.commands.command.CommandMedia; +import com.getpcpanel.commands.command.CommandObsMuteSource; +import com.getpcpanel.commands.command.CommandObsSetScene; +import com.getpcpanel.commands.command.CommandObsSetSourceVolume; +import com.getpcpanel.commands.command.CommandRun; +import com.getpcpanel.commands.command.CommandShortcut; +import com.getpcpanel.commands.command.CommandVoiceMeeterAdvanced; +import com.getpcpanel.commands.command.CommandVoiceMeeterAdvancedButton; +import com.getpcpanel.commands.command.CommandVoiceMeeterBasic; +import com.getpcpanel.commands.command.CommandVoiceMeeterBasicButton; +import com.getpcpanel.commands.command.CommandVolumeApplicationDeviceToggle; +import com.getpcpanel.commands.command.CommandVolumeDefaultDevice; +import com.getpcpanel.commands.command.CommandVolumeDefaultDeviceAdvanced; +import com.getpcpanel.commands.command.CommandVolumeDefaultDeviceToggle; +import com.getpcpanel.commands.command.CommandVolumeDefaultDeviceToggleAdvanced; +import com.getpcpanel.commands.command.CommandVolumeDevice; +import com.getpcpanel.commands.command.CommandVolumeDeviceMute; +import com.getpcpanel.commands.command.CommandVolumeFocus; +import com.getpcpanel.commands.command.CommandVolumeFocusMute; +import com.getpcpanel.commands.command.CommandVolumeProcess; +import com.getpcpanel.commands.command.CommandVolumeProcessMute; +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.rest.EventBroadcaster.AssignmentChangedEvent.Kinds; +import com.getpcpanel.rest.model.dto.CommandType; +import com.getpcpanel.rest.model.dto.CommandType.CommandCategory; +import com.getpcpanel.wavelink.command.CommandWaveLinkAddFocusToChannel; +import com.getpcpanel.wavelink.command.CommandWaveLinkChangeLevel; +import com.getpcpanel.wavelink.command.CommandWaveLinkChangeMute; +import com.getpcpanel.wavelink.command.CommandWaveLinkChannelEffect; +import com.getpcpanel.wavelink.command.CommandWaveLinkMainOutput; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import one.util.streamex.StreamEx; + +@Path("/api/commands") +@ApplicationScoped +public class CommandsResource { + @Inject SaveService saveService; + + private static final List commandTypes = List.of( + new CommandType("Brightness", CommandBrightness.class.getName(), CommandCategory.standard, Kinds.dial), + new CommandType("Process volume", CommandVolumeProcess.class.getName(), CommandCategory.standard, Kinds.dial), + new CommandType("Focus volume", CommandVolumeFocus.class.getName(), CommandCategory.standard, Kinds.dial), + new CommandType("Device volume", CommandVolumeDevice.class.getName(), CommandCategory.standard, Kinds.dial), + new CommandType("Obs Source Volume", CommandObsSetSourceVolume.class.getName(), CommandCategory.obs, Kinds.dial), + new CommandType("VoiceMeeter Advanced", CommandVoiceMeeterAdvanced.class.getName(), CommandCategory.voicemeeter, Kinds.dial), + new CommandType("VoiceMeeter Basic", CommandVoiceMeeterBasic.class.getName(), CommandCategory.voicemeeter, Kinds.dial), + new CommandType("WaveLink Change Level", CommandWaveLinkChangeLevel.class.getName(), CommandCategory.wavelink, Kinds.dial), + + new CommandType("End Program", CommandEndProgram.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Keystroke", CommandKeystroke.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Run", CommandRun.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Shortcut", CommandShortcut.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Media", CommandMedia.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Toggle application device", CommandVolumeApplicationDeviceToggle.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Default Device", CommandVolumeDefaultDevice.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Default Device Advanced", CommandVolumeDefaultDeviceAdvanced.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Default Device Toggle", CommandVolumeDefaultDeviceToggle.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Default Device Toggle Advanced", CommandVolumeDefaultDeviceToggleAdvanced.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Device Mute", CommandVolumeDeviceMute.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Focus Mute", CommandVolumeFocusMute.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Process Mute", CommandVolumeProcessMute.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Obs Mute Source", CommandObsMuteSource.class.getName(), CommandCategory.obs, Kinds.button), + new CommandType("Obs Set Scene", CommandObsSetScene.class.getName(), CommandCategory.obs, Kinds.button), + new CommandType("VoiceMeeter Advanced", CommandVoiceMeeterAdvancedButton.class.getName(), CommandCategory.voicemeeter, Kinds.button), + new CommandType("VoiceMeeter Basic", CommandVoiceMeeterBasicButton.class.getName(), CommandCategory.voicemeeter, Kinds.button), + new CommandType("WaveLink Add Focus To Channel", CommandWaveLinkAddFocusToChannel.class.getName(), CommandCategory.wavelink, Kinds.button), + new CommandType("WaveLink Change Mute", CommandWaveLinkChangeMute.class.getName(), CommandCategory.wavelink, Kinds.button), + new CommandType("WaveLink Channel Effect", CommandWaveLinkChannelEffect.class.getName(), CommandCategory.wavelink, Kinds.button), + new CommandType("WaveLink Main Output", CommandWaveLinkMainOutput.class.getName(), CommandCategory.wavelink, Kinds.button) + ); + + @GET + @Path("/available") + public Collection listAvailableCommands() { + return StreamEx.of(commandTypes).filter(this::enabled).toList(); + } + + private boolean enabled(CommandType commandType) { + return switch (commandType.category()) { + case standard -> true; + case obs -> saveService.get().isObsEnabled(); + case voicemeeter -> saveService.get().isVoicemeeterEnabled(); + case wavelink -> saveService.get().getWaveLink().enabled(); + }; + } +} diff --git a/src/main/java/com/getpcpanel/rest/DeviceResource.java b/src/main/java/com/getpcpanel/rest/DeviceResource.java new file mode 100644 index 00000000..873e0fb2 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/DeviceResource.java @@ -0,0 +1,283 @@ +package com.getpcpanel.rest; + +import java.util.List; +import java.util.Optional; + +import com.getpcpanel.commands.Commands; +import com.getpcpanel.device.Device; +import com.getpcpanel.hid.DeviceHolder; +import com.getpcpanel.profile.DeviceSave; +import com.getpcpanel.profile.Profile; +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.profile.dto.KnobSetting; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.rest.EventBroadcaster.AssignmentChangedEvent; +import com.getpcpanel.rest.EventBroadcaster.AssignmentChangedEvent.Kinds; +import com.getpcpanel.rest.EventBroadcaster.DeviceRenamedEvent; +import com.getpcpanel.rest.EventBroadcaster.LightingChangedEvent; +import com.getpcpanel.rest.EventBroadcaster.ProfileSwitchedEvent; +import com.getpcpanel.rest.EventBroadcaster.KnobSettingChangedEvent; +import com.getpcpanel.rest.model.dto.ControlAssignmentsUpdateDto; +import com.getpcpanel.rest.model.dto.DeviceDto; +import com.getpcpanel.rest.model.dto.ProfileDto; +import com.getpcpanel.rest.model.dto.ProfileSnapshotDto; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import one.util.streamex.StreamEx; + +@Path("/api/devices") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class DeviceResource { + @Inject DeviceHolder deviceHolder; + @Inject SaveService saveService; + @Inject Event eventBus; + + @GET + public List listDevices() { + var save = saveService.get(); + return StreamEx.of(deviceHolder.all()) + .map(d -> DeviceDto.from(d, save.getDeviceSave(d.getSerialNumber()))) + .toList(); + } + + @GET + @Path("/{serial}") + public DeviceDto getDevice(@PathParam("serial") String serial) { + var device = deviceHolder.getDevice(serial).orElseThrow(NotFoundException::new); + return DeviceDto.from(device, saveService.get().getDeviceSave(serial)); + } + + @PUT + @Path("/{serial}/name") + public Response renameDevice(@PathParam("serial") String serial, String name) { + var device = deviceHolder.getDevice(serial).orElseThrow(NotFoundException::new); + device.setDisplayName(name); + saveService.save(); + eventBus.fire(new DeviceRenamedEvent(serial, name)); + return Response.ok().build(); + } + + // ── Profiles ────────────────────────────────────────────────────────────── + + @GET + @Path("/{serial}/profiles") + public List listProfiles(@PathParam("serial") String serial) { + var deviceSave = getDeviceSave(serial); + return StreamEx.of(deviceSave.getProfiles()).map(ProfileDto::from).toList(); + } + + @POST + @Path("/{serial}/profiles") + public Response createProfile(@PathParam("serial") String serial, String name) { + var deviceSave = getDeviceSave(serial); + var device = deviceHolder.getDevice(serial).orElseThrow(NotFoundException::new); + var profile = new Profile(name, device.deviceType()); + deviceSave.getProfiles().add(profile); + saveService.save(); + return Response.ok(ProfileDto.from(profile)).build(); + } + + @DELETE + @Path("/{serial}/profiles/{name}") + public Response deleteProfile(@PathParam("serial") String serial, @PathParam("name") String name) { + var deviceSave = getDeviceSave(serial); + deviceSave.getProfiles().removeIf(p -> p.getName().equals(name)); + saveService.save(); + return Response.noContent().build(); + } + + @PUT + @Path("/{serial}/profiles/current") + public Response switchProfile(@PathParam("serial") String serial, String name) { + var deviceSave = getDeviceSave(serial); + var profile = deviceSave.setCurrentProfile(name).orElseThrow(() -> new NotFoundException("Profile not found: " + name)); + saveService.save(); + eventBus.fire(new ProfileSwitchedEvent(serial, name, ProfileSnapshotDto.from(profile))); + return Response.ok().build(); + } + + // ── Button/Dial assignments ──────────────────────────────────────────────── + + @GET + @Path("/{serial}/profiles/{profile}/buttons/{index}") + public Commands getButton(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index) { + return getProfile(serial, profileName).getButtonData(index); + } + + @PUT + @Path("/{serial}/profiles/{profile}/buttons/{index}") + public Response setButton(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index, + Commands commands) { + getProfile(serial, profileName).setButtonData(index, commands); + saveService.save(); + eventBus.fire(new AssignmentChangedEvent(serial, Kinds.button, index, commands)); + return Response.ok().build(); + } + + @GET + @Path("/{serial}/profiles/{profile}/dblbuttons/{index}") + public Commands getDblButton(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index) { + return Optional.ofNullable(getProfile(serial, profileName).getDblButtonData(index)) + .orElse(Commands.EMPTY); + } + + @PUT + @Path("/{serial}/profiles/{profile}/dblbuttons/{index}") + public Response setDblButton(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index, + Commands commands) { + getProfile(serial, profileName).setDblButtonData(index, commands); + saveService.save(); + eventBus.fire(new AssignmentChangedEvent(serial, Kinds.dblbutton, index, commands)); + return Response.ok().build(); + } + + @GET + @Path("/{serial}/profiles/{profile}/dials/{index}") + public Commands getDial(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index) { + return Optional.ofNullable(getProfile(serial, profileName).getDialData(index)) + .orElse(Commands.EMPTY); + } + + @PUT + @Path("/{serial}/profiles/{profile}/dials/{index}") + public Response setDial(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index, + Commands commands) { + getProfile(serial, profileName).setDialData(index, commands); + saveService.save(); + eventBus.fire(new AssignmentChangedEvent(serial, Kinds.dial, index, commands)); + return Response.ok().build(); + } + + @GET + @Path("/{serial}/profiles/{profile}/knobsettings/{index}") + public KnobSetting getKnobSettings(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index) { + return getProfile(serial, profileName).getKnobSettings(index); + } + + @PUT + @Path("/{serial}/profiles/{profile}/knobsettings/{index}") + public Response setKnobSettings(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index, + KnobSetting settings) { + var knob = getProfile(serial, profileName).getKnobSettings(index); + knob.setMinTrim(settings.getMinTrim()); + knob.setMaxTrim(settings.getMaxTrim()); + knob.setLogarithmic(settings.isLogarithmic()); + knob.setOverlayIcon(settings.getOverlayIcon()); + knob.setButtonDebounce(settings.getButtonDebounce()); + saveService.save(); + eventBus.fire(new KnobSettingChangedEvent(serial, index, knob)); + return Response.ok().build(); + } + + // ── Lighting ────────────────────────────────────────────────────────────── + + @GET + @Path("/{serial}/lighting") + public LightingConfig getLighting(@PathParam("serial") String serial) { + return deviceHolder.getDevice(serial) + .map(Device::getSavedLightingConfig) + .orElseThrow(NotFoundException::new); + } + + @PUT + @Path("/{serial}/lighting") + public Response setLighting(@PathParam("serial") String serial, LightingConfig config) { + var device = deviceHolder.getDevice(serial).orElseThrow(NotFoundException::new); + device.setSavedLighting(config); + saveService.save(); + eventBus.fire(new LightingChangedEvent(serial, config)); + return Response.ok().build(); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private DeviceSave getDeviceSave(String serial) { + var save = saveService.get(); + var deviceSave = save.getDevices().get(serial); + if (deviceSave == null) { + throw new NotFoundException("Device not found: " + serial); + } + return deviceSave; + } + + private Profile getProfile(String serial, String profileName) { + return getDeviceSave(serial).getProfile(profileName) + .orElseThrow(() -> new NotFoundException("Profile not found: " + profileName)); + } + + @PUT + @Path("/{serial}/profiles/{profile}/controls/{index}") + public Response setControlAssignments(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index, + ControlAssignmentsUpdateDto update) { + var profile = getProfile(serial, profileName); + var changed = false; + + if (update.analog() != null) { + profile.setDialData(index, update.analog()); + eventBus.fire(new AssignmentChangedEvent(serial, Kinds.dial, index, update.analog())); + changed = true; + } + + if (update.button() != null) { + profile.setButtonData(index, update.button()); + eventBus.fire(new AssignmentChangedEvent(serial, Kinds.button, index, update.button())); + changed = true; + } + + if (update.dblButton() != null) { + profile.setDblButtonData(index, update.dblButton()); + eventBus.fire(new AssignmentChangedEvent(serial, Kinds.dblbutton, index, update.dblButton())); + changed = true; + } + + if (update.knobSetting() != null) { + var knob = profile.getKnobSettings(index); + knob.setMinTrim(update.knobSetting().getMinTrim()); + knob.setMaxTrim(update.knobSetting().getMaxTrim()); + knob.setLogarithmic(update.knobSetting().isLogarithmic()); + knob.setOverlayIcon(update.knobSetting().getOverlayIcon()); + knob.setButtonDebounce(update.knobSetting().getButtonDebounce()); + eventBus.fire(new KnobSettingChangedEvent(serial, index, knob)); + changed = true; + } + + if (changed) { + saveService.save(); + } + + return Response.ok().build(); + } +} diff --git a/src/main/java/com/getpcpanel/rest/EventBroadcaster.java b/src/main/java/com/getpcpanel/rest/EventBroadcaster.java new file mode 100644 index 00000000..71ebb408 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/EventBroadcaster.java @@ -0,0 +1,134 @@ +package com.getpcpanel.rest; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.getpcpanel.commands.Commands; +import com.getpcpanel.hid.DeviceCommunicationHandler.ButtonPressEvent; +import com.getpcpanel.hid.DeviceCommunicationHandler.KnobRotateEvent; +import com.getpcpanel.hid.DeviceHolder; +import com.getpcpanel.hid.DeviceHolder.DeviceFullyConnectedEvent; +import com.getpcpanel.hid.DeviceScanner.DeviceDisconnectedEvent; +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.profile.dto.KnobSetting; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.rest.ProVisualColorsService.ProVisualColors; +import com.getpcpanel.rest.model.dto.DeviceSnapshotDto; +import com.getpcpanel.rest.model.dto.ProfileSnapshotDto; +import com.getpcpanel.rest.model.ws.WsAssignmentChangedEvent; +import com.getpcpanel.rest.model.ws.WsButtonEvent; +import com.getpcpanel.rest.model.ws.WsControlSettingChangedEvent; +import com.getpcpanel.rest.model.ws.WsDeviceConnectedEvent; +import com.getpcpanel.rest.model.ws.WsDeviceDisconnectedEvent; +import com.getpcpanel.rest.model.ws.WsDeviceRenamedEvent; +import com.getpcpanel.rest.model.ws.WsKnobEvent; +import com.getpcpanel.rest.model.ws.WsLightingChangedEvent; +import com.getpcpanel.rest.model.ws.WsProfileSwitchedEvent; +import com.getpcpanel.rest.model.ws.WsVisualColorsChangedEvent; +import com.getpcpanel.util.AppShutdownState; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +@ApplicationScoped +public class EventBroadcaster { + @Inject ObjectMapper objectMapper; + @Inject SaveService saveService; + @Inject DeviceHolder deviceHolder; + @Inject ProVisualColorsService proVisualColorsService; + + private boolean shouldSkipBroadcast() { + return AppShutdownState.isShuttingDown(); + } + + private void broadcast(Object event) { + if (shouldSkipBroadcast()) + return; + EventWebSocket.broadcast(event, objectMapper); + } + + // ── Existing operational events ──────────────────────────────────────────── + + public void onDeviceConnected(@Observes DeviceFullyConnectedEvent event) { + var serial = event.device().getSerialNumber(); + var save = saveService.get().getDeviceSave(serial); + if (save == null) { + log.debug("Skipping device_connected broadcast for {} because no device save exists", serial); + return; + } + + var snapshot = DeviceSnapshotDto.from(event.device(), save, proVisualColorsService); + broadcast(new WsDeviceConnectedEvent(snapshot)); + } + + public void onDeviceDisconnected(@Observes DeviceDisconnectedEvent event) { + broadcast(new WsDeviceDisconnectedEvent(event.serialNum())); + } + + public void onKnobRotate(@Observes KnobRotateEvent event) { + broadcast(new WsKnobEvent(event.serialNum(), event.knob(), event.value())); + } + + public void onButtonPress(@Observes ButtonPressEvent event) { + broadcast(new WsButtonEvent(event.serialNum(), event.button(), event.pressed())); + } + + // ── Mutation patch events ────────────────────────────────────────────────── + + public void onDeviceRenamed(@Observes DeviceRenamedEvent event) { + broadcast(new WsDeviceRenamedEvent(event.serial(), event.displayName())); + } + + public void onProfileSwitched(@Observes ProfileSwitchedEvent event) { + var colors = colorsFor(event.serial()); + broadcast(new WsProfileSwitchedEvent(event.serial(), event.profileName(), event.profileSnapshot(), colors.dialColors(), colors.sliderLabelColors(), colors.sliderColors(), colors.logoColor())); + } + + public void onLightingChanged(@Observes LightingChangedEvent event) { + var colors = colorsFor(event.serial()); + broadcast(new WsLightingChangedEvent(event.serial(), event.lightingConfig(), colors.dialColors(), colors.sliderLabelColors(), colors.sliderColors(), colors.logoColor())); + } + + public void onVisualColorsChanged(@Observes VisualColorsChangedEvent event) { + var colors = colorsFor(event.serial()); + broadcast(new WsVisualColorsChangedEvent(event.serial(), colors.dialColors(), colors.sliderLabelColors(), colors.sliderColors(), colors.logoColor())); + } + + public void onAssignmentChanged(@Observes AssignmentChangedEvent event) { + broadcast(new WsAssignmentChangedEvent(event.serial(), event.kind(), event.index(), event.commands())); + } + + public void onSettingChanged(@Observes KnobSettingChangedEvent event) { + broadcast(new WsControlSettingChangedEvent(event.serial(), event.index(), event.settings())); + } + + // ── CDI mutation events (fired by DeviceResource) ───────────────────────── + + public record DeviceRenamedEvent(String serial, String displayName) { + } + + public record ProfileSwitchedEvent(String serial, String profileName, ProfileSnapshotDto profileSnapshot) { + } + + public record LightingChangedEvent(String serial, LightingConfig lightingConfig) { + } + + public record VisualColorsChangedEvent(String serial) { + } + + public record KnobSettingChangedEvent(String serial, int index, KnobSetting settings) { + } + + public record AssignmentChangedEvent(String serial, Kinds kind, int index, Commands commands) { + public enum Kinds { + dial, button, dblbutton + } + } + + private ProVisualColors colorsFor(String serial) { + return deviceHolder.getDevice(serial) + .map(proVisualColorsService::resolve) + .orElse(ProVisualColors.empty()); + } +} diff --git a/src/main/java/com/getpcpanel/rest/EventWebSocket.java b/src/main/java/com/getpcpanel/rest/EventWebSocket.java new file mode 100644 index 00000000..8fdb5c94 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/EventWebSocket.java @@ -0,0 +1,86 @@ +package com.getpcpanel.rest; + +import java.util.concurrent.CopyOnWriteArraySet; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.getpcpanel.hid.DeviceHolder; +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.rest.model.dto.DeviceSnapshotDto; +import com.getpcpanel.rest.model.ws.WsDeviceConnectedEvent; +import com.getpcpanel.util.AppShutdownState; + +import io.quarkus.websockets.next.OnClose; +import io.quarkus.websockets.next.OnOpen; +import io.quarkus.websockets.next.WebSocket; +import io.quarkus.websockets.next.WebSocketConnection; +import jakarta.inject.Inject; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +@WebSocket(path = "/ws/events") +public class EventWebSocket { + private static final CopyOnWriteArraySet connections = new CopyOnWriteArraySet<>(); + + @Inject ObjectMapper objectMapper; + @Inject DeviceHolder deviceHolder; + @Inject SaveService saveService; + @Inject ProVisualColorsService proVisualColorsService; + + @OnOpen + public void onOpen(WebSocketConnection connection) { + if (AppShutdownState.isShuttingDown()) { + log.debug("Ignoring websocket connection {} because shutdown is in progress", connection.id()); + return; + } + connections.add(connection); + log.debug("WebSocket client connected: {} (total connections: {})", connection.id(), connections.size()); + sendInitialSnapshots(connection); + } + + @OnClose + public void onClose(WebSocketConnection connection) { + connections.remove(connection); + log.debug("WebSocket client disconnected: {} (remaining connections: {})", connection.id(), connections.size()); + } + + private void sendInitialSnapshots(WebSocketConnection connection) { + var save = saveService.get(); + deviceHolder.all().forEach(device -> { + try { + var deviceSave = save.getDeviceSave(device.getSerialNumber()); + if (deviceSave == null) { + log.debug("Skipping initial device_connected for {} because no device save exists", device.getSerialNumber()); + return; + } + + var snapshot = DeviceSnapshotDto.from(device, deviceSave, proVisualColorsService); + var connectedEvent = new WsDeviceConnectedEvent(snapshot); + var json = objectMapper.writeValueAsString(connectedEvent); + connection.sendTextAndAwait(json); + } catch (Exception e) { + log.warn("Failed to send initial device_connected for {} to new WS connection {}", device.getSerialNumber(), connection.id(), e); + } + }); + } + + public static void broadcast(Object event, ObjectMapper mapper) { + if (AppShutdownState.isShuttingDown()) { + connections.clear(); + return; + } + try { + var json = mapper.writeValueAsString(event); + log.debug("Broadcasting event to {} WebSocket clients: {}", connections.size(), json); + connections.forEach(c -> { + try { + c.sendTextAndAwait(json); + } catch (Exception e) { + log.debug("Failed to send event to WS client {}", c.id(), e); + } + }); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize event", e); + } + } +} diff --git a/src/main/java/com/getpcpanel/rest/IconResource.java b/src/main/java/com/getpcpanel/rest/IconResource.java new file mode 100644 index 00000000..b3276f01 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/IconResource.java @@ -0,0 +1,67 @@ +package com.getpcpanel.rest; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; + +import javax.imageio.ImageIO; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Response; + +import com.getpcpanel.iconextract.IIconService; + +import lombok.extern.log4j.Log4j2; + +@Log4j2 +@Path("/api/icons") +@ApplicationScoped +public class IconResource { + @Inject IIconService iconService; + + @GET + @Produces("image/png") + public Response getIcon(@QueryParam("path") String filePath, + @QueryParam("size") @DefaultValue("32") int size) { + if (filePath == null || filePath.isBlank()) { + throw new NotFoundException(); + } + // Resolve canonical path to prevent path traversal sequences (e.g. "../") + File file; + try { + file = new File(filePath).getCanonicalFile(); + } catch (IOException e) { + throw new NotFoundException(); + } + // Restrict to files with known safe extensions to prevent arbitrary file access + var name = file.getName().toLowerCase(); + var allowed = name.endsWith(".exe") || name.endsWith(".lnk") || name.endsWith(".ico") + || name.endsWith(".png") || name.endsWith(".jpg") || name.endsWith(".jpeg") + || name.endsWith(".bmp") || name.endsWith(".gif"); + if (!allowed) { + throw new NotFoundException(); + } + if (!file.isFile()) { + throw new NotFoundException(); + } + var img = iconService.getIconForFile(size, size, file); + if (img == null) { + throw new NotFoundException(); + } + try { + var baos = new ByteArrayOutputStream(); + ImageIO.write(img, "png", baos); + return Response.ok(baos.toByteArray()).type("image/png").build(); + } catch (Exception e) { + log.error("Failed to encode icon for {}", filePath, e); + return Response.serverError().build(); + } + } +} diff --git a/src/main/java/com/getpcpanel/rest/ObsResource.java b/src/main/java/com/getpcpanel/rest/ObsResource.java new file mode 100644 index 00000000..17fd7221 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/ObsResource.java @@ -0,0 +1,37 @@ +package com.getpcpanel.rest; + +import java.util.List; + +import com.getpcpanel.obs.OBS; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("/api/obs") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +public class ObsResource { + @Inject OBS obs; + + @GET + @Path("/scenes") + public List listScenes() { + if (!obs.isConnected()) { + return List.of(); + } + return obs.getScenes(); + } + + @GET + @Path("/sources") + public List listSources() { + if (!obs.isConnected()) { + return List.of(); + } + return obs.getSourcesWithAudio(); + } +} diff --git a/src/main/java/com/getpcpanel/rest/OverlayResource.java b/src/main/java/com/getpcpanel/rest/OverlayResource.java new file mode 100644 index 00000000..aa2948b4 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/OverlayResource.java @@ -0,0 +1,37 @@ +package com.getpcpanel.rest; + +import com.getpcpanel.overlay.Overlay; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +@Path("/api/overlay") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +public class OverlayResource { + @Inject Overlay overlay; + + @GET + public Response testOverlay() { + System.out.println("Overlay!"); + overlay.show(0); + return Response.ok().build(); + } + + @POST + public Response showOverlay(OverlayDto params) { + overlay.show(params.value()); + return Response.ok().build(); + } + + @RegisterForReflection + public record OverlayDto(int value, String icon) { + } +} diff --git a/src/main/java/com/getpcpanel/rest/ProVisualColorsService.java b/src/main/java/com/getpcpanel/rest/ProVisualColorsService.java new file mode 100644 index 00000000..e4cea3a0 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/ProVisualColorsService.java @@ -0,0 +1,302 @@ +package com.getpcpanel.rest; + +import java.awt.Color; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import com.getpcpanel.device.Device; +import com.getpcpanel.device.DeviceType; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.profile.dto.SingleKnobLightingConfig; +import com.getpcpanel.profile.dto.SingleLogoLightingConfig; +import com.getpcpanel.profile.dto.SingleSliderLabelLightingConfig; +import com.getpcpanel.profile.dto.SingleSliderLightingConfig; +import com.getpcpanel.util.Util; +import com.getpcpanel.util.coloroverride.OverrideColorService; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +@ApplicationScoped +public class ProVisualColorsService { + private static final String BLACK = "#000000"; + private static final int PRO_DIAL_COUNT = 5; + private static final int PRO_SLIDER_COUNT = 4; + private static final int PRO_SLIDER_SEGMENT_COUNT = 5; + + @Inject + OverrideColorService overrideColorService; + + public ProVisualColors resolve(Device device) { + if (device == null || device.deviceType() != DeviceType.PCPANEL_PRO) { + return ProVisualColors.empty(); + } + + var config = device.lightingConfig(); + if (config == null || config.lightingMode() == null) { + return ProVisualColors.defaultForPro(); + } + + return switch (config.lightingMode()) { + case ALL_COLOR -> monochrome(colorOrDefault(config.allColor())); + case ALL_WAVE -> fromWave(config); + case ALL_BREATH -> monochrome(colorFromHue(config.breathHue(), config.breathBrightness())); + case ALL_RAINBOW -> fromRainbow(config); + case CUSTOM -> fromCustom(device.getSerialNumber(), config); + default -> ProVisualColors.defaultForPro(); + }; + } + + private ProVisualColors fromRainbow(LightingConfig config) { + var baseHue = unitByte(config.rainbowPhaseShift()); + var reverse = config.rainbowReverse() == 1; + var vertical = config.rainbowVertical() == 1; + var brightness = unitByte(config.rainbowBrightness()); + + var dialColors = new ArrayList(PRO_DIAL_COUNT); + for (var i = 0; i < PRO_DIAL_COUNT; i++) { + dialColors.add(rainbowColor(baseHue, reverse, i, PRO_DIAL_COUNT, brightness)); + } + + var sliderLabelColors = new ArrayList(PRO_SLIDER_COUNT); + for (var i = 0; i < PRO_SLIDER_COUNT; i++) { + sliderLabelColors.add(rainbowColor(baseHue, reverse, i + PRO_DIAL_COUNT, PRO_DIAL_COUNT + PRO_SLIDER_COUNT, brightness)); + } + + var sliderColors = new ArrayList>(PRO_SLIDER_COUNT); + for (var s = 0; s < PRO_SLIDER_COUNT; s++) { + var segmentColors = new ArrayList(PRO_SLIDER_SEGMENT_COUNT); + for (var seg = 0; seg < PRO_SLIDER_SEGMENT_COUNT; seg++) { + var idx = vertical ? seg : (s * PRO_SLIDER_SEGMENT_COUNT + seg); + var total = vertical ? PRO_SLIDER_SEGMENT_COUNT : (PRO_SLIDER_COUNT * PRO_SLIDER_SEGMENT_COUNT); + segmentColors.add(rainbowColor(baseHue, reverse, idx, total, brightness)); + } + sliderColors.add(List.copyOf(segmentColors)); + } + + var logoColor = rainbowColor(baseHue, reverse, PRO_DIAL_COUNT + PRO_SLIDER_COUNT, PRO_DIAL_COUNT + PRO_SLIDER_COUNT + 1, brightness); + return new ProVisualColors(List.copyOf(dialColors), List.copyOf(sliderLabelColors), List.copyOf(sliderColors), logoColor); + } + + private ProVisualColors fromWave(LightingConfig config) { + var centerHue = unitByte(config.waveHue()); + var brightness = unitByte(config.waveBrightness()); + var reverse = config.waveReverse() == 1; + var bounce = config.waveBounce() == 1; + + var dialColors = new ArrayList(PRO_DIAL_COUNT); + for (var i = 0; i < PRO_DIAL_COUNT; i++) { + dialColors.add(waveColor(centerHue, brightness, i, PRO_DIAL_COUNT, reverse, bounce)); + } + + var sliderLabelColors = new ArrayList(PRO_SLIDER_COUNT); + for (var i = 0; i < PRO_SLIDER_COUNT; i++) { + sliderLabelColors.add(waveColor(centerHue, brightness, i, PRO_SLIDER_COUNT, reverse, bounce)); + } + + var sliderColors = new ArrayList>(PRO_SLIDER_COUNT); + for (var s = 0; s < PRO_SLIDER_COUNT; s++) { + var segmentColors = new ArrayList(PRO_SLIDER_SEGMENT_COUNT); + for (var seg = 0; seg < PRO_SLIDER_SEGMENT_COUNT; seg++) { + segmentColors.add(waveColor(centerHue, brightness, seg, PRO_SLIDER_SEGMENT_COUNT, reverse, bounce)); + } + sliderColors.add(List.copyOf(segmentColors)); + } + + var logoColor = colorFromHue(config.waveHue(), config.waveBrightness()); + return new ProVisualColors(List.copyOf(dialColors), List.copyOf(sliderLabelColors), List.copyOf(sliderColors), logoColor); + } + + private ProVisualColors monochrome(String color) { + var c = colorOrDefault(color); + var dials = nCopies(PRO_DIAL_COUNT, c); + var labels = nCopies(PRO_SLIDER_COUNT, c); + var sliders = new ArrayList>(PRO_SLIDER_COUNT); + for (var i = 0; i < PRO_SLIDER_COUNT; i++) { + sliders.add(nCopies(PRO_SLIDER_SEGMENT_COUNT, c)); + } + return new ProVisualColors(dials, labels, List.copyOf(sliders), c); + } + + private ProVisualColors fromCustom(String serial, LightingConfig config) { + var dialColors = new ArrayList(PRO_DIAL_COUNT); + var knobConfigs = config.knobConfigs(); + for (var i = 0; i < PRO_DIAL_COUNT; i++) { + var knob = i < knobConfigs.length ? knobConfigs[i] : new SingleKnobLightingConfig(); + knob = overrideColorService.getDialOverride(serial, i).orElse(knob); + dialColors.add(resolveDialColor(knob)); + } + + var sliderLabelColors = new ArrayList(PRO_SLIDER_COUNT); + var labelConfigs = config.sliderLabelConfigs(); + for (var i = 0; i < PRO_SLIDER_COUNT; i++) { + var label = i < labelConfigs.length ? labelConfigs[i] : new SingleSliderLabelLightingConfig(); + label = overrideColorService.getSliderLabelOverride(serial, i).orElse(label); + sliderLabelColors.add(resolveSliderLabelColor(label)); + } + + var sliderColors = new ArrayList>(PRO_SLIDER_COUNT); + var sliderConfigs = config.sliderConfigs(); + for (var i = 0; i < PRO_SLIDER_COUNT; i++) { + var slider = i < sliderConfigs.length ? sliderConfigs[i] : new SingleSliderLightingConfig(); + slider = overrideColorService.getSliderOverride(serial, i).orElse(slider); + sliderColors.add(resolveSliderColors(slider)); + } + + var logo = overrideColorService.getLogoOverride(serial).orElse(config.logoConfig()); + var logoColor = resolveLogoColor(logo); + + return new ProVisualColors(List.copyOf(dialColors), List.copyOf(sliderLabelColors), List.copyOf(sliderColors), logoColor); + } + + private String resolveDialColor(SingleKnobLightingConfig config) { + if (config == null || config.getMode() == null) { + return BLACK; + } + return switch (config.getMode()) { + case NONE -> BLACK; + case STATIC, VOLUME_GRADIENT -> firstColor(config.getColor1(), config.getColor2()); + }; + } + + private String resolveSliderLabelColor(SingleSliderLabelLightingConfig config) { + if (config == null || config.getMode() == null) { + return BLACK; + } + return switch (config.getMode()) { + case NONE -> BLACK; + case STATIC -> colorOrDefault(config.getColor()); + }; + } + + private List resolveSliderColors(SingleSliderLightingConfig config) { + if (config == null || config.getMode() == null) { + return nCopies(PRO_SLIDER_SEGMENT_COUNT, BLACK); + } + + return switch (config.getMode()) { + case NONE -> nCopies(PRO_SLIDER_SEGMENT_COUNT, BLACK); + case STATIC -> nCopies(PRO_SLIDER_SEGMENT_COUNT, colorOrDefault(config.getColor1())); + case STATIC_GRADIENT, VOLUME_GRADIENT -> gradient(config.getColor1(), config.getColor2(), PRO_SLIDER_SEGMENT_COUNT); + }; + } + + String resolveLogoColor(SingleLogoLightingConfig config) { + if (config == null || config.getMode() == null) { + return BLACK; + } + + return switch (config.getMode()) { + case NONE -> BLACK; + case STATIC -> colorOrDefault(config.getColor()); + case RAINBOW -> "$RAINBOW!"; + case BREATH -> "$BREATH"; + }; + } + + private List gradient(String startColor, String endColor, int steps) { + var start = Util.parseColorComponents(colorOrDefault(startColor)); + var end = Util.parseColorComponents(colorOrDefault(endColor)); + + if (start == null || end == null) { + return nCopies(steps, BLACK); + } + + var result = new ArrayList(steps); + for (var i = 0; i < steps; i++) { + var ratio = steps == 1 ? 0f : (float) i / (steps - 1); + var r = Math.round(start[0] + (end[0] - start[0]) * ratio); + var g = Math.round(start[1] + (end[1] - start[1]) * ratio); + var b = Math.round(start[2] + (end[2] - start[2]) * ratio); + result.add(toHex(r, g, b)); + } + return List.copyOf(result); + } + + private String colorFromHue(byte hue, byte brightness) { + return colorFromHsb(unitByte(hue), 1f, unitByte(brightness)); + } + + private String rainbowColor(float baseHue, boolean reverse, int index, int total, float brightness) { + var span = 0.7f; + var shift = total <= 1 ? 0f : (span * index / (total - 1)); + var hue = reverse ? baseHue - shift : baseHue + shift; + return colorFromHsb(normalizeHue(hue), 1f, brightness); + } + + private String waveColor(float centerHue, float brightness, int index, int total, boolean reverse, boolean bounce) { + var progress = total <= 1 ? 0f : (float) index / (total - 1); + if (reverse) { + progress = 1f - progress; + } + var spread = 0.12f; + var offset = bounce + ? (Math.abs(progress - 0.5f) * 2f * spread) + : ((progress - 0.5f) * 2f * spread); + return colorFromHsb(normalizeHue(centerHue + offset), 1f, brightness); + } + + private String colorFromHsb(float hue, float saturation, float brightness) { + var rgb = Color.HSBtoRGB(hue, saturation, brightness); + var r = (rgb >> 16) & 0xFF; + var g = (rgb >> 8) & 0xFF; + var b = rgb & 0xFF; + return toHex(r, g, b); + } + + private String toHex(int r, int g, int b) { + var hex = Util.formatHexString(r, g, b); + return hex == null ? BLACK : hex; + } + + private float unitByte(byte value) { + return (value & 0xFF) / 255f; + } + + private float normalizeHue(float hue) { + var normalized = hue % 1f; + return normalized < 0 ? normalized + 1f : normalized; + } + + private String firstColor(String color1, String color2) { + var c1 = colorOrDefault(color1); + if (!BLACK.equals(c1)) { + return c1; + } + return colorOrDefault(color2); + } + + private String colorOrDefault(String color) { + var parsed = Util.parseColorComponents(color); + if (parsed == null) { + return BLACK; + } + return color.startsWith("#") ? color : "#" + color; + } + + private static List nCopies(int count, String color) { + return Collections.nCopies(count, color); + } + + public record ProVisualColors( + List dialColors, + List sliderLabelColors, + List> sliderColors, + String logoColor + ) { + public static ProVisualColors empty() { + return new ProVisualColors(List.of(), List.of(), List.of(), BLACK); + } + + public static ProVisualColors defaultForPro() { + var blackDials = nCopies(PRO_DIAL_COUNT, BLACK); + var blackLabels = nCopies(PRO_SLIDER_COUNT, BLACK); + var blackSliders = new ArrayList>(PRO_SLIDER_COUNT); + for (var i = 0; i < PRO_SLIDER_COUNT; i++) { + blackSliders.add(nCopies(PRO_SLIDER_SEGMENT_COUNT, BLACK)); + } + return new ProVisualColors(blackDials, blackLabels, List.copyOf(blackSliders), BLACK); + } + } +} diff --git a/src/main/java/com/getpcpanel/rest/ProcessResource.java b/src/main/java/com/getpcpanel/rest/ProcessResource.java new file mode 100644 index 00000000..2abfbbd8 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/ProcessResource.java @@ -0,0 +1,55 @@ +package com.getpcpanel.rest; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Base64; +import java.util.List; + +import javax.imageio.ImageIO; + +import com.getpcpanel.cpp.ISndCtrl; +import com.getpcpanel.iconextract.IIconService; +import com.getpcpanel.rest.model.dto.ProcessDto; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +@Path("/api/processes") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +public class ProcessResource { + @Inject ISndCtrl sndCtrl; + @Inject IIconService iconService; + + @GET + public List listProcesses() { + return sndCtrl.getRunningApplications().stream() + .map(app -> new ProcessDto( + app.pid(), + app.file().getAbsolutePath(), + app.name(), + encodeIcon(iconService.getIconForFile(32, 32, app.file())))) + .toList(); + } + + static String encodeIcon(BufferedImage img) { + if (img == null) { + return null; + } + try { + var baos = new ByteArrayOutputStream(); + ImageIO.write(img, "png", baos); + return "data:image/png;base64," + Base64.getEncoder().encodeToString(baos.toByteArray()); + } catch (IOException e) { + log.debug("Failed to encode process icon", e); + return null; + } + } +} diff --git a/src/main/java/com/getpcpanel/rest/SettingsResource.java b/src/main/java/com/getpcpanel/rest/SettingsResource.java new file mode 100644 index 00000000..25a5bfc0 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/SettingsResource.java @@ -0,0 +1,65 @@ +package com.getpcpanel.rest; + +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.profile.dto.MqttSettings; +import com.getpcpanel.profile.dto.WaveLinkSettings; +import com.getpcpanel.rest.model.dto.SettingsDto; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +@Path("/api/settings") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class SettingsResource { + @Inject SaveService saveService; + + @GET + public SettingsDto getSettings() { + return SettingsDto.from(saveService.get()); + } + + @PUT + public Response updateSettings(SettingsDto dto) { + var save = saveService.get(); + dto.applyTo(save); + saveService.save(); + return Response.ok().build(); + } + + @GET + @Path("/mqtt") + public MqttSettings getMqttSettings() { + return saveService.get().getMqtt(); + } + + @PUT + @Path("/mqtt") + public Response updateMqttSettings(MqttSettings settings) { + saveService.get().setMqtt(settings); + saveService.save(); + return Response.ok().build(); + } + + @GET + @Path("/wavelink") + public WaveLinkSettings getWaveLinkSettings() { + return saveService.get().getWaveLink(); + } + + @PUT + @Path("/wavelink") + public Response updateWaveLinkSettings(WaveLinkSettings settings) { + saveService.get().setWaveLink(settings); + saveService.save(); + return Response.ok().build(); + } +} diff --git a/src/main/java/com/getpcpanel/rest/VoiceMeeterResource.java b/src/main/java/com/getpcpanel/rest/VoiceMeeterResource.java new file mode 100644 index 00000000..5e4792b2 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/VoiceMeeterResource.java @@ -0,0 +1,39 @@ +package com.getpcpanel.rest; + +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("/api/voicemeeter") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +public class VoiceMeeterResource { + + public static class VoiceMeeterParam { + public String name; + public List params; + + public VoiceMeeterParam(String name, List params) { + this.name = name; + this.params = params; + } + } + + @GET + @Path("/basic") + public List getBasicParams() { + // Return empty list for now - these are typically obtained from user configuration + return List.of(); + } + + @GET + @Path("/advanced") + public List getAdvancedParams() { + // Return empty list for now - these are typically obtained from user configuration + return List.of(); + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/CommandType.java b/src/main/java/com/getpcpanel/rest/model/dto/CommandType.java new file mode 100644 index 00000000..4b0217d6 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/CommandType.java @@ -0,0 +1,15 @@ +package com.getpcpanel.rest.model.dto; + +import com.getpcpanel.rest.EventBroadcaster.AssignmentChangedEvent.Kinds; + +public record CommandType( + String name, + String command, + CommandCategory category, + Kinds kind +) { + + public enum CommandCategory { + standard, voicemeeter, obs, wavelink + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/ControlAssignmentsUpdateDto.java b/src/main/java/com/getpcpanel/rest/model/dto/ControlAssignmentsUpdateDto.java new file mode 100644 index 00000000..9c2378b0 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/ControlAssignmentsUpdateDto.java @@ -0,0 +1,14 @@ +package com.getpcpanel.rest.model.dto; + +import com.getpcpanel.commands.Commands; +import com.getpcpanel.profile.dto.KnobSetting; + +import jakarta.annotation.Nullable; + +public record ControlAssignmentsUpdateDto( + @Nullable Commands analog, + @Nullable Commands button, + @Nullable Commands dblButton, + @Nullable KnobSetting knobSetting +) { +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/DeviceDto.java b/src/main/java/com/getpcpanel/rest/model/dto/DeviceDto.java new file mode 100644 index 00000000..0e26655e --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/DeviceDto.java @@ -0,0 +1,34 @@ +package com.getpcpanel.rest.model.dto; + +import java.util.List; + +import com.getpcpanel.device.Device; +import com.getpcpanel.device.DeviceType; +import com.getpcpanel.profile.DeviceSave; + +import one.util.streamex.StreamEx; + +public record DeviceDto( + String serial, + String displayName, + DeviceType deviceType, + int analogCount, + int buttonCount, + boolean hasLogoLed, + String currentProfile, + List profiles +) { + public static DeviceDto from(Device device, DeviceSave deviceSave) { + var type = device.deviceType(); + return new DeviceDto( + device.getSerialNumber(), + device.getDisplayName(), + type, + type.getAnalogCount(), + type.getButtonCount(), + type.isHasLogoLed(), + deviceSave.getCurrentProfileName(), + StreamEx.of(deviceSave.getProfiles()).map(p -> p.getName()).toList() + ); + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/DeviceSnapshotDto.java b/src/main/java/com/getpcpanel/rest/model/dto/DeviceSnapshotDto.java new file mode 100644 index 00000000..99364d4d --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/DeviceSnapshotDto.java @@ -0,0 +1,77 @@ +package com.getpcpanel.rest.model.dto; + +import java.util.List; +import java.util.stream.IntStream; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.getpcpanel.device.Device; +import com.getpcpanel.profile.DeviceSave; +import com.getpcpanel.profile.Profile; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.rest.ProVisualColorsService; +import com.getpcpanel.rest.model.ws.WsEvent; + +import one.util.streamex.StreamEx; + +/** + * Full device state snapshot sent over WebSocket on connection. + * Combines DeviceDto fields with lighting config, the active profile's + * assignments, and the current analog knob values — so the frontend + * never needs separate HTTP calls just to display device state. + */ +@JsonTypeName("device_snapshot") +public record DeviceSnapshotDto( + // ── core device fields (same as DeviceDto) ────────────────────────── + String serial, + String displayName, + String deviceType, + int analogCount, + int buttonCount, + boolean hasLogoLed, + String currentProfile, + List profiles, + // ── extra snapshot fields ──────────────────────────────────────────── + LightingConfig lightingConfig, + ProfileSnapshotDto currentProfileSnapshot, + List analogValues, + List dialColors, + List sliderLabelColors, + List> sliderColors, + String logoColor +) implements WsEvent { + /** + * WsEvent type discriminator understood by the frontend. + */ + public String type() { + return "device_snapshot"; + } + + public static DeviceSnapshotDto from(Device device, DeviceSave deviceSave, ProVisualColorsService proVisualColorsService) { + var dt = device.deviceType(); + var profile = device.currentProfile(); + var analogCount = dt.getAnalogCount(); + var visualColors = proVisualColorsService.resolve(device); + + var knobValues = IntStream.range(0, analogCount) + .mapToObj(device::getKnobRotation) + .toList(); + + return new DeviceSnapshotDto( + device.getSerialNumber(), + device.getDisplayName(), + dt.name(), + analogCount, + dt.getButtonCount(), + dt.isHasLogoLed(), + deviceSave.getCurrentProfileName(), + StreamEx.of(deviceSave.getProfiles()).map(Profile::getName).toList(), + device.getSavedLightingConfig(), + ProfileSnapshotDto.from(profile), + knobValues, + visualColors.dialColors(), + visualColors.sliderLabelColors(), + visualColors.sliderColors(), + visualColors.logoColor() + ); + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/ProcessDto.java b/src/main/java/com/getpcpanel/rest/model/dto/ProcessDto.java new file mode 100644 index 00000000..b7f31d90 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/ProcessDto.java @@ -0,0 +1,6 @@ +package com.getpcpanel.rest.model.dto; + +import javax.annotation.Nullable; + +public record ProcessDto(int pid, String path, String name, @Nullable String icon) { +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/ProfileDto.java b/src/main/java/com/getpcpanel/rest/model/dto/ProfileDto.java new file mode 100644 index 00000000..d902cfc8 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/ProfileDto.java @@ -0,0 +1,9 @@ +package com.getpcpanel.rest.model.dto; + +import com.getpcpanel.profile.Profile; + +public record ProfileDto(String name, boolean isMainProfile) { + public static ProfileDto from(Profile profile) { + return new ProfileDto(profile.getName(), profile.isMainProfile()); + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/ProfileSnapshotDto.java b/src/main/java/com/getpcpanel/rest/model/dto/ProfileSnapshotDto.java new file mode 100644 index 00000000..a6e8a8ae --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/ProfileSnapshotDto.java @@ -0,0 +1,32 @@ +package com.getpcpanel.rest.model.dto; + +import java.util.Map; + +import com.getpcpanel.commands.Commands; +import com.getpcpanel.profile.Profile; +import com.getpcpanel.profile.dto.KnobSetting; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +/** + * Snapshot of the currently active profile — all assignment data the frontend + * needs to render the device page without any additional HTTP calls. + */ +@RegisterForReflection +public record ProfileSnapshotDto( + String name, + Map dialData, + Map buttonData, + Map dblButtonData, + Map knobSettings +) { + public static ProfileSnapshotDto from(Profile profile) { + return new ProfileSnapshotDto( + profile.getName(), + profile.getDialData(), + profile.getButtonData(), + profile.getDblButtonData(), + profile.getKnobSettings() + ); + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/SettingsDto.java b/src/main/java/com/getpcpanel/rest/model/dto/SettingsDto.java new file mode 100644 index 00000000..2bf947bc --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/SettingsDto.java @@ -0,0 +1,125 @@ +package com.getpcpanel.rest.model.dto; + +import java.util.List; + +import javax.annotation.Nullable; + +import com.getpcpanel.profile.Save; +import com.getpcpanel.profile.dto.MqttSettings; +import com.getpcpanel.profile.dto.OSCConnectionInfo; +import com.getpcpanel.profile.dto.OverlayPosition; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class SettingsDto { + // General + private boolean mainUIIcons; + private boolean startupVersionCheck; + private boolean forceVolume; + private Long dblClickInterval; + private boolean preventClickWhenDblClick; + @Nullable private Integer preventSliderTwitchDelay; + @Nullable private Integer sliderRollingAverage; + @Nullable private Integer sendOnlyIfDelta; + private boolean workaroundsOnlySliders; + + // OBS + private boolean obsEnabled; + private String obsAddress; + private String obsPort; + private String obsPassword; + + // VoiceMeeter + private boolean voicemeeterEnabled; + private String voicemeeterPath; + + // OSC + private Integer oscListenPort; + private List oscConnections; + + // Overlay + private boolean overlayEnabled; + private boolean overlayUseLog; + private boolean overlayShowNumber; + private String overlayBackgroundColor; + private String overlayTextColor; + private String overlayBarColor; + private String overlayBarBackgroundColor; + private int overlayWindowCornerRounding; + @Nullable private Integer overlayBarHeight; + @Nullable private Integer overlayBarCornerRounding; + @Nullable private OverlayPosition overlayPosition; + @Nullable private Integer overlayPadding; + private MqttSettings mqtt; + + public static SettingsDto from(Save save) { + var dto = new SettingsDto(); + dto.mainUIIcons = save.isMainUIIcons(); + dto.startupVersionCheck = save.isStartupVersionCheck(); + dto.forceVolume = save.isForceVolume(); + dto.dblClickInterval = save.getDblClickInterval(); + dto.preventClickWhenDblClick = save.isPreventClickWhenDblClick(); + dto.preventSliderTwitchDelay = save.getPreventSliderTwitchDelay(); + dto.sliderRollingAverage = save.getSliderRollingAverage(); + dto.sendOnlyIfDelta = save.getSendOnlyIfDelta(); + dto.workaroundsOnlySliders = save.isWorkaroundsOnlySliders(); + dto.obsEnabled = save.isObsEnabled(); + dto.obsAddress = save.getObsAddress(); + dto.obsPort = save.getObsPort(); + dto.obsPassword = save.getObsPassword(); + dto.voicemeeterEnabled = save.isVoicemeeterEnabled(); + dto.voicemeeterPath = save.getVoicemeeterPath(); + dto.oscListenPort = save.getOscListenPort(); + dto.oscConnections = save.getOscConnections(); + dto.overlayEnabled = save.isOverlayEnabled(); + dto.overlayUseLog = save.isOverlayUseLog(); + dto.overlayShowNumber = save.isOverlayShowNumber(); + dto.overlayBackgroundColor = save.getOverlayBackgroundColor(); + dto.overlayTextColor = save.getOverlayTextColor(); + dto.overlayBarColor = save.getOverlayBarColor(); + dto.overlayBarBackgroundColor = save.getOverlayBarBackgroundColor(); + dto.overlayWindowCornerRounding = save.getOverlayWindowCornerRounding(); + dto.overlayBarHeight = save.getOverlayBarHeight(); + dto.overlayBarCornerRounding = save.getOverlayBarCornerRounding(); + dto.overlayPosition = save.getOverlayPosition(); + dto.overlayPadding = save.getOverlayPadding(); + dto.mqtt = save.getMqtt(); + return dto; + } + + public void applyTo(Save save) { + save.setMainUIIcons(mainUIIcons); + save.setStartupVersionCheck(startupVersionCheck); + save.setForceVolume(forceVolume); + save.setDblClickInterval(dblClickInterval); + save.setPreventClickWhenDblClick(preventClickWhenDblClick); + save.setPreventSliderTwitchDelay(preventSliderTwitchDelay); + save.setSliderRollingAverage(sliderRollingAverage); + save.setSendOnlyIfDelta(sendOnlyIfDelta); + save.setWorkaroundsOnlySliders(workaroundsOnlySliders); + save.setObsEnabled(obsEnabled); + save.setObsAddress(obsAddress); + save.setObsPort(obsPort); + save.setObsPassword(obsPassword); + save.setVoicemeeterEnabled(voicemeeterEnabled); + save.setVoicemeeterPath(voicemeeterPath); + save.setOscListenPort(oscListenPort); + save.setOscConnections(oscConnections); + save.setOverlayEnabled(overlayEnabled); + save.setOverlayUseLog(overlayUseLog); + save.setOverlayShowNumber(overlayShowNumber); + save.setOverlayBackgroundColor(overlayBackgroundColor); + save.setOverlayTextColor(overlayTextColor); + save.setOverlayBarColor(overlayBarColor); + save.setOverlayBarBackgroundColor(overlayBarBackgroundColor); + save.setOverlayWindowCornerRounding(overlayWindowCornerRounding); + save.setOverlayBarHeight(overlayBarHeight); + save.setOverlayBarCornerRounding(overlayBarCornerRounding); + save.setOverlayPosition(overlayPosition); + save.setOverlayPadding(overlayPadding); + save.setMqtt(mqtt); + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsAssignmentChangedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsAssignmentChangedEvent.java new file mode 100644 index 00000000..a5d246ed --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsAssignmentChangedEvent.java @@ -0,0 +1,9 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.getpcpanel.commands.Commands; +import com.getpcpanel.rest.EventBroadcaster.AssignmentChangedEvent.Kinds; + +@JsonTypeName("assignment_changed") +public record WsAssignmentChangedEvent(String serial, Kinds kind, int index, Commands commands) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsButtonEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsButtonEvent.java new file mode 100644 index 00000000..d1aa0328 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsButtonEvent.java @@ -0,0 +1,7 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; + +@JsonTypeName("button_press") +public record WsButtonEvent(String serial, int button, boolean pressed) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsControlSettingChangedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsControlSettingChangedEvent.java new file mode 100644 index 00000000..66b37b08 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsControlSettingChangedEvent.java @@ -0,0 +1,8 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.getpcpanel.profile.dto.KnobSetting; + +@JsonTypeName("control_setting_changed") +public record WsControlSettingChangedEvent(String serial, int index, KnobSetting settings) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceConnectedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceConnectedEvent.java new file mode 100644 index 00000000..be407264 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceConnectedEvent.java @@ -0,0 +1,10 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.getpcpanel.rest.model.dto.DeviceSnapshotDto; + +@JsonTypeName("device_connected") +public record WsDeviceConnectedEvent( + DeviceSnapshotDto deviceSnapshot +) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceDisconnectedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceDisconnectedEvent.java new file mode 100644 index 00000000..c5fa06fd --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceDisconnectedEvent.java @@ -0,0 +1,7 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; + +@JsonTypeName("device_disconnected") +public record WsDeviceDisconnectedEvent(String serial) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceRenamedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceRenamedEvent.java new file mode 100644 index 00000000..73f2ebc4 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceRenamedEvent.java @@ -0,0 +1,7 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; + +@JsonTypeName("device_renamed") +public record WsDeviceRenamedEvent(String serial, String displayName) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsEvent.java new file mode 100644 index 00000000..ad235d06 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsEvent.java @@ -0,0 +1,25 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.getpcpanel.rest.model.dto.DeviceSnapshotDto; + +@JsonTypeInfo(use = Id.NAME, include = As.PROPERTY, property = "type") +@JsonSubTypes({ + @Type(value = WsAssignmentChangedEvent.class, name = "assignment_changed"), + @Type(value = WsButtonEvent.class, name = "button_press"), + @Type(value = WsDeviceConnectedEvent.class, name = "device_connected"), + @Type(value = WsDeviceDisconnectedEvent.class, name = "device_disconnected"), + @Type(value = WsDeviceRenamedEvent.class, name = "device_renamed"), + @Type(value = WsKnobEvent.class, name = "knob_rotate"), + @Type(value = WsLightingChangedEvent.class, name = "lighting_changed"), + @Type(value = WsProfileSwitchedEvent.class, name = "profile_switched"), + @Type(value = WsVisualColorsChangedEvent.class, name = "visual_colors_changed"), + @Type(value = DeviceSnapshotDto.class, name = "device_snapshot"), + @Type(value = WsControlSettingChangedEvent.class, name = "control_setting_changed") +}) +public interface WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsKnobEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsKnobEvent.java new file mode 100644 index 00000000..745a8088 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsKnobEvent.java @@ -0,0 +1,7 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; + +@JsonTypeName("knob_rotate") +public record WsKnobEvent(String serial, int knob, int value) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsLightingChangedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsLightingChangedEvent.java new file mode 100644 index 00000000..6bdbb137 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsLightingChangedEvent.java @@ -0,0 +1,17 @@ +package com.getpcpanel.rest.model.ws; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.getpcpanel.profile.dto.LightingConfig; + +@JsonTypeName("lighting_changed") +public record WsLightingChangedEvent( + String serial, + LightingConfig lightingConfig, + List dialColors, + List sliderLabelColors, + List> sliderColors, + String logoColor +) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsProfileSwitchedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsProfileSwitchedEvent.java new file mode 100644 index 00000000..4c30bb4a --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsProfileSwitchedEvent.java @@ -0,0 +1,18 @@ +package com.getpcpanel.rest.model.ws; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.getpcpanel.rest.model.dto.ProfileSnapshotDto; + +@JsonTypeName("profile_switched") +public record WsProfileSwitchedEvent( + String serial, + String profileName, + ProfileSnapshotDto profileSnapshot, + List dialColors, + List sliderLabelColors, + List
Jackson deserialises {@link Command} subtypes via {@code @JsonTypeInfo(use = ID.CLASS)}, so + * every concrete subtype must be registered for reflection. All profile/command model classes that + * are serialised/deserialised by Jackson are collected here. + * + *
hid4java JNA structures and library interfaces are also registered here for JNA reflective + * instantiation and field access. + */ +@RegisterForReflection(targets = { + // hid4java JNA library + structure classes (JNA needs reflection to instantiate structures) + HidApi.class, + HidApiLibrary.class, + HidDeviceInfoStructure.class, + HidDeviceStructure.class, + HidrawHidApiLibrary.class, + LibusbHidApiLibrary.class, + WideStringBuffer.class, + + // MQTT Home Assistant discovery payload classes (serialised to JSON by Jackson) + // Note: these records are package-private so referenced by classNames below + + // Command type hierarchy + Command.class, + CommandBrightness.class, + CommandEndProgram.class, + CommandKeystroke.class, + CommandMedia.class, + VolumeButton.class, + CommandNoOp.class, + CommandObs.class, + CommandObsMuteSource.class, + CommandObsSetScene.class, + CommandObsSetSourceVolume.class, + CommandProfile.class, + CommandRun.class, + CommandShortcut.class, + CommandVoiceMeeter.class, + CommandVoiceMeeterAdvanced.class, + CommandVoiceMeeterAdvancedButton.class, + CommandVoiceMeeterBasic.class, + CommandVoiceMeeterBasicButton.class, + CommandVolume.class, + CommandVolumeApplicationDeviceToggle.class, + CommandVolumeDefaultDevice.class, + CommandVolumeDefaultDeviceAdvanced.class, + CommandVolumeDefaultDeviceToggle.class, + CommandVolumeDefaultDeviceToggleAdvanced.class, + CommandVolumeDevice.class, + CommandVolumeDeviceMute.class, + CommandVolumeFocus.class, + CommandVolumeFocusMute.class, + CommandVolumeProcess.class, + CommandVolumeProcessMute.class, + + // WaveLink command hierarchy (also extends Command → ID.CLASS polymorphism) + CommandWaveLink.class, + CommandWaveLinkAddFocusToChannel.class, + CommandWaveLinkChange.class, + CommandWaveLinkChangeLevel.class, + CommandWaveLinkChangeMute.class, + CommandWaveLinkChannelEffect.class, + CommandWaveLinkMainOutput.class, + WaveLinkCommandTarget.class, + + // WaveLink RPC protocol classes (Jackson @JsonSubTypes / @JsonTypeInfo) + JsonRpcMessage.class, + JsonRpcResponse.class, + ErrorDetail.class, + WaveLinkJsonRpcCommand.class, + WaveLinkChannelChangedCommand.class, + WaveLinkChannelsChangedCommand.class, + WaveLinkFocusedAppChangedCommand.class, + WaveLinkMixChangedCommand.class, + WaveLinkOutputDeviceChangedCommand.class, + WaveLinkGetApplicationInfo.class, + WaveLinkGetChannels.class, + WaveLinkGetInputDevices.class, + WaveLinkGetMixes.class, + WaveLinkGetOutputDevices.class, + WaveLinkSetChannelCommand.class, + WaveLinkSetMixCommand.class, + WaveLinkSetOutputDeviceCommand.class, + WaveLinkSetSubscription.class, + WaveLinkAddToChannelCommand.class, + WaveLinkUnknownCommand.class, + + // WaveLink model classes (deserialised from WaveLink JSON API) + WaveLinkApp.class, + WaveLinkChannel.class, + WaveLinkControlAction.class, + WaveLinkEffect.class, + WaveLinkGain.class, + WaveLinkImage.class, + WaveLinkInput.class, + WaveLinkInputDevice.class, + WaveLinkMainOutput.class, + WaveLinkMix.class, + WaveLinkOutput.class, + WaveLinkOutputDevice.class, + + // Command support types serialised by Jackson + Commands.class, + CommandsType.class, + DeviceSet.class, + DialCommandParams.class, + + // Profile / save model classes (Jackson deserialization of user save file) + Save.class, + DeviceSave.class, + Profile.class, + LightingConfig.class, + LightingMode.class, + SingleKnobLightingConfig.class, + SINGLE_KNOB_MODE.class, + SingleSliderLightingConfig.class, + SINGLE_SLIDER_MODE.class, + SingleSliderLabelLightingConfig.class, + SINGLE_SLIDER_LABEL_MODE.class, + SingleLogoLightingConfig.class, + SINGLE_LOGO_MODE.class, + KnobSetting.class, + MqttSettings.class, + HomeAssistantSettings.class, + WaveLinkSettings.class, + OSCConnectionInfo.class, + OSCBinding.class, + OverlayPosition.class, +}, classNames = { + // MQTT Home Assistant discovery records (package-private inner classes – referenced by name) + "com.getpcpanel.mqtt.MqttHomeAssistantHelper$HomeAssistantAvailability", + "com.getpcpanel.mqtt.MqttHomeAssistantHelper$HomeAssistantButtonConfig", + "com.getpcpanel.mqtt.MqttHomeAssistantHelper$HomeAssistantButtonEventConfig", + "com.getpcpanel.mqtt.MqttHomeAssistantHelper$HomeAssistantDevice", + "com.getpcpanel.mqtt.MqttHomeAssistantHelper$HomeAssistantLightConfig", + "com.getpcpanel.mqtt.MqttHomeAssistantHelper$HomeAssistantNumberConfig", +}) +public class NativeImageConfig { + private NativeImageConfig() { + } +} diff --git a/src/main/java/com/getpcpanel/hid/ByteWriter.java b/src/main/java/com/getpcpanel/hid/ByteWriter.java index 3979d3eb..43399350 100644 --- a/src/main/java/com/getpcpanel/hid/ByteWriter.java +++ b/src/main/java/com/getpcpanel/hid/ByteWriter.java @@ -2,8 +2,10 @@ import java.util.stream.Stream; -import javafx.scene.paint.Color; - +/** + * Builds byte arrays for HID lighting commands. + * Colors are passed as hex strings (e.g. "#rrggbb") or as separate R/G/B int components. + */ class ByteWriter { private final byte[] buff; private final int brightnessMultiplier; @@ -34,9 +36,32 @@ public ByteWriter appendBrightness(byte nr) { return append(applyBrightness(nr)); } - @SuppressWarnings("NumericCastThatLosesPrecision") - public ByteWriter append(Color c) { - return append(applyBrightness((byte) (c.getRed() * 255)), applyBrightness((byte) (c.getGreen() * 255)), applyBrightness((byte) (c.getBlue() * 255))); + /** + * Append RGB from a CSS hex color string ("#rrggbb"). If null/invalid, appends black. + */ + public ByteWriter appendHex(String hexColor) { + int r = 0, g = 0, b = 0; + if (hexColor != null) { + try { + String hex = hexColor.startsWith("#") ? hexColor.substring(1) : hexColor; + r = Integer.parseInt(hex.substring(0, 2), 16) & 0xFF; + g = Integer.parseInt(hex.substring(2, 4), 16) & 0xFF; + b = Integer.parseInt(hex.substring(4, 6), 16) & 0xFF; + } catch (Exception ignored) { + } + } + return append(applyBrightness((byte) r), applyBrightness((byte) g), applyBrightness((byte) b)); + } + + /** + * Append RGB from int components (0-255). Values are clamped to [0, 255]. + */ + public ByteWriter appendRGB(int r, int g, int b) { + return append( + applyBrightness((byte) (Math.min(255, Math.max(0, r)) & 0xFF)), + applyBrightness((byte) (Math.min(255, Math.max(0, g)) & 0xFF)), + applyBrightness((byte) (Math.min(255, Math.max(0, b)) & 0xFF)) + ); } private byte applyBrightness(byte nr) { diff --git a/src/main/java/com/getpcpanel/hid/DeviceCommunicationHandler.java b/src/main/java/com/getpcpanel/hid/DeviceCommunicationHandler.java index dc6d47e0..b8767802 100644 --- a/src/main/java/com/getpcpanel/hid/DeviceCommunicationHandler.java +++ b/src/main/java/com/getpcpanel/hid/DeviceCommunicationHandler.java @@ -14,14 +14,17 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import org.apache.commons.lang3.tuple.Pair; import org.hid4java.HidDevice; -import org.springframework.context.ApplicationEventPublisher; + +import javax.annotation.Nullable; import com.getpcpanel.device.DeviceType; import com.getpcpanel.profile.SaveService; +import jakarta.enterprise.event.Event; import lombok.Setter; import lombok.extern.log4j.Log4j2; @@ -30,7 +33,7 @@ public class DeviceCommunicationHandler { private static final byte INPUT_CODE_KNOB_CHANGE = 1; private static final byte INPUT_CODE_BUTTON_CHANGE = 2; - private final ApplicationEventPublisher eventPublisher; + private final Event eventBus; private final DeviceScanner deviceScanner; private final SaveService saveService; private final String key; @@ -47,23 +50,64 @@ public class DeviceCommunicationHandler { private final KnobDebouncer debouncer = new KnobDebouncer(); private final RollingAverageSetter rollingAverageSetter = new RollingAverageSetter(); private final Map prevSent = new ConcurrentHashMap<>(); + private final AtomicBoolean stopping = new AtomicBoolean(false); + private Thread readerThread; + private Thread writerThread; - public DeviceCommunicationHandler(DeviceScanner deviceScanner, ApplicationEventPublisher eventPublisher, SaveService saveService, String key, HidDevice device, DeviceType deviceType) { - this.eventPublisher = eventPublisher; + public DeviceCommunicationHandler(DeviceScanner deviceScanner, SaveService saveService, Event eventBus, String key, HidDevice device, DeviceType deviceType) { this.deviceScanner = deviceScanner; this.saveService = saveService; + this.eventBus = eventBus; this.key = key; this.device = device; this.deviceType = deviceType; } public void start() { - var reader = new Thread(this::reader, "HIDReader " + device.getSerialNumber()); - var writer = new Thread(this::writer, "HIDWriter " + device.getSerialNumber()); - reader.setDaemon(true); - writer.setDaemon(true); - reader.start(); - writer.start(); + readerThread = new Thread(this::reader, "HIDReader " + device.getSerialNumber()); + writerThread = new Thread(this::writer, "HIDWriter " + device.getSerialNumber()); + readerThread.setDaemon(true); + writerThread.setDaemon(true); + readerThread.start(); + writerThread.start(); + } + + public void stopGracefully(long joinTimeoutMs) { + if (!stopping.compareAndSet(false, true)) { + return; + } + + queue.clear(); + if (readerThread != null) { + readerThread.interrupt(); + } + if (writerThread != null) { + writerThread.interrupt(); + } + + joinThread(readerThread, joinTimeoutMs); + joinThread(writerThread, joinTimeoutMs); + + debouncer.shutdown(); + rollingAverageSetter.shutdown(); + + try { + device.close(); + } catch (Exception e) { + log.debug("Error while closing HID device {}", key, e); + } + } + + private void joinThread(@Nullable Thread thread, long timeoutMs) { + if (thread == null) { + return; + } + try { + thread.join(timeoutMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.debug("Interrupted while waiting for {} to stop", thread.getName(), e); + } } public void sendMessage(byte[]... data) { @@ -82,8 +126,10 @@ public void reader() { switch (val) { case -1 -> { - log.error("DCH ERR: {}", device.getLastErrorMessage()); - deviceScanner.deviceRemoved(key, device); + if (!stopping.get()) { + log.error("DCH ERR: {}", device.getLastErrorMessage()); + deviceScanner.deviceRemoved(key, device); + } return; } case 0 -> { @@ -91,6 +137,9 @@ public void reader() { continue; } } + if (!isConnected()) { + return; + } interpretInputData(readUntilNotInitial != 0, data); } } @@ -104,16 +153,18 @@ private void writer() { sendMessageReal(toSend); } } catch (InterruptedException e) { - throw new RuntimeException(e); + if (stopping.get()) { + return; + } + Thread.currentThread().interrupt(); + return; } } - debouncer.shutdown(); - rollingAverageSetter.shutdown(); } private boolean isConnected() { //noinspection ObjectEquality - return deviceScanner.getConnectedDevice(key) == this; + return !stopping.get() && deviceScanner.getConnectedDevice(key) == this; } private void sendMessageReal(byte[] info) { @@ -164,7 +215,7 @@ private void triggerEvent(KnobRotateEvent o) { } else { prevSent.put(o.knob(), currentSendValue); log.debug("< {}", o); - eventPublisher.publishEvent(o); + eventBus.fire(o); } } @@ -174,7 +225,7 @@ private boolean applyWorkaround(int knob) { private void triggerEvent(ButtonPressEvent o) { log.debug("< {}", o); - eventPublisher.publishEvent(o); + eventBus.fire(o); } private void triggerOrDebounce(KnobRotateEvent event) { @@ -272,7 +323,7 @@ public RollingAverageSetter() { public void setKnob(KnobRotateEvent knob, Integer rollWindowMs) { this.rollWindowMs = rollWindowMs; - var target = targets.computeIfAbsent(knob.knob(), k -> new ArrayDeque<>()); + var target = targets.computeIfAbsent(knob.knob(), ignoredKnob -> new ArrayDeque<>()); synchronized (target) { if (knob.initial()) { triggerEvent(knob); diff --git a/src/main/java/com/getpcpanel/hid/DeviceCommunicationHandlerFactory.java b/src/main/java/com/getpcpanel/hid/DeviceCommunicationHandlerFactory.java index ea0dd171..da2ef50c 100644 --- a/src/main/java/com/getpcpanel/hid/DeviceCommunicationHandlerFactory.java +++ b/src/main/java/com/getpcpanel/hid/DeviceCommunicationHandlerFactory.java @@ -1,24 +1,26 @@ package com.getpcpanel.hid; import org.hid4java.HidDevice; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; +import jakarta.enterprise.event.Event; +import jakarta.inject.Inject; +import jakarta.enterprise.context.ApplicationScoped; import com.getpcpanel.device.DeviceType; import com.getpcpanel.profile.SaveService; -import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public class DeviceCommunicationHandlerFactory { - private final ApplicationEventPublisher eventPublisher; - private final DeviceScanner deviceScanner; - private final SaveService saveService; + @Inject + Event eventBus; + @Inject + DeviceScanner deviceScanner; + @Inject + SaveService saveService; public DeviceCommunicationHandler build(String key, HidDevice device, DeviceType deviceType) { - return new DeviceCommunicationHandler(deviceScanner, eventPublisher, saveService, key, device, deviceType); + return new DeviceCommunicationHandler(deviceScanner, saveService, eventBus, key, device, deviceType); } } diff --git a/src/main/java/com/getpcpanel/hid/DeviceHolder.java b/src/main/java/com/getpcpanel/hid/DeviceHolder.java index b5ece766..57140a15 100644 --- a/src/main/java/com/getpcpanel/hid/DeviceHolder.java +++ b/src/main/java/com/getpcpanel/hid/DeviceHolder.java @@ -1,37 +1,37 @@ package com.getpcpanel.hid; -import static org.springframework.core.Ordered.HIGHEST_PRECEDENCE; - import java.util.Collection; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.function.Predicate; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.annotation.Lazy; -import org.springframework.context.event.EventListener; -import org.springframework.core.annotation.Order; -import org.springframework.stereotype.Service; +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import com.getpcpanel.commands.Commands; +import com.getpcpanel.commands.command.Command; import com.getpcpanel.cpp.windows.WindowFocusChangedEvent; import com.getpcpanel.device.Device; import com.getpcpanel.device.DeviceFactory; import com.getpcpanel.device.DeviceType; import com.getpcpanel.profile.SaveService; -import javafx.application.Platform; import lombok.RequiredArgsConstructor; -import lombok.Setter; +import one.util.streamex.EntryStream; +import one.util.streamex.StreamEx; -@Service -@RequiredArgsConstructor +@ApplicationScoped public class DeviceHolder { private final Map devices = new ConcurrentHashMap<>(); - private final SaveService saveService; - @Autowired @Lazy @Setter private DeviceFactory deviceFactory; - private final OutputInterpreter outputInterpreter; - private final ApplicationEventPublisher eventPublisher; + @Inject SaveService saveService; + @Inject DeviceFactory deviceFactory; + @Inject OutputInterpreter outputInterpreter; + @Inject Event eventBus; public Optional getDevice(String key) { return Optional.ofNullable(devices.get(key)); @@ -45,9 +45,8 @@ public Collection values() { return devices.values(); } - @EventListener - @Order(HIGHEST_PRECEDENCE) - public void deviceAdded(DeviceScanner.DeviceConnectedEvent event) { + @Priority(1) + public void deviceAdded(@Observes DeviceScanner.DeviceConnectedEvent event) { Device device; var save = saveService.get(); if (!save.getDevices().containsKey(event.serialNum())) @@ -63,25 +62,22 @@ public void deviceAdded(DeviceScanner.DeviceConnectedEvent event) { } devices.put(event.serialNum(), device); outputInterpreter.sendInit(event.serialNum()); - eventPublisher.publishEvent(new DeviceFullyConnectedEvent(device)); + device.setLighting(device.lightingConfig(), true); + eventBus.fire(new DeviceFullyConnectedEvent(device)); } - @Order - @EventListener - public void onDeviceDisconnected(DeviceScanner.DeviceDisconnectedEvent event) { + public void onDeviceDisconnected(@Observes DeviceScanner.DeviceDisconnectedEvent event) { var device = devices.remove(event.serialNum()); if (device != null) { - Platform.runLater(device::disconnected); + device.disconnected(); } } - @EventListener(WindowFocusChangedEvent.class) - public void focusApplicationChanged() { + public void focusApplicationChanged(@Observes WindowFocusChangedEvent event) { devices.values().forEach(Device::focusApplicationChanged); } - @EventListener(SaveService.SaveEvent.class) - public void saveChanged() { + public void saveChanged(@Observes SaveService.SaveEvent event) { devices.values().forEach(Device::saveChanged); } @@ -89,6 +85,32 @@ public Collection all() { return devices.values(); } + private EntryStream buildCommandStream(Class clazz) { + return StreamEx.of(all()) + .mapToEntry(Device::getSerialNumber).invert() + .mapValues(d -> d.currentProfile()) + .flatMapKeyValue((id, profile) -> EntryStream.of(profile.getDialData()).mapKeys(d -> new DeviceAndDial(id, d))) + .mapToEntry(Map.Entry::getKey, Map.Entry::getValue) + .flatMapValues(d -> Commands.cmds(d).stream()) + .selectValues(clazz); + } + + public boolean hasCommandsOf(Class clazz, Predicate filter) { + return buildCommandStream(clazz).values().anyMatch(filter); + } + + public void triggerCommandsOf(Class clazz, Function, EntryStream> chain) { + buildCommandStream(clazz) + .chain(chain) + .forKeyValue((idAndDial, cmd) -> getDevice(idAndDial.id()).ifPresent(device -> { + var current = device.getKnobRotation(idAndDial.dial()); + eventBus.fire(new DeviceCommunicationHandler.KnobRotateEvent(idAndDial.id(), idAndDial.dial(), current, false)); + })); + } + + public record DeviceAndDial(String id, int dial) { + } + public record DeviceFullyConnectedEvent(Device device) { } } diff --git a/src/main/java/com/getpcpanel/hid/DeviceScanner.java b/src/main/java/com/getpcpanel/hid/DeviceScanner.java index dd0a743d..5ccbef42 100644 --- a/src/main/java/com/getpcpanel/hid/DeviceScanner.java +++ b/src/main/java/com/getpcpanel/hid/DeviceScanner.java @@ -1,7 +1,9 @@ package com.getpcpanel.hid; import java.util.Optional; +import java.util.ArrayList; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; import org.hid4java.HidDevice; import org.hid4java.HidManager; @@ -10,25 +12,27 @@ import org.hid4java.HidServicesSpecification; import org.hid4java.ScanMode; import org.hid4java.event.HidServicesEvent; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.annotation.Lazy; -import org.springframework.stereotype.Service; import com.getpcpanel.device.DeviceType; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import io.quarkus.runtime.ShutdownEvent; +import io.quarkus.runtime.StartupEvent; import lombok.NonNull; -import lombok.RequiredArgsConstructor; -import lombok.Setter; import lombok.extern.log4j.Log4j2; +import one.util.streamex.StreamEx; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public class DeviceScanner implements HidServicesListener { private final ConcurrentHashMap connectedDeviceMap = new ConcurrentHashMap<>(); - private final ApplicationEventPublisher eventPublisher; - @Autowired @Lazy @Setter private DeviceCommunicationHandlerFactory deviceCommunicationHandlerFactory; + private final AtomicBoolean shuttingDown = new AtomicBoolean(false); + private static final long HANDLER_JOIN_TIMEOUT_MS = 1000; + @Inject Event eventBus; + @Inject DeviceCommunicationHandlerFactory deviceCommunicationHandlerFactory; private HidServices hidServices; @@ -36,13 +40,35 @@ public DeviceCommunicationHandler getConnectedDevice(String key) { return connectedDeviceMap.get(key); } - // Not @PostConstruct because the HomePage must have loaded before + // Not @PostConstruct because the startup sequence needs to control when this runs + public void onStart(@Observes StartupEvent ev) { + try { + init(); + } catch (Throwable e) { + log.error("Failed to initialize HID services – device scanning will be unavailable: {}", e.getMessage(), e); + } + } + + public void onShutdown(@Observes ShutdownEvent event) { + close(); + } + public void init() { hidServices = HidManager.getHidServices(buildSpecification()); hidServices.addHidServicesListener(this); log.info("Starting HID services."); hidServices.start(); - log.info("Enumerating attached devices..."); + log.info("Enumerating attached devices...."); + + if (!shuttingDown.compareAndSet(true, false)) { + reconnectDevicesAfterRestart(); + } + } + + private void reconnectDevicesAfterRestart() { + StreamEx.of(hidServices.getAttachedHidDevices()) + .mapToEntry(this::determineType).flatMapValues(Optional::stream) + .forKeyValue(this::foundPCPanel); } static HidServicesSpecification buildSpecification() { @@ -64,18 +90,21 @@ public void deviceAdded(@NonNull String key, @NonNull HidDevice device, DeviceTy var deviceHandler = deviceCommunicationHandlerFactory.build(key, device, deviceType); connectedDeviceMap.put(key, deviceHandler); deviceHandler.start(); - eventPublisher.publishEvent(new DeviceConnectedEvent(key, deviceType)); + fireEvent(new DeviceConnectedEvent(key, deviceType)); } public void deviceRemoved(String key, HidDevice device) { if (key == null || device == null) throw new IllegalArgumentException("serialNum or device cannot be null serialNum: " + key + " device: " + device); if (connectedDeviceMap.remove(key) != null) - eventPublisher.publishEvent(new DeviceDisconnectedEvent(key)); + fireEvent(new DeviceDisconnectedEvent(key)); } private void foundPCPanel(HidDevice newPCPanel, DeviceType deviceType) { log.info("FOUND PCPANEL : {}", newPCPanel); + if (!newPCPanel.isOpen()) + newPCPanel.open(); + try { deviceAdded(newPCPanel.getSerialNumber(), newPCPanel, deviceType); } catch (Exception e) { @@ -94,32 +123,60 @@ private void lostPCPanel(HidDevice lostPCPanel) { @Override public void hidDeviceAttached(HidServicesEvent event) { - determineType(event).ifPresent(type -> foundPCPanel(event.getHidDevice(), type)); + determineType(event.getHidDevice()).ifPresent(type -> foundPCPanel(event.getHidDevice(), type)); } @Override public void hidDeviceDetached(HidServicesEvent event) { - determineType(event).ifPresent(type -> lostPCPanel(event.getHidDevice())); + if (determineType(event.getHidDevice()).isPresent()) { + lostPCPanel(event.getHidDevice()); + } } @Override public void hidFailure(HidServicesEvent event) { - determineType(event).ifPresent(type -> lostPCPanel(event.getHidDevice())); + if (determineType(event.getHidDevice()).isPresent()) { + lostPCPanel(event.getHidDevice()); + } } - private Optional determineType(HidServicesEvent event) { + private Optional determineType(HidDevice device) { for (var deviceType : DeviceType.ALL) { - if (event.getHidDevice().isVidPidSerial(deviceType.getVid(), deviceType.getPid(), null)) + if (device.isVidPidSerial(deviceType.getVid(), deviceType.getPid(), null)) return Optional.of(deviceType); } return Optional.empty(); } public void close() { + if (!shuttingDown.compareAndSet(false, true)) { + return; + } + + var handlers = new ArrayList<>(connectedDeviceMap.values()); + connectedDeviceMap.clear(); + for (var handler : handlers) { + try { + handler.stopGracefully(HANDLER_JOIN_TIMEOUT_MS); + } catch (Exception e) { + log.debug("Error while stopping handler during shutdown.", e); + } + } + try { - hidServices.shutdown(); + if (hidServices != null) { + hidServices.removeHidServicesListener(this); + hidServices.shutdown(); + hidServices = null; + } } catch (Exception e) { - log.error("Error occurred when closing device", e); + log.error("Error occurred when closing device!", e); + } + } + + public void fireEvent(Object event) { + if (!shuttingDown.get()) { + eventBus.fire(event); } } diff --git a/src/main/java/com/getpcpanel/hid/DialValue.java b/src/main/java/com/getpcpanel/hid/DialValue.java index 2da1f5c6..8613d00e 100644 --- a/src/main/java/com/getpcpanel/hid/DialValue.java +++ b/src/main/java/com/getpcpanel/hid/DialValue.java @@ -3,7 +3,7 @@ import javax.annotation.Nullable; import com.getpcpanel.commands.command.Command; -import com.getpcpanel.profile.KnobSetting; +import com.getpcpanel.profile.dto.KnobSetting; public record DialValue( DialValueCalculator settings, diff --git a/src/main/java/com/getpcpanel/hid/DialValueCalculator.java b/src/main/java/com/getpcpanel/hid/DialValueCalculator.java index 91bd2d44..c8ba90a6 100644 --- a/src/main/java/com/getpcpanel/hid/DialValueCalculator.java +++ b/src/main/java/com/getpcpanel/hid/DialValueCalculator.java @@ -7,7 +7,7 @@ import com.getpcpanel.commands.command.Command; import com.getpcpanel.commands.command.DialAction; import com.getpcpanel.commands.command.DialAction.DialCommandParams; -import com.getpcpanel.profile.KnobSetting; +import com.getpcpanel.profile.dto.KnobSetting; public class DialValueCalculator { public static final double EXP_CONST = 1.04723275; // This will make 0-100 map to 1-101 exponentially diff --git a/src/main/java/com/getpcpanel/hid/InputInterpreter.java b/src/main/java/com/getpcpanel/hid/InputInterpreter.java index 43bb81d1..07bed28e 100644 --- a/src/main/java/com/getpcpanel/hid/InputInterpreter.java +++ b/src/main/java/com/getpcpanel/hid/InputInterpreter.java @@ -9,9 +9,10 @@ import java.util.Map; import java.util.concurrent.TimeUnit; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; +import jakarta.enterprise.event.Event; +import jakarta.inject.Inject; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.context.ApplicationScoped; import com.getpcpanel.commands.PCPanelControlEvent; import com.getpcpanel.device.DeviceType; @@ -22,20 +23,22 @@ import lombok.extern.log4j.Log4j2; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public final class InputInterpreter { - private final SaveService save; - private final DeviceHolder devices; - private final ApplicationEventPublisher eventPublisher; - private final Debouncer debouncer; + @Inject + SaveService save; + @Inject + DeviceHolder devices; + @Inject + Event eventBus; + @Inject + Debouncer debouncer; private final Map lastClicks = new HashMap<>(); - @EventListener - public void onKnobRotate(DeviceCommunicationHandler.KnobRotateEvent event) { + public void onKnobRotate(@Observes DeviceCommunicationHandler.KnobRotateEvent event) { devices.getDevice(event.serialNum()).ifPresent(device -> { var value = event.value(); - if (device.getDeviceType() == DeviceType.PCPANEL_RGB) { + if (device.deviceType() == DeviceType.PCPANEL_RGB) { value = map(value, 0, 100, 0, 255); } device.setKnobRotation(event.knob(), value); @@ -44,15 +47,14 @@ public void onKnobRotate(DeviceCommunicationHandler.KnobRotateEvent event) { }); } - @EventListener - public void onButtonPress(DeviceCommunicationHandler.ButtonPressEvent event) throws IOException { + public void onButtonPress(@Observes DeviceCommunicationHandler.ButtonPressEvent event) throws IOException { devices.getDevice(event.serialNum()).ifPresent(device -> device.setButtonPressed(event.button(), event.pressed())); if (event.pressed()) doClickAction(event.serialNum(), event.button()); } private void doDialAction(String serialNum, boolean initial, int knob, DialValue v) { - save.getProfile(serialNum).map(p -> p.getDialData(knob)).ifPresent(data -> eventPublisher.publishEvent(new PCPanelControlEvent(serialNum, knob, data, initial, v))); + save.getProfile(serialNum).map(p -> p.getDialData(knob)).ifPresent(data -> eventBus.fire(new PCPanelControlEvent(serialNum, knob, data, initial, v))); } private void doClickAction(String serialNum, int knob) { @@ -68,13 +70,13 @@ private void determineClick(ClickId clickId, long timeDiff) { if (isDblClick) { debouncer.debounce(clickId, () -> { }, debounceTime, TimeUnit.MILLISECONDS); - eventPublisher.publishEvent(new ButtonClickEvent(clickId.serialNum(), clickId.button(), true)); + eventBus.fire(new ButtonClickEvent(clickId.serialNum(), clickId.button(), true)); lastClicks.remove(clickId); return; } lastClicks.put(clickId, System.currentTimeMillis()); - Runnable trigger = () -> eventPublisher.publishEvent(new ButtonClickEvent(clickId.serialNum(), clickId.button(), false)); + Runnable trigger = () -> eventBus.fire(new ButtonClickEvent(clickId.serialNum(), clickId.button(), false)); if (save.get().isPreventClickWhenDblClick()) { debouncer.debounce(clickId, trigger, debounceTime, TimeUnit.MILLISECONDS); } else { @@ -82,16 +84,15 @@ private void determineClick(ClickId clickId, long timeDiff) { } } - @EventListener - public void onButtonPress(ButtonClickEvent event) { + public void onButtonPress(@Observes ButtonClickEvent event) { save.getProfile(event.serialNum()).ifPresent(profile -> { var click = profile.getButtonData(event.button()); var dblClick = profile.getDblButtonData(event.button()); if (event.dblClick() && hasCommands(dblClick)) { - eventPublisher.publishEvent(new PCPanelControlEvent(event.serialNum(), event.button(), dblClick, false, null)); + eventBus.fire(new PCPanelControlEvent(event.serialNum(), event.button(), dblClick, false, null)); } else if (!event.dblClick() && hasCommands(click)) { - eventPublisher.publishEvent(new PCPanelControlEvent(event.serialNum(), event.button(), click, false, null)); + eventBus.fire(new PCPanelControlEvent(event.serialNum(), event.button(), click, false, null)); } }); } diff --git a/src/main/java/com/getpcpanel/hid/OutputInterpreter.java b/src/main/java/com/getpcpanel/hid/OutputInterpreter.java index 31e5561d..f027db26 100644 --- a/src/main/java/com/getpcpanel/hid/OutputInterpreter.java +++ b/src/main/java/com/getpcpanel/hid/OutputInterpreter.java @@ -2,26 +2,25 @@ import java.util.Arrays; -import org.springframework.stereotype.Service; - import com.getpcpanel.device.DeviceType; -import com.getpcpanel.profile.LightingConfig; -import com.getpcpanel.profile.SingleKnobLightingConfig; -import com.getpcpanel.profile.SingleLogoLightingConfig; -import com.getpcpanel.profile.SingleSliderLabelLightingConfig; -import com.getpcpanel.profile.SingleSliderLightingConfig; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.profile.dto.SingleKnobLightingConfig; +import com.getpcpanel.profile.dto.SingleLogoLightingConfig; +import com.getpcpanel.profile.dto.SingleSliderLabelLightingConfig; +import com.getpcpanel.profile.dto.SingleSliderLightingConfig; import com.getpcpanel.util.coloroverride.OverrideColorService; -import javafx.scene.paint.Color; -import lombok.RequiredArgsConstructor; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import lombok.extern.log4j.Log4j2; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public final class OutputInterpreter { - private final DeviceScanner deviceScanner; - private final OverrideColorService overrideColorService; + @Inject + DeviceScanner deviceScanner; + @Inject + OverrideColorService overrideColorService; private static final byte[] OUTPUT_CODE_INIT = { 1 }; private static final byte ANIMATION_RAINBOW_HORIZONTAL = 1; @@ -61,7 +60,7 @@ public void sendFullLEDData(String deviceSerialNumber, int brightness, String[] var data = new ByteWriter(brightness, 2 + 4 * colors.length + colors.length).append(2, 0); for (var color : colors) { var toSend = overrideColorService.getDialOverride(deviceSerialNumber, 0).map(SingleKnobLightingConfig::getColor1).orElse(color); - data.append(OUTPUT_CODE_RGB_RGB).append(Color.valueOf(toSend)); + data.append(OUTPUT_CODE_RGB_RGB).appendHex(toSend); } for (var b : volumeTrack) { data.append(b ? 1 : 0); @@ -86,14 +85,18 @@ public void sendLightingConfig(String serialNumber, DeviceType dt, LightingConfi private void sendLightingConfigMini(String serialNumber, LightingConfig config) { var handler = deviceScanner.getConnectedDevice(serialNumber); - var mode = config.getLightingMode(); + var mode = config.lightingMode(); + if (mode == null) { + log.error("Null lighting mode in sendLightingConfigMini, ignoring"); + return; + } switch (mode) { case ALL_COLOR -> writeAllColor(handler, PREFIX_MINI, (byte) 5, config); case ALL_RAINBOW -> writeAllRainbow(handler, PREFIX_MINI, config); case ALL_WAVE -> writeAllWave(handler, PREFIX_MINI, config); case ALL_BREATH -> writeAllBreath(handler, PREFIX_MINI, config); case CUSTOM -> { - var knobData = buildKnobData(serialNumber, PREFIX_MINI, config.getGlobalBrightness(), config.getKnobConfigs()); + var knobData = buildKnobData(serialNumber, PREFIX_MINI, config.getGlobalBrightness(), config.knobConfigs()); handler.sendMessage(new byte[][] { knobData }); } } @@ -101,35 +104,39 @@ private void sendLightingConfigMini(String serialNumber, LightingConfig config) private void sendLightingConfigPro(String serialNumber, LightingConfig config) { var handler = deviceScanner.getConnectedDevice(serialNumber); - var mode = config.getLightingMode(); + var mode = config.lightingMode(); + if (mode == null) { + log.error("Null lighting mode in sendLightingConfigPro, ignoring"); + return; + } switch (mode) { case ALL_COLOR -> writeAllColor(handler, PREFIX_PRO, (byte) 2, config); case ALL_RAINBOW -> writeAllRainbow(handler, PREFIX_PRO, config); case ALL_WAVE -> writeAllWave(handler, PREFIX_PRO, config); case ALL_BREATH -> writeAllBreath(handler, PREFIX_PRO, config); case CUSTOM -> { - var knobData = buildKnobData(serialNumber, PREFIX_PRO, config.getGlobalBrightness(), config.getKnobConfigs()); - var sliderLabelData = buildSliderLabelData(serialNumber, config.getGlobalBrightness(), config.getSliderLabelConfigs()); - var sliderData = buildSliderData(serialNumber, config.getGlobalBrightness(), config.getSliderConfigs()); - var logoData = buildLogoData(serialNumber, config.getGlobalBrightness(), config.getLogoConfig()); + var knobData = buildKnobData(serialNumber, PREFIX_PRO, config.getGlobalBrightness(), config.knobConfigs()); + var sliderLabelData = buildSliderLabelData(serialNumber, config.getGlobalBrightness(), config.sliderLabelConfigs()); + var sliderData = buildSliderData(serialNumber, config.getGlobalBrightness(), config.sliderConfigs()); + var logoData = buildLogoData(serialNumber, config.getGlobalBrightness(), config.logoConfig()); handler.sendMessage(knobData, sliderLabelData, sliderData, logoData); } } } private void writeAllColor(DeviceCommunicationHandler handler, byte prefix, byte secondPrefix, LightingConfig config) { - var c1 = Color.valueOf(config.getAllColor()); - var data = new ByteWriter(config.getGlobalBrightness()).append(prefix, MODE_LIGHT_ANIMATION, secondPrefix).append(c1).get(); + var c1 = config.allColor(); + var data = new ByteWriter(config.getGlobalBrightness()).append(prefix, MODE_LIGHT_ANIMATION, secondPrefix).appendHex(c1).get(); handler.sendMessage(new byte[][] { data }); } private void writeAllRainbow(DeviceCommunicationHandler handler, byte prefix, LightingConfig config) { - var data = new ByteWriter(config.getGlobalBrightness()).append(prefix, MODE_LIGHT_ANIMATION, (config.getRainbowVertical() == 1) ? ANIMATION_RAINBOW_VERTICAL : ANIMATION_RAINBOW_HORIZONTAL) - .append(config.getRainbowPhaseShift(), + var data = new ByteWriter(config.getGlobalBrightness()).append(prefix, MODE_LIGHT_ANIMATION, (config.rainbowVertical() == 1) ? ANIMATION_RAINBOW_VERTICAL : ANIMATION_RAINBOW_HORIZONTAL) + .append(config.rainbowPhaseShift(), -1) - .appendBrightness(config.getRainbowBrightness()) - .append(config.getRainbowSpeed(), - config.getRainbowReverse()) + .appendBrightness(config.rainbowBrightness()) + .append(config.rainbowSpeed(), + config.rainbowReverse()) .get(); handler.sendMessage(new byte[][] { data }); } @@ -137,22 +144,22 @@ private void writeAllRainbow(DeviceCommunicationHandler handler, byte prefix, Li private void writeAllWave(DeviceCommunicationHandler handler, byte prefix, LightingConfig config) { var data = new ByteWriter(config.getGlobalBrightness()) .append(prefix, MODE_LIGHT_ANIMATION, ANIMATION_WAVE) - .append(config.getWaveHue(), + .append(config.waveHue(), -1) - .appendBrightness(config.getWaveBrightness()) - .append(config.getWaveSpeed(), - config.getWaveReverse(), - config.getWaveBounce()); + .appendBrightness(config.waveBrightness()) + .append(config.waveSpeed(), + config.waveReverse(), + config.waveBounce()); handler.sendMessage(new byte[][] { data.get() }); } private void writeAllBreath(DeviceCommunicationHandler handler, byte prefix, LightingConfig config) { var data = new ByteWriter(config.getGlobalBrightness()) .append(prefix, MODE_LIGHT_ANIMATION, ANIMATION_BREATH) - .append(config.getBreathHue(), + .append(config.breathHue(), -1) - .appendBrightness(config.getBreathBrightness()) - .append(config.getBreathSpeed()); + .appendBrightness(config.breathBrightness()) + .append(config.breathSpeed()); handler.sendMessage(new byte[][] { data.get() }); } @@ -166,16 +173,16 @@ private byte[] buildKnobData(String deviceSerial, byte prefix, int brightness, S var ignored = switch (knobConfig.getMode()) { case NONE -> knobData; case STATIC -> { - var c1 = Color.valueOf(knobConfig.getColor1()); + var c1 = knobConfig.getColor1(); yield knobData.append(COLOR_STATIC) - .append(c1); + .appendHex(c1); } case VOLUME_GRADIENT -> { - var c1 = Color.valueOf(knobConfig.getColor1()); - var c2 = Color.valueOf(knobConfig.getColor2()); + var c1 = knobConfig.getColor1(); + var c2 = knobConfig.getColor2(); yield knobData.append(COLOR_GRADIENT) - .append(c1) - .append(c2); + .appendHex(c1) + .appendHex(c2); } }; knobData.skipFromMark(7); @@ -191,10 +198,10 @@ private byte[] buildSliderLabelData(String deviceSerial, int brightness, SingleS var ignored = switch (sliderLabelConfig.getMode()) { case NONE -> sliderLabelData; case STATIC -> { - var c1 = Color.valueOf(sliderLabelConfig.getColor()); + var c1 = sliderLabelConfig.getColor(); yield sliderLabelData.mark() .append(1) - .append(c1); + .appendHex(c1); } }; sliderLabelData.skipFromMark(7); @@ -211,17 +218,17 @@ private byte[] buildSliderData(String deviceSerial, int brightness, SingleSlider var ignored = switch (sliderConfig.getMode()) { case NONE -> sliderData; case STATIC -> { - var c1 = Color.valueOf(sliderConfig.getColor1()); + var c1 = sliderConfig.getColor1(); yield sliderData.append(1) - .append(c1) - .append(c1); + .appendHex(c1) + .appendHex(c1); } case STATIC_GRADIENT -> sliderData.append(1) - .append(Color.valueOf(sliderConfig.getColor1())) - .append(Color.valueOf(sliderConfig.getColor2())); + .appendHex(sliderConfig.getColor1()) + .appendHex(sliderConfig.getColor2()); case VOLUME_GRADIENT -> sliderData.append(3) - .append(Color.valueOf(sliderConfig.getColor1())) - .append(Color.valueOf(sliderConfig.getColor2())); + .appendHex(sliderConfig.getColor1()) + .appendHex(sliderConfig.getColor2()); }; sliderData.skipFromMark(7); } @@ -234,8 +241,8 @@ private byte[] buildLogoData(String deviceSerial, int brightness, SingleLogoLigh var ignored = switch (logoConfig.getMode()) { case NONE -> logoConfig; case STATIC -> { - var c1 = Color.valueOf(logoConfig.getColor()); - yield logoData.append(COLOR_STATIC).append(c1); + var c1 = logoConfig.getColor(); + yield logoData.append(COLOR_STATIC).appendHex(c1); } case RAINBOW -> logoData.append(LOGO_RAINBOW) .append(-1) @@ -251,18 +258,18 @@ private byte[] buildLogoData(String deviceSerial, int brightness, SingleLogoLigh } private void sendLightingConfigRGB(String serialNumber, LightingConfig config, boolean priority) { - var mode = config.getLightingMode(); + var mode = config.lightingMode(); if (mode == null) { log.error("unexpected lighting mode in deviceOutputHandler"); return; } switch (mode) { - case ALL_COLOR -> sendRGBAll(serialNumber, config.getGlobalBrightness(), Color.valueOf(config.getAllColor()), config.getVolumeBrightnessTrackingEnabled(), priority); - case SINGLE_COLOR -> sendFullLEDData(serialNumber, config.getGlobalBrightness(), config.getIndividualColors(), config.getVolumeBrightnessTrackingEnabled(), priority); - case ALL_RAINBOW -> sendRainbow(serialNumber, config.getRainbowPhaseShift(), (byte) -1, config.getRainbowBrightness(), config.getRainbowSpeed(), config.getRainbowReverse(), priority); - case ALL_WAVE -> sendWave(serialNumber, config.getWaveHue(), (byte) -1, config.getWaveBrightness(), config.getWaveSpeed(), config.getWaveReverse(), config.getWaveBounce(), priority); - case ALL_BREATH -> sendBreath(serialNumber, config.getBreathHue(), (byte) -1, config.getBreathBrightness(), config.getBreathSpeed(), priority); + case ALL_COLOR -> sendRGBAll(serialNumber, config.getGlobalBrightness(), config.allColor(), config.volumeBrightnessTrackingEnabled(), priority); + case SINGLE_COLOR -> sendFullLEDData(serialNumber, config.getGlobalBrightness(), config.individualColors(), config.volumeBrightnessTrackingEnabled(), priority); + case ALL_RAINBOW -> sendRainbow(serialNumber, config.rainbowPhaseShift(), (byte) -1, config.rainbowBrightness(), config.rainbowSpeed(), config.rainbowReverse(), priority); + case ALL_WAVE -> sendWave(serialNumber, config.waveHue(), (byte) -1, config.waveBrightness(), config.waveSpeed(), config.waveReverse(), config.waveBounce(), priority); + case ALL_BREATH -> sendBreath(serialNumber, config.breathHue(), (byte) -1, config.breathBrightness(), config.breathSpeed(), priority); default -> log.error("unexpected lighting mode in deviceOutputHandler"); } } @@ -304,8 +311,18 @@ public void sendBreath(String deviceSerialNumber, byte hue, byte saturation, byt } @SuppressWarnings("NumericCastThatLosesPrecision") - public void sendRGBAll(String deviceSerialNumber, int brightness, Color color, boolean[] bs, boolean priority) { - sendRGBAll(deviceSerialNumber, brightness, (int) (color.getRed() * MAX_BYTE), (int) (color.getGreen() * MAX_BYTE), (int) (color.getBlue() * MAX_BYTE), bs, priority); + public void sendRGBAll(String deviceSerialNumber, int brightness, String hexColor, boolean[] bs, boolean priority) { + int r = 0, g = 0, b = 0; + if (hexColor != null) { + try { + String hex = hexColor.startsWith("#") ? hexColor.substring(1) : hexColor; + r = Integer.parseInt(hex.substring(0, 2), 16); + g = Integer.parseInt(hex.substring(2, 4), 16); + b = Integer.parseInt(hex.substring(4, 6), 16); + } catch (Exception ignored) { + } + } + sendRGBAll(deviceSerialNumber, brightness, r, g, b, bs, priority); } public void sendRGBAll(String deviceSerialNumber, int brightness, int red, int green, int blue, boolean[] volumeTrack, boolean priority) { @@ -316,7 +333,7 @@ public void sendRGBAll(String deviceSerialNumber, int brightness, int red, int g throw new IllegalArgumentException("ints must be byte size"); var data = new ByteWriter(brightness, 6 + volumeTrack.length) .append(OUTPUT_CODE_RGB, OUTPUT_CODE_RGB_RGB, 0) - .append(Color.rgb(red, green, blue)); + .appendRGB(red, green, blue); for (var b : volumeTrack) data.append(b ? 1 : 0); if (priority) { diff --git a/src/main/java/com/getpcpanel/iconextract/IIconService.java b/src/main/java/com/getpcpanel/iconextract/IIconService.java index 770deafa..6fc7579c 100644 --- a/src/main/java/com/getpcpanel/iconextract/IIconService.java +++ b/src/main/java/com/getpcpanel/iconextract/IIconService.java @@ -3,24 +3,9 @@ import java.awt.image.BufferedImage; import java.io.File; -import javax.annotation.Nullable; - -import org.springframework.cache.annotation.Cacheable; - -import javafx.embed.swing.SwingFXUtils; -import javafx.scene.image.Image; +import jakarta.annotation.Nullable; public interface IIconService { - @Cacheable("icon") - BufferedImage getIconForFile(int width, int height, File file); - @Nullable - @Cacheable("icon") - default Image getIconImageForFile(int width, int height, File file) { - var image = getIconForFile(width, height, file); - if (image != null) { - return SwingFXUtils.toFXImage(image, null); - } - return null; - } + BufferedImage getIconForFile(int width, int height, File file); } diff --git a/src/main/java/com/getpcpanel/iconextract/IconServiceLinux.java b/src/main/java/com/getpcpanel/iconextract/IconServiceLinux.java index c83565c2..a084cd43 100644 --- a/src/main/java/com/getpcpanel/iconextract/IconServiceLinux.java +++ b/src/main/java/com/getpcpanel/iconextract/IconServiceLinux.java @@ -3,15 +3,16 @@ import java.awt.image.BufferedImage; import java.io.File; -import org.springframework.stereotype.Service; +import com.getpcpanel.platform.LinuxBuild; -import com.getpcpanel.spring.ConditionalOnLinux; +import jakarta.enterprise.context.ApplicationScoped; -@Service -@ConditionalOnLinux +@ApplicationScoped +@LinuxBuild public class IconServiceLinux implements IIconService { @Override public BufferedImage getIconForFile(int width, int height, File file) { return null; } } + diff --git a/src/main/java/com/getpcpanel/iconextract/IconServiceWindows.java b/src/main/java/com/getpcpanel/iconextract/IconServiceWindows.java index bf779ecd..5af655d6 100644 --- a/src/main/java/com/getpcpanel/iconextract/IconServiceWindows.java +++ b/src/main/java/com/getpcpanel/iconextract/IconServiceWindows.java @@ -3,15 +3,16 @@ import java.awt.image.BufferedImage; import java.io.File; -import org.springframework.stereotype.Service; +import com.getpcpanel.platform.WindowsBuild; -import com.getpcpanel.spring.ConditionalOnWindows; +import jakarta.enterprise.context.ApplicationScoped; -@Service -@ConditionalOnWindows +@ApplicationScoped +@WindowsBuild public class IconServiceWindows implements IIconService { @Override public BufferedImage getIconForFile(int width, int height, File file) { return JIconExtract.getIconForFile(width, height, file); } } + diff --git a/src/main/java/com/getpcpanel/mqtt/MqttDeviceColorService.java b/src/main/java/com/getpcpanel/mqtt/MqttDeviceColorService.java index 5486f4fd..a0cb1a10 100644 --- a/src/main/java/com/getpcpanel/mqtt/MqttDeviceColorService.java +++ b/src/main/java/com/getpcpanel/mqtt/MqttDeviceColorService.java @@ -6,6 +6,7 @@ import static com.getpcpanel.mqtt.MqttTopicHelper.ColorType.slider; import static com.getpcpanel.mqtt.MqttTopicHelper.ValueType.brightness; import static com.getpcpanel.util.Util.parseColor; +import static com.getpcpanel.util.Util.parseColorComponents; import java.util.function.Consumer; import java.util.function.Function; @@ -16,40 +17,52 @@ import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.math.NumberUtils; -import org.apache.logging.log4j.util.TriConsumer; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.core.annotation.Order; -import org.springframework.stereotype.Service; import com.getpcpanel.device.Device; -import com.getpcpanel.profile.LightingConfig; -import com.getpcpanel.profile.SingleKnobLightingConfig; -import com.getpcpanel.profile.SingleLogoLightingConfig; -import com.getpcpanel.profile.SingleSliderLabelLightingConfig; -import com.getpcpanel.profile.SingleSliderLightingConfig; -import com.getpcpanel.ui.HomePage; +import com.getpcpanel.device.GlobalBrightnessChangedEvent; +import com.getpcpanel.mqtt.MqttTopicHelper.ColorType; +import com.getpcpanel.mqtt.MqttTopicHelper.DeviceMqttTopicHelper; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.profile.dto.SingleKnobLightingConfig; +import com.getpcpanel.profile.dto.SingleKnobLightingConfig.SINGLE_KNOB_MODE; +import com.getpcpanel.profile.dto.SingleLogoLightingConfig; +import com.getpcpanel.profile.dto.SingleLogoLightingConfig.SINGLE_LOGO_MODE; +import com.getpcpanel.profile.dto.SingleSliderLabelLightingConfig; +import com.getpcpanel.profile.dto.SingleSliderLabelLightingConfig.SINGLE_SLIDER_LABEL_MODE; +import com.getpcpanel.profile.dto.SingleSliderLightingConfig; +import com.getpcpanel.profile.dto.SingleSliderLightingConfig.SINGLE_SLIDER_MODE; import com.getpcpanel.util.coloroverride.ColorOverrideHolder; import com.getpcpanel.util.coloroverride.IOverrideColorProvider; import com.getpcpanel.util.coloroverride.IOverrideColorProviderProvider; -import javafx.scene.paint.Color; -import lombok.RequiredArgsConstructor; +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; +import jakarta.inject.Inject; import lombok.extern.log4j.Log4j2; import one.util.streamex.EntryStream; import one.util.streamex.StreamEx; @Log4j2 -@Service -@Order(1) -@RequiredArgsConstructor +@ApplicationScoped +@Priority(1) public class MqttDeviceColorService implements IOverrideColorProviderProvider { + + @FunctionalInterface + interface TriFunction { + R apply(A a, B b, C c); + } + public static final String EFFECT_NONE = "none"; public static final String EFFECT_STOP_OVERRIDE = "stop_override"; - private final MqttService mqtt; - private final MqttTopicHelper mqttTopicHelper; + @Inject + MqttService mqtt; + @Inject + MqttTopicHelper mqttTopicHelper; private final ColorOverrideHolder colorOverrideHolder = new MqttColorOverrideHolder(); - private final ApplicationEventPublisher applicationEventPublisher; + @Inject + Event eventBus; @Override public IOverrideColorProvider getOverrideColorProvider() { @@ -59,10 +72,10 @@ public IOverrideColorProvider getOverrideColorProvider() { public void sendColor(String topic, @Nonnull String colorString, boolean immediate) { mqtt.send(topic, colorString, immediate); - var setColor = parseColor(colorString).orElse(Color.BLACK); - var r = Math.round(setColor.getRed() * 255); - var g = Math.round(setColor.getGreen() * 255); - var b = Math.round(setColor.getBlue() * 255); + var colorComponents = parseColorComponents(colorString); + int r = colorComponents != null ? colorComponents[0] : 0; + int g = colorComponents != null ? colorComponents[1] : 0; + int b = colorComponents != null ? colorComponents[2] : 0; // Home assistant seems to think that the brightness is the highest value of the RGB var brightness = Math.max(r, Math.max(g, b)); @@ -80,20 +93,23 @@ public void sendColor(String topic, @Nonnull String colorString, boolean immedia public void buildSubscriptions(Device device, LightingConfig lighting) { var topicHelper = mqttTopicHelper.device(device.getSerialNumber()); Runnable andThen = () -> device.setLighting(lighting, true); - TriConsumer knobOverride = (idx, payload, knob) -> { - colorOverrideHolder.setDialOverride(device.getSerialNumber(), idx, new SingleKnobLightingConfig().setMode(SingleKnobLightingConfig.SINGLE_KNOB_MODE.STATIC).setColor1(payload)); + TriFunction knobOverride = (idx, payload, knob) -> { + colorOverrideHolder.setDialOverride(device.getSerialNumber(), idx, new SingleKnobLightingConfig().setMode(SINGLE_KNOB_MODE.STATIC).setColor1(payload)); andThen.run(); + return null; }; - TriConsumer sliderOverride = (idx, payload, knob) -> { - colorOverrideHolder.setSliderOverride(device.getSerialNumber(), idx, new SingleSliderLightingConfig().setMode(SingleSliderLightingConfig.SINGLE_SLIDER_MODE.STATIC).setColor1(payload)); + TriFunction sliderOverride = (idx, payload, knob) -> { + colorOverrideHolder.setSliderOverride(device.getSerialNumber(), idx, new SingleSliderLightingConfig().setMode(SINGLE_SLIDER_MODE.STATIC).setColor1(payload)); andThen.run(); + return null; }; - TriConsumer sliderLabelOverride = (idx, payload, knob) -> { - colorOverrideHolder.setSliderLabelOverride(device.getSerialNumber(), idx, new SingleSliderLabelLightingConfig().setMode(SingleSliderLabelLightingConfig.SINGLE_SLIDER_LABEL_MODE.STATIC).setColor(payload)); + TriFunction sliderLabelOverride = (idx, payload, knob) -> { + colorOverrideHolder.setSliderLabelOverride(device.getSerialNumber(), idx, new SingleSliderLabelLightingConfig().setMode(SINGLE_SLIDER_LABEL_MODE.STATIC).setColor(payload)); andThen.run(); + return null; }; Consumer logoOverride = payload -> { - colorOverrideHolder.setLogoOverride(device.getSerialNumber(), new SingleLogoLightingConfig().setMode(SingleLogoLightingConfig.SINGLE_LOGO_MODE.STATIC).setColor(payload)); + colorOverrideHolder.setLogoOverride(device.getSerialNumber(), new SingleLogoLightingConfig().setMode(SINGLE_LOGO_MODE.STATIC).setColor(payload)); andThen.run(); }; @@ -101,20 +117,20 @@ public void buildSubscriptions(Device device, LightingConfig lighting) { var newBrightness = NumberUtils.toInt(payload, 100); lighting.setGlobalBrightness(newBrightness); andThen.run(); - applicationEventPublisher.publishEvent(new HomePage.GlobalBrightnessChangedEvent(this, device.getSerialNumber(), newBrightness)); + eventBus.fire(new GlobalBrightnessChangedEvent(device.getSerialNumber(), newBrightness)); }); - subscribeToColors(lighting.getKnobConfigs(), topicHelper, dial, knobOverride, idx -> device.getLightingConfig().getKnobConfigs()[idx].getColor1()); - subscribeToColors(lighting.getSliderConfigs(), topicHelper, slider, sliderOverride, idx -> device.getLightingConfig().getSliderConfigs()[idx].getColor1()); - subscribeToColors(lighting.getSliderLabelConfigs(), topicHelper, label, sliderLabelOverride, idx -> device.getLightingConfig().getSliderLabelConfigs()[idx].getColor()); - if (lighting.getLogoConfig() != null) { - subscribeToColor(topicHelper.lightTopic(logo, 0), logoOverride, () -> device.getLightingConfig().getLogoConfig().getColor()); + subscribeToColors(lighting.knobConfigs(), topicHelper, dial, knobOverride, idx -> device.lightingConfig().knobConfigs()[idx].getColor1()); + subscribeToColors(lighting.sliderConfigs(), topicHelper, slider, sliderOverride, idx -> device.lightingConfig().sliderConfigs()[idx].getColor1()); + subscribeToColors(lighting.sliderLabelConfigs(), topicHelper, label, sliderLabelOverride, idx -> device.lightingConfig().sliderLabelConfigs()[idx].getColor()); + if (lighting.logoConfig() != null) { + subscribeToColor(topicHelper.lightTopic(logo, 0), logoOverride, () -> device.lightingConfig().logoConfig().getColor()); } } - private void subscribeToColors(T[] items, MqttTopicHelper.DeviceMqttTopicHelper topicHelper, MqttTopicHelper.ColorType type, TriConsumer consumer, Function currentColorSupplier) { + private void subscribeToColors(T[] items, DeviceMqttTopicHelper topicHelper, ColorType type, TriFunction consumer, Function currentColorSupplier) { EntryStream.of(items).forKeyValue((idx, knob) -> { var topic = topicHelper.lightTopic(type, idx); - subscribeToColor(topic, payload -> consumer.accept(idx, payload, knob), () -> currentColorSupplier.apply(idx)); + subscribeToColor(topic, payload -> consumer.apply(idx, payload, knob), () -> currentColorSupplier.apply(idx)); }); } @@ -135,7 +151,7 @@ private void subscribeToColor(String baseTopic, Consumer colorOverrider, return; } - color.fromColor(setColor.get()); + color.fromHex(setColor.get()); colorOverrider.accept(publish); }); @@ -240,12 +256,17 @@ private static class MutableColor { int blue = 255; int brightness = 255; - @SuppressWarnings("NumericCastThatLosesPrecision") - public void fromColor(Color color) { - red = (int) Math.round(color.getRed() * 255); - green = (int) Math.round(color.getGreen() * 255); - blue = (int) Math.round(color.getBlue() * 255); - brightness = 255; + public void fromHex(String hex) { + if (hex == null) + return; + try { + String h = hex.startsWith("#") ? hex.substring(1) : hex; + red = Integer.parseInt(h.substring(0, 2), 16); + green = Integer.parseInt(h.substring(2, 4), 16); + blue = Integer.parseInt(h.substring(4, 6), 16); + brightness = 255; + } catch (Exception ignored) { + } } public String toColorString() { diff --git a/src/main/java/com/getpcpanel/mqtt/MqttDeviceService.java b/src/main/java/com/getpcpanel/mqtt/MqttDeviceService.java index e8024fe2..a80607ca 100644 --- a/src/main/java/com/getpcpanel/mqtt/MqttDeviceService.java +++ b/src/main/java/com/getpcpanel/mqtt/MqttDeviceService.java @@ -16,50 +16,55 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; -import org.springframework.context.event.EventListener; -import org.springframework.core.annotation.Order; -import org.springframework.stereotype.Service; - import com.getpcpanel.device.Device; +import com.getpcpanel.device.GlobalBrightnessChangedEvent; import com.getpcpanel.hid.ButtonClickEvent; -import com.getpcpanel.hid.DeviceCommunicationHandler; +import com.getpcpanel.hid.DeviceCommunicationHandler.ButtonPressEvent; +import com.getpcpanel.hid.DeviceCommunicationHandler.KnobRotateEvent; import com.getpcpanel.hid.DeviceHolder; import com.getpcpanel.hid.DeviceHolder.DeviceFullyConnectedEvent; import com.getpcpanel.mqtt.MqttTopicHelper.ColorType; import com.getpcpanel.mqtt.MqttTopicHelper.DeviceMqttTopicHelper; -import com.getpcpanel.profile.LightingConfig; import com.getpcpanel.profile.SaveService; -import com.getpcpanel.profile.SingleKnobLightingConfig; -import com.getpcpanel.profile.SingleSliderLabelLightingConfig; -import com.getpcpanel.profile.SingleSliderLightingConfig; -import com.getpcpanel.ui.HomePage.GlobalBrightnessChangedEvent; - -import lombok.RequiredArgsConstructor; +import com.getpcpanel.profile.SaveService.SaveEvent; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.profile.dto.LightingConfig.LightingMode; +import com.getpcpanel.profile.dto.SingleKnobLightingConfig; +import com.getpcpanel.profile.dto.SingleSliderLabelLightingConfig; +import com.getpcpanel.profile.dto.SingleSliderLightingConfig; + +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; import lombok.extern.log4j.Log4j2; import one.util.streamex.EntryStream; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public class MqttDeviceService { - private final MqttService mqtt; - private final SaveService saveService; - private final DeviceHolder deviceHolder; - private final MqttHomeAssistantHelper mqttHomeAssistantHelper; - private final MqttTopicHelper mqttTopicHelper; - private final MqttDeviceColorService deviceColorService; + @Inject + MqttService mqtt; + @Inject + SaveService saveService; + @Inject + DeviceHolder deviceHolder; + @Inject + MqttHomeAssistantHelper mqttHomeAssistantHelper; + @Inject + MqttTopicHelper mqttTopicHelper; + @Inject + MqttDeviceColorService deviceColorService; private final Set initializedDevices = new HashSet<>(); - @Order(ORDER_OF_SAVE + 1) // Ensure we are disconnected if the setting is turned off - @EventListener(SaveService.SaveEvent.class) - public void saveChanged() { + @Priority(ORDER_OF_SAVE + 1) // Ensure we are disconnected if the setting is turned off + public void saveChanged(@Observes SaveEvent event) { if (mqtt.isConnected()) { initialize(); } } - @EventListener - public void mqttConnected(MqttStatusEvent event) { + public void mqttConnected(@Observes MqttStatusEvent event) { if (!event.connected()) { return; } @@ -81,16 +86,14 @@ public boolean clear() { return false; } - @EventListener - public void deviceConnected(DeviceFullyConnectedEvent event) { + public void deviceConnected(@Observes DeviceFullyConnectedEvent event) { if (!mqtt.isConnected()) { return; } initialize(event.device()); } - @EventListener - public void dialAction(DeviceCommunicationHandler.KnobRotateEvent dial) { + public void dialAction(@Observes KnobRotateEvent dial) { if (!mqtt.isConnected()) { return; } @@ -101,8 +104,7 @@ public void dialAction(DeviceCommunicationHandler.KnobRotateEvent dial) { }); } - @EventListener - public void buttonPress(DeviceCommunicationHandler.ButtonPressEvent btn) { + public void buttonPress(@Observes ButtonPressEvent btn) { if (!mqtt.isConnected()) { return; } @@ -112,8 +114,7 @@ public void buttonPress(DeviceCommunicationHandler.ButtonPressEvent btn) { }); } - @EventListener - public void buttonPress(ButtonClickEvent btn) { + public void buttonPress(@Observes ButtonClickEvent btn) { if (!mqtt.isConnected()) { return; } @@ -123,12 +124,11 @@ public void buttonPress(ButtonClickEvent btn) { }); } - @EventListener - public void globalBrightnessChange(GlobalBrightnessChangedEvent event) { + public void globalBrightnessChange(@Observes GlobalBrightnessChangedEvent event) { if (!mqtt.isConnected()) { return; } - mqtt.send(mqttTopicHelper.valueTopic(event.serialNr(), brightness, 0), String.valueOf(event.brightness()), false); + mqtt.send(mqttTopicHelper.valueTopic(event.serialNum(), brightness, 0), String.valueOf(event.brightness()), false); } private void initialize(Device device) { @@ -137,8 +137,8 @@ private void initialize(Device device) { } initializedDevices.add(device); - var lighting = device.getLightingConfig(); - if (lighting.getLightingMode() != LightingConfig.LightingMode.CUSTOM) { + var lighting = device.lightingConfig(); + if (lighting.lightingMode() != LightingMode.CUSTOM) { log.debug("Only custom lighting will be written to mqtt"); return; } @@ -163,11 +163,11 @@ private void writeLighting(Device device, LightingConfig lighting) { var mqttHelper = mqttTopicHelper.device(device.getSerialNumber()); mqtt.send(mqttHelper.valueTopic(brightness, 0), String.valueOf(lighting.getGlobalBrightness()), false); - sendColors(lighting.getKnobConfigs(), mqttHelper, dial, SingleKnobLightingConfig::getColor1); - sendColors(lighting.getSliderConfigs(), mqttHelper, slider, SingleSliderLightingConfig::getColor1); - sendColors(lighting.getSliderLabelConfigs(), mqttHelper, label, SingleSliderLabelLightingConfig::getColor); - if (device.getDeviceType().isHasLogoLed() && lighting.getLogoConfig() != null) { - deviceColorService.sendColor(mqttHelper.lightTopic(logo, 0), toColorString(lighting.getLogoConfig().getColor()), false); + sendColors(lighting.knobConfigs(), mqttHelper, dial, SingleKnobLightingConfig::getColor1); + sendColors(lighting.sliderConfigs(), mqttHelper, slider, SingleSliderLightingConfig::getColor1); + sendColors(lighting.sliderLabelConfigs(), mqttHelper, label, SingleSliderLabelLightingConfig::getColor); + if (device.deviceType().isHasLogoLed() && lighting.logoConfig() != null) { + deviceColorService.sendColor(mqttHelper.lightTopic(logo, 0), toColorString(lighting.logoConfig().getColor()), false); } } @@ -178,12 +178,13 @@ private void sendColors(T[] items, DeviceMqttTopicHelper mqttHelper, ColorTy }); } - private @Nonnull String toColorString(@Nullable String color) { + @Nonnull + private String toColorString(@Nullable String color) { return color == null ? "#000000" : color; } private void writeButtons(Device device) { - for (var i = 0; i < device.getDeviceType().getButtonCount(); i++) { + for (var i = 0; i < device.deviceType().getButtonCount(); i++) { mqtt.send(mqttTopicHelper.buttonUpDownTopic(device.getSerialNumber(), button, i), "up", true); } } diff --git a/src/main/java/com/getpcpanel/mqtt/MqttHomeAssistantHelper.java b/src/main/java/com/getpcpanel/mqtt/MqttHomeAssistantHelper.java index 83dd4b2a..0ed6ea1d 100644 --- a/src/main/java/com/getpcpanel/mqtt/MqttHomeAssistantHelper.java +++ b/src/main/java/com/getpcpanel/mqtt/MqttHomeAssistantHelper.java @@ -6,25 +6,30 @@ import java.util.List; import javax.annotation.Nonnull; -import javax.annotation.Nullable; import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import javax.annotation.Nullable; import com.getpcpanel.device.Device; -import com.getpcpanel.profile.MqttSettings; +import com.getpcpanel.mqtt.MqttTopicHelper.ActionType; +import com.getpcpanel.mqtt.MqttTopicHelper.ColorType; +import com.getpcpanel.mqtt.MqttTopicHelper.ValueType; +import com.getpcpanel.profile.dto.MqttSettings; -import lombok.RequiredArgsConstructor; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import lombok.extern.log4j.Log4j2; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public class MqttHomeAssistantHelper { - private final MqttTopicHelper topicHelper; - private final MqttService mqttService; - @Value("${application.version}") private String version; + @Inject + MqttTopicHelper topicHelper; + @Inject + MqttService mqttService; + @ConfigProperty(name = "pcpanel.version") private String version; public void clearAll(MqttSettings settings) { var topic = StringUtils.joinWith("/", settings.homeAssistant().baseTopic(), "+", "pcpanel", "#"); @@ -47,19 +52,19 @@ private void addLights(MqttSettings settings, Device device, HomeAssistantDevice } private void addControlLights(MqttSettings settings, Device device, HomeAssistantDevice haDevice, @Nullable HomeAssistantAvailability availability) { - for (var i = 0; i < device.getDeviceType().getAnalogCount(); i++) { - var buttonCount = device.getDeviceType().getButtonCount(); - var type = i < buttonCount ? MqttTopicHelper.ColorType.dial : MqttTopicHelper.ColorType.slider; + for (var i = 0; i < device.deviceType().getAnalogCount(); i++) { + var buttonCount = device.deviceType().getButtonCount(); + var type = i < buttonCount ? ColorType.dial : ColorType.slider; var idx = i < buttonCount ? i : i - buttonCount; addControlLightConfig(settings, device, haDevice, availability, i, type, idx); - if (type == MqttTopicHelper.ColorType.slider) { + if (type == ColorType.slider) { addSliderLabelLightConfig(settings, device, haDevice, availability, idx, type); } } } - private void addControlLightConfig(MqttSettings settings, Device device, HomeAssistantDevice haDevice, @Nullable HomeAssistantAvailability availability, int i, MqttTopicHelper.ColorType type, int idx) { + private void addControlLightConfig(MqttSettings settings, Device device, HomeAssistantDevice haDevice, @Nullable HomeAssistantAvailability availability, int i, ColorType type, int idx) { var controlConfigTopic = lightTopicFor(settings, device, "control_" + i); var controlValueTopic = topicHelper.lightTopic(device.getSerialNumber(), type, idx); @@ -73,9 +78,9 @@ private void addControlLightConfig(MqttSettings settings, Device device, HomeAss mqttService.send(controlConfigTopic, config, false); } - private void addSliderLabelLightConfig(MqttSettings settings, Device device, HomeAssistantDevice haDevice, @Nullable HomeAssistantAvailability availability, int idx, MqttTopicHelper.ColorType type) { + private void addSliderLabelLightConfig(MqttSettings settings, Device device, HomeAssistantDevice haDevice, @Nullable HomeAssistantAvailability availability, int idx, ColorType type) { var labelConfigTopic = lightTopicFor(settings, device, "label_" + idx); - var labelValueTopic = topicHelper.lightTopic(device.getSerialNumber(), MqttTopicHelper.ColorType.label, idx); + var labelValueTopic = topicHelper.lightTopic(device.getSerialNumber(), ColorType.label, idx); var labelConfig = new HomeAssistantLightConfig( haDevice, availability, @@ -88,9 +93,9 @@ private void addSliderLabelLightConfig(MqttSettings settings, Device device, Hom } private void addAnalogValueConfigs(MqttSettings settings, Device device, HomeAssistantDevice haDevice, @Nullable HomeAssistantAvailability availability) { - for (var i = 0; i < device.getDeviceType().getAnalogCount(); i++) { + for (var i = 0; i < device.deviceType().getAnalogCount(); i++) { var configTopic = configTopicFor(settings, device, "number", "analog", i); - var valueTopic = topicHelper.valueTopic(device.getSerialNumber(), MqttTopicHelper.ValueType.analog, i); + var valueTopic = topicHelper.valueTopic(device.getSerialNumber(), ValueType.analog, i); var config = new HomeAssistantNumberConfig( haDevice, availability, @@ -107,7 +112,7 @@ private void addAnalogValueConfigs(MqttSettings settings, Device device, HomeAss private void addBrightnessDevice(MqttSettings settings, Device device, HomeAssistantDevice haDevice, @Nullable HomeAssistantAvailability availability) { var configTopic = configTopicFor(settings, device, "number", "brightness", 0); - var valueTopic = topicHelper.valueTopic(device.getSerialNumber(), MqttTopicHelper.ValueType.brightness, 0); + var valueTopic = topicHelper.valueTopic(device.getSerialNumber(), ValueType.brightness, 0); var config = new HomeAssistantNumberConfig( haDevice, availability, @@ -122,12 +127,12 @@ private void addBrightnessDevice(MqttSettings settings, Device device, HomeAssis } private void addLogoLight(MqttSettings settings, Device device, HomeAssistantDevice haDevice, @Nullable HomeAssistantAvailability availability) { - if (!device.getDeviceType().isHasLogoLed()) { + if (!device.deviceType().isHasLogoLed()) { return; } var configTopic = lightTopicFor(settings, device, "logo"); - var valueTopic = topicHelper.lightTopic(device.getSerialNumber(), MqttTopicHelper.ColorType.logo, 0); + var valueTopic = topicHelper.lightTopic(device.getSerialNumber(), ColorType.logo, 0); var config = new HomeAssistantLightConfig( haDevice, availability, @@ -140,7 +145,7 @@ private void addLogoLight(MqttSettings settings, Device device, HomeAssistantDev } private void addButtons(MqttSettings settings, Device device, HomeAssistantDevice haDevice, @Nullable HomeAssistantAvailability availability) { - for (var i = 0; i < device.getDeviceType().getButtonCount(); i++) { + for (var i = 0; i < device.deviceType().getButtonCount(); i++) { addButtonUpDown(settings, device, haDevice, availability, i); addButtonEvent(settings, device, haDevice, availability, i); } @@ -148,7 +153,7 @@ private void addButtons(MqttSettings settings, Device device, HomeAssistantDevic private void addButtonUpDown(MqttSettings settings, Device device, HomeAssistantDevice haDevice, @Nullable HomeAssistantAvailability availability, int i) { var configTopic = configTopicFor(settings, device, "binary_sensor", "button", i); - var valueTopic = topicHelper.buttonUpDownTopic(device.getSerialNumber(), MqttTopicHelper.ActionType.button, i); + var valueTopic = topicHelper.buttonUpDownTopic(device.getSerialNumber(), ActionType.button, i); var upDownConfig = new HomeAssistantButtonConfig( haDevice, availability, @@ -161,7 +166,7 @@ private void addButtonUpDown(MqttSettings settings, Device device, HomeAssistant private void addButtonEvent(MqttSettings settings, Device device, HomeAssistantDevice haDevice, @Nullable HomeAssistantAvailability availability, int i) { var eventConfigTopic = configTopicFor(settings, device, "event", "button", i); - var eventTopic = topicHelper.eventTopic(device.getSerialNumber(), MqttTopicHelper.ActionType.button, i); + var eventTopic = topicHelper.eventTopic(device.getSerialNumber(), ActionType.button, i); var eventConfig = new HomeAssistantButtonEventConfig( haDevice, availability, @@ -193,7 +198,7 @@ private String lightTopicFor(MqttSettings settings, Device device, String name) } private String determineAnalogIcon(Device device, int i) { - var buttonCount = device.getDeviceType().getButtonCount(); + var buttonCount = device.deviceType().getButtonCount(); if (i < buttonCount) { return "mdi:knob"; } @@ -201,7 +206,7 @@ private String determineAnalogIcon(Device device, int i) { } private String determineAnalogName(Device device, int i) { - var buttonCount = device.getDeviceType().getButtonCount(); + var buttonCount = device.deviceType().getButtonCount(); if (i < buttonCount) { return "Dial " + (i + 1); } @@ -325,14 +330,15 @@ private HomeAssistantDevice buildDevice(Device device) { version, List.of(device.getSerialNumber()), "PCPanel Holdings, LLC", - device.getDeviceType().getNiceName(), + device.deviceType().getNiceName(), device.getSerialNumber(), device.getSerialNumber(), "Office" ); } - private @Nullable HomeAssistantAvailability buildAvailability(MqttSettings settings) { + @Nullable + private HomeAssistantAvailability buildAvailability(MqttSettings settings) { if (settings.homeAssistant().availability()) { return new HomeAssistantAvailability( topicHelper.availabilityTopic(), diff --git a/src/main/java/com/getpcpanel/mqtt/MqttService.java b/src/main/java/com/getpcpanel/mqtt/MqttService.java index c31acb8f..1dc3d76d 100644 --- a/src/main/java/com/getpcpanel/mqtt/MqttService.java +++ b/src/main/java/com/getpcpanel/mqtt/MqttService.java @@ -13,35 +13,35 @@ import javax.annotation.Nullable; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.event.EventListener; -import org.springframework.core.annotation.Order; -import org.springframework.stereotype.Service; - import com.fasterxml.jackson.databind.ObjectMapper; -import com.getpcpanel.profile.MqttSettings; -import com.getpcpanel.profile.SaveService; +import com.getpcpanel.profile.SaveService.SaveEvent; +import com.getpcpanel.profile.dto.MqttSettings; import com.getpcpanel.util.Debouncer; import com.hivemq.client.mqtt.MqttClient; import com.hivemq.client.mqtt.MqttGlobalPublishFilter; import com.hivemq.client.mqtt.mqtt5.Mqtt5Client; import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5Publish; -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; import lombok.extern.log4j.Log4j2; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public class MqttService { static final int ORDER_OF_SAVE = 0; public static final String IGNORE_CORRELATION = "pcpanel"; - private final SaveService saveService; - private final ApplicationEventPublisher eventPublisher; - private final ObjectMapper objectMapper; - private final Debouncer debouncer; - private final MqttTopicHelper topicHelper; + @Inject + Event eventBus; + @Inject + ObjectMapper objectMapper; + @Inject + Debouncer debouncer; + @Inject + MqttTopicHelper topicHelper; private MqttSettings connectedSettings; @Nullable private Mqtt5Client mqttClient; @@ -140,14 +140,12 @@ private Pattern topicToRegex(String topic) { ); } - @Order(ORDER_OF_SAVE) - @PostConstruct - @EventListener(SaveService.SaveEvent.class) - public void saveChanged() { - var mqttSettings = saveService.get().getMqtt(); + @Priority(ORDER_OF_SAVE) + public void saveChanged(@Observes SaveEvent event) { + var mqttSettings = event.save().getMqtt(); if (mqttSettings == null || !mqttSettings.enabled()) { disconnect(); - eventPublisher.publishEvent(new MqttStatusEvent(false)); + eventBus.fire(new MqttStatusEvent(false)); connectedSettings = MqttSettings.DEFAULT; return; } @@ -158,11 +156,11 @@ public void saveChanged() { log.trace("Save changed, starting mqtt"); connect(mqttSettings); connectedSettings = mqttSettings; - eventPublisher.publishEvent(new MqttStatusEvent(true)); + eventBus.fire(new MqttStatusEvent(true)); } private void connect(MqttSettings mqttSettings) { - var availabilityTopic = topicHelper.availabilityTopic(); + var availabilityTopic = topicHelper.availabilityTopic(mqttSettings); var builder = MqttClient.builder() .identifier(UUID.randomUUID().toString()) .serverHost(mqttSettings.host()) diff --git a/src/main/java/com/getpcpanel/mqtt/MqttTopicHelper.java b/src/main/java/com/getpcpanel/mqtt/MqttTopicHelper.java index ca886132..14f8e0fb 100644 --- a/src/main/java/com/getpcpanel/mqtt/MqttTopicHelper.java +++ b/src/main/java/com/getpcpanel/mqtt/MqttTopicHelper.java @@ -1,19 +1,18 @@ package com.getpcpanel.mqtt; -import org.springframework.stereotype.Service; - -import com.getpcpanel.profile.MqttSettings; import com.getpcpanel.profile.SaveService; +import com.getpcpanel.profile.dto.MqttSettings; -import lombok.RequiredArgsConstructor; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import lombok.extern.log4j.Log4j2; import one.util.streamex.StreamEx; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped class MqttTopicHelper { - private final SaveService saveService; + @Inject + SaveService saveService; public DeviceMqttTopicHelper device(String deviceSerial) { return new DeviceMqttTopicHelper(deviceSerial); @@ -23,6 +22,10 @@ public String availabilityTopic() { return baseJoining("available"); } + public String availabilityTopic(MqttSettings settings) { + return baseJoining(settings, "available"); + } + public String baseTopicFilter() { return baseJoining("#"); } @@ -47,6 +50,10 @@ private String baseJoining(Object... parts) { return StreamEx.of(parts).prepend(getSettings().baseTopic()).joining("/"); } + private String baseJoining(MqttSettings settings, Object... parts) { + return StreamEx.of(parts).prepend(settings.baseTopic()).joining("/"); + } + private MqttSettings getSettings() { return saveService.get().getMqtt(); } @@ -67,10 +74,13 @@ enum ColorType { logo, } - @RequiredArgsConstructor class DeviceMqttTopicHelper { private final String deviceSerial; + DeviceMqttTopicHelper(String deviceSerial) { + this.deviceSerial = deviceSerial; + } + public String valueTopic(ValueType type, int index) { return MqttTopicHelper.this.valueTopic(deviceSerial, type, index); } diff --git a/src/main/java/com/getpcpanel/obs/OBS.java b/src/main/java/com/getpcpanel/obs/OBS.java index ade01086..21277fc4 100644 --- a/src/main/java/com/getpcpanel/obs/OBS.java +++ b/src/main/java/com/getpcpanel/obs/OBS.java @@ -1,362 +1,139 @@ package com.getpcpanel.obs; -import java.net.ConnectException; -import java.net.SocketTimeoutException; -import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import java.util.function.Consumer; -import java.util.function.Function; - -import javax.annotation.Nullable; - -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.math.NumberUtils; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.event.EventListener; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; +import com.fasterxml.jackson.databind.ObjectMapper; import com.getpcpanel.profile.SaveService; -import com.getpcpanel.util.Util; -import io.obswebsocket.community.client.OBSRemoteController; -import io.obswebsocket.community.client.OBSRemoteControllerBuilder; -import io.obswebsocket.community.client.listener.lifecycle.ReasonThrowable; -import io.obswebsocket.community.client.message.event.inputs.InputMuteStateChangedEvent; -import io.obswebsocket.community.client.message.request.RequestBatch; -import io.obswebsocket.community.client.message.request.inputs.GetInputMuteRequest; -import io.obswebsocket.community.client.message.response.RequestBatchResponse; -import io.obswebsocket.community.client.message.response.RequestResponse; -import io.obswebsocket.community.client.message.response.inputs.GetInputMuteResponse; -import io.obswebsocket.community.client.model.Input; -import io.obswebsocket.community.client.model.Scene; +import io.quarkus.scheduler.Scheduled; import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; +import jakarta.inject.Inject; import lombok.extern.log4j.Log4j2; -import one.util.streamex.StreamEx; +/** + * OBS integration via a custom OBS WebSocket 5 client. + * + * Connects automatically when {@code obsEnabled=true} in the user's settings. + * Reconnects every 30 s if the connection is lost. + * Fires {@link OBSConnectEvent} and {@link OBSMuteEvent} CDI events. + */ @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public final class OBS { - private static final long WAIT_TIME_MS = 1000L; - private static final ObsIdHelper OBS_ID_HELPER = new ObsIdHelper(); + private static final long CONNECT_TIMEOUT_MS = 5_000; + + @Inject SaveService save; + @Inject Event connectEvent; + @Inject Event muteEvent; + @Inject ObjectMapper objectMapper; - private final SaveService save; - private final ApplicationEventPublisher eventPublisher; - private List previousSettings = List.of(); - private boolean connected; - private boolean shuttingDown; - @Nullable private OBSRemoteController controller; + private ObsWebSocketClient client; @PostConstruct public void init() { - Runtime.getRuntime().addShutdownHook(new Thread(this::applicationEnding, "OBS Shutdown hook")); + reconnectIfNeeded(); } - @Scheduled(fixedRateString = "${pcpanel.obs.rate:2500}") - public void connect() { - if (!connected && !shuttingDown) { - buildAndConnectObsController(); + @PreDestroy + public void destroy() { + if (client != null) { + client.disconnect(); } } - private void applicationEnding() { - shuttingDown = true; - disconnectController(); - } - - private void buildAndConnectObsController() { - var save = this.save.get(); - if (!save.isObsEnabled() || (connected && !settingsStillSame(false)) || shuttingDown) { - log.trace("Obs is disabled({})/already connected({})/we are shutting down({})", save.isObsEnabled(), connected, shuttingDown); - disconnectController(); + @Scheduled(every = "30s") + public void reconnectIfNeeded() { + var settings = save.get(); + if (!settings.isObsEnabled()) { return; } - - try { - doBuildAndConnectObsController(); - } catch (Exception e) { - doConnected(false); - connected = false; - log.debug("Connecting failed", e); - } - } - - private void doBuildAndConnectObsController() { - var save = this.save.get(); - log.debug("Connecting to OBS"); - if (settingsStillSame(true) && controller != null) { - if (connected) { - return; - } - connected = true; - controller.connect(); + if (client != null && client.isConnected()) { return; } - - disconnectController(); - connected = true; - var port = NumberUtils.toInt(save.getObsPort(), -1); - var address = save.getObsAddress(); - var password = StringUtils.trimToNull(save.getObsPassword()); - - if (port != -1 && StringUtils.isNotBlank(address)) { - var currentIdx = OBS_ID_HELPER.incAndGet(); - controller = buildController(address, port, password).lifecycle() - .onReady(this::connected) - .onDisconnect(() -> OBS_ID_HELPER.runIfIdEq(currentIdx, () -> { - doConnected(false); - connected = false; - })) - .onControllerError(e -> OBS_ID_HELPER.runIfIdEq(currentIdx, () -> onError(e))) - .and() - .registerEventListener(InputMuteStateChangedEvent.class, this::onMuteChanged) - .build(); - controller.connect(); - } else { - doConnected(false); - connected = false; - } - } - - private void onMuteChanged(InputMuteStateChangedEvent t) { - eventPublisher.publishEvent(new OBSMuteEvent(t.getMessageData().getEventData().getInputName(), t.getMessageData().getEventData().getInputMuted())); - } - - private void disconnectController() { - doConnected(false); - connected = false; - if (controller != null) { - controller.disconnect(); - controller.stop(); - controller = null; - } + connect(settings.getObsAddress(), parsePort(settings.getObsPort()), settings.getObsPassword()); } - @Nullable - public String test(String address, int port, String password, long timeout) { - var latch = new CountDownLatch(1); - var result = new String[1]; - Consumer doResult = str -> { - result[0] = str; - latch.countDown(); - }; - - var controller = buildController(address, port, password).lifecycle() - .onReady(() -> doResult.accept(null)) - .onDisconnect(latch::countDown) - .onControllerError(e -> doResult.accept(e.getReason())) - .onCommunicatorError(e -> doResult.accept(e.getReason())) - .onClose(e -> doResult.accept(e.name())) - .and().build(); - controller.connect(); - + private void connect(String host, int port, String password) { try { - var waitSuccess = latch.await(timeout, TimeUnit.MILLISECONDS); - var message = waitSuccess && result[0] == null ? null : result[0]; - controller.disconnect(); - controller.stop(); - return message; - } catch (InterruptedException e) { - log.warn("Unable to wait for the latch"); - } - return null; - } - - private OBSRemoteControllerBuilder buildController(String address, int port, String password) { - return OBSRemoteController.builder() - .autoConnect(false) - .host(address) - .port(port) - .password(password) - .lifecycle() - .withControllerDefaultLogging(false) - .withCommunicatorDefaultLogging(false) - .and(); - } - - private void onError(ReasonThrowable reasonThrowable) { - var exception = reasonThrowable.getThrowable(); - if (exception instanceof ExecutionException exEx) { - exception = exEx.getCause(); - } - - if (exception instanceof SocketTimeoutException || exception instanceof TimeoutException || exception instanceof ConnectException) { - log.debug("Timeout/connect exception occurred", exception); - } else { - log.warn("Unknown OBS error, stack is logged in debug"); - log.debug("Unknown OBS error", exception); + if (client != null) { + client.disconnect(); + } + client = new ObsWebSocketClient(objectMapper, password, + connected -> connectEvent.fire(new OBSConnectEvent(connected)), + event -> muteEvent.fire(event)); + client.connect(host, port, CONNECT_TIMEOUT_MS); + log.info("OBS: connecting to {}:{}", host, port); + } catch (Exception e) { + log.debug("OBS: connection attempt failed: {}", e.getMessage()); } - doConnected(false); - connected = false; } - private boolean settingsStillSame(boolean updatePrevious) { - var port = NumberUtils.toInt(save.get().getObsPort(), -1); - var address = save.get().getObsAddress(); - var password = StringUtils.trimToNull(save.get().getObsPassword()); - var settings = List.of(port, Objects.requireNonNullElse(address, "-"), Objects.requireNonNullElse(password, "-")); - if (settings.equals(previousSettings)) { - return true; - } - if (updatePrevious) { - previousSettings = settings; + private static int parsePort(String port) { + try { + return Integer.parseInt(port); + } catch (NumberFormatException e) { + return 4455; } - return false; } - private void connected() { - log.info("Connected to OBS"); - doConnected(true); - connected = true; - } + // --- Public API used by command classes --- - @EventListener(SaveService.SaveEvent.class) - public void saveUpdated() { - buildAndConnectObsController(); + public boolean isConnected() { + return client != null && client.isConnected(); } public List getSourcesWithAudio() { - var nameToMute = getSourcesWithMuteState(); - return new ArrayList<>(nameToMute.keySet()); + return isConnected() ? client.getSourcesWithAudio() : List.of(); } public Map getSourcesWithMuteState() { - if (!isConnected() || controller == null) { - return Map.of(); - } - var result = controller.getInputList(null, WAIT_TIME_MS); - if (result == null) { - return Map.of(); - } - var sources = result.getInputs(); - return getNameToMuteState(sources); + return isConnected() ? client.getSourcesWithMuteState() : Map.of(); } public List getScenes() { - if (!isConnected() || controller == null) { - return List.of(); - } - - return Optional.ofNullable(controller.getSceneList(WAIT_TIME_MS)) - .map(ss -> StreamEx.of(ss.getScenes()).map(Scene::getSceneName).toList()) - .orElse(List.of()); + return isConnected() ? client.getScenes() : List.of(); } public void setSourceVolume(String sourceName, int vol) { - if (!isConnected() || controller == null) { - return; - } - try { - var decimal = (float) Util.map(vol, 0, 100, -97, 0); - controller.setInputVolume(sourceName, null, decimal, WAIT_TIME_MS); - } catch (Exception e) { - log.error("Unable to get source volume", e); + if (isConnected()) { + client.setSourceVolume(sourceName, vol); } } public void toggleSourceMute(String sourceName) { - if (!isConnected() || controller == null) { - return; - } - try { - controller.toggleInputMute(sourceName, WAIT_TIME_MS); - } catch (Exception e) { - log.error("Unable to toggle source mute {}", sourceName, e); + if (isConnected()) { + client.toggleSourceMute(sourceName); } } public void setSourceMute(String sourceName, boolean mute) { - if (!isConnected() || controller == null) { - return; - } - try { - controller.setInputMute(sourceName, mute, WAIT_TIME_MS); - } catch (Exception e) { - log.error("Unable to set source mute {} {}", sourceName, mute, e); + if (isConnected()) { + client.setSourceMute(sourceName, mute); } } public void setCurrentScene(String sceneName) { - if (!isConnected() || controller == null) { - return; - } - try { - controller.setCurrentProgramScene(sceneName, WAIT_TIME_MS); - } catch (Exception e) { - log.error("Unable to set current scene to {}", sceneName, e); + if (isConnected()) { + client.setCurrentScene(sceneName); } } - public boolean isConnected() { - return save.get().isObsEnabled() && controller != null && connected; - } - - private void doConnected(boolean connected) { - new Thread(() -> { - eventPublisher.publishEvent(new OBSConnectEvent(connected)); - if (connected) { - getSourcesWithMuteState(); - } - }).start(); - } - - private Map getNameToMuteState(List sources) { - if (controller == null) { - return Map.of(); - } - record RequestAndName(GetInputMuteRequest request, String name) { - } - - var muteRequests = StreamEx.of(sources) - .map(source -> { - var req = GetInputMuteRequest.builder().inputName(source.getInputName()).build(); - return new RequestAndName(req, source.getInputName()); - }) - .mapToEntry(rn -> rn.request.getRequestId(), Function.identity()) - .toMap(); - - var latch = new ArrayBlockingQueue(1); - controller.sendRequestBatch(RequestBatch.builder().requests(StreamEx.ofValues(muteRequests).map(RequestAndName::request).toList()).build(), latch::offer); - + /** Returns null on success, or an error message on failure. */ + public String test(String address, int port, String password, long timeout) { + var tester = new ObsWebSocketClient(objectMapper, password, c -> {}, e -> {}); try { - var result = latch.poll(WAIT_TIME_MS, TimeUnit.MILLISECONDS); - if (result == null) { - return Map.of(); - } - return StreamEx.of(result.getData().getResults()) - .mapToEntry(rs -> muteRequests.get(rs.getRequestId()), RequestResponse.Data::getResponseData) - .nonNullKeys().mapKeys(rn -> rn.name) - .nonNullValues() - .selectValues(GetInputMuteResponse.SpecificData.class) - .mapValues(GetInputMuteResponse.SpecificData::getInputMuted) - .toMap(); - } catch (InterruptedException e) { - return Map.of(); - } - } - - static class ObsIdHelper { - private int activeIdx; - - private int incAndGet() { - activeIdx++; - return activeIdx; - } - - private void runIfIdEq(int id, Runnable toRun) { - if (activeIdx == id) { - toRun.run(); - } + tester.connect(address, port, timeout); + Thread.sleep(500); // allow hello/identify exchange + return tester.isConnected() ? null : "Connected but not authenticated"; + } catch (Exception e) { + return e.getMessage(); + } finally { + tester.disconnect(); } } } + diff --git a/src/main/java/com/getpcpanel/obs/ObsConnectedVolumeService.java b/src/main/java/com/getpcpanel/obs/ObsConnectedVolumeService.java index 00e0f57c..4570c183 100644 --- a/src/main/java/com/getpcpanel/obs/ObsConnectedVolumeService.java +++ b/src/main/java/com/getpcpanel/obs/ObsConnectedVolumeService.java @@ -1,27 +1,22 @@ package com.getpcpanel.obs; import java.util.function.Function; - -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; - -import com.getpcpanel.commands.AbstractNewXVolumeService; import com.getpcpanel.commands.command.CommandObsSetSourceVolume; import com.getpcpanel.hid.DeviceHolder; -import com.getpcpanel.spring.ConditionalOnWindows; +import com.getpcpanel.platform.WindowsBuild; -@Service -@ConditionalOnWindows -public class ObsConnectedVolumeService extends AbstractNewXVolumeService { - public ObsConnectedVolumeService(DeviceHolder devices, ApplicationEventPublisher eventPublisher) { - super(devices, eventPublisher); - } +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; + +@ApplicationScoped +@WindowsBuild +public class ObsConnectedVolumeService { + @Inject DeviceHolder devices; - @EventListener - public void onVoiceMeeterConnected(OBSConnectEvent event) { + public void onVoiceMeeterConnected(@Observes OBSConnectEvent event) { if (event.connected()) { - triggerCommandsOf(CommandObsSetSourceVolume.class, Function.identity()); + devices.triggerCommandsOf(CommandObsSetSourceVolume.class, Function.identity()); } } } diff --git a/src/main/java/com/getpcpanel/obs/ObsWebSocketClient.java b/src/main/java/com/getpcpanel/obs/ObsWebSocketClient.java new file mode 100644 index 00000000..bae473cd --- /dev/null +++ b/src/main/java/com/getpcpanel/obs/ObsWebSocketClient.java @@ -0,0 +1,284 @@ +package com.getpcpanel.obs; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import lombok.extern.log4j.Log4j2; + +/** + * OBS WebSocket protocol 5 client built on java.net.http.WebSocket. + * + * Handles: hello/identify handshake, optional SHA-256 password auth, + * request/response correlation, InputMuteStateChanged events. + */ +@Log4j2 +public class ObsWebSocketClient implements WebSocket.Listener { + + // OBS WebSocket 5 opcodes + private static final int OP_HELLO = 0; + private static final int OP_IDENTIFY = 1; + private static final int OP_IDENTIFIED = 2; + private static final int OP_EVENT = 5; + private static final int OP_REQUEST = 6; + private static final int OP_REQUEST_RESPONSE = 7; + + // EventSubscriptions bit: Inputs (for InputMuteStateChanged) + private static final int EVENT_SUB_INPUTS = 1 << 3; + + private final ObjectMapper mapper; + private final String password; + private final Consumer onConnected; + private final Consumer onMuteChange; + + private WebSocket webSocket; + private final ConcurrentHashMap> pending = new ConcurrentHashMap<>(); + private final StringBuilder textBuffer = new StringBuilder(); + private volatile boolean connected = false; + + public ObsWebSocketClient(ObjectMapper mapper, String password, + Consumer onConnected, Consumer onMuteChange) { + this.mapper = mapper; + this.password = password; + this.onConnected = onConnected; + this.onMuteChange = onMuteChange; + } + + public void connect(String host, int port, long timeoutMs) throws Exception { + var uri = URI.create("ws://" + host + ":" + port); + webSocket = HttpClient.newHttpClient() + .newWebSocketBuilder() + .buildAsync(uri, this) + .get(timeoutMs, TimeUnit.MILLISECONDS); + } + + public void disconnect() { + connected = false; + if (webSocket != null) { + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "bye"); + } + } + + public boolean isConnected() { + return connected; + } + + // --- WebSocket.Listener --- + + @Override + public CompletionStage> onText(WebSocket ws, CharSequence data, boolean last) { + textBuffer.append(data); + ws.request(1); + if (!last) { + return null; + } + var text = textBuffer.toString(); + textBuffer.setLength(0); + try { + handleMessage(mapper.readTree(text)); + } catch (Exception e) { + log.warn("OBS: failed to handle message", e); + } + return null; + } + + @Override + public CompletionStage> onClose(WebSocket ws, int statusCode, String reason) { + if (connected) { + connected = false; + onConnected.accept(false); + } + return null; + } + + @Override + public void onError(WebSocket ws, Throwable error) { + log.warn("OBS WebSocket error: {}", error.getMessage()); + if (connected) { + connected = false; + onConnected.accept(false); + } + pending.values().forEach(f -> f.completeExceptionally(error)); + pending.clear(); + } + + // --- Protocol handling --- + + private void handleMessage(JsonNode msg) throws Exception { + var op = msg.path("op").asInt(-1); + var d = msg.path("d"); + switch (op) { + case OP_HELLO -> identify(d); + case OP_IDENTIFIED -> { + connected = true; + log.info("OBS: connected and authenticated"); + onConnected.accept(true); + } + case OP_EVENT -> handleEvent(d); + case OP_REQUEST_RESPONSE -> { + var id = d.path("requestId").asText(null); + var future = id != null ? pending.remove(id) : null; + if (future != null) { + future.complete(d.path("responseData")); + } + } + default -> log.trace("OBS: unhandled opcode {}", op); + } + } + + private void identify(JsonNode hello) throws Exception { + var msg = mapper.createObjectNode(); + msg.put("op", OP_IDENTIFY); + var data = msg.putObject("d"); + data.put("rpcVersion", 1); + data.put("eventSubscriptions", EVENT_SUB_INPUTS); + var authNode = hello.path("authentication"); + if (!authNode.isMissingNode() && !authNode.isNull() && password != null && !password.isBlank()) { + data.put("authentication", computeAuth(password, + authNode.path("salt").asText(), + authNode.path("challenge").asText())); + } + send(msg); + } + + private void handleEvent(JsonNode d) { + var type = d.path("eventType").asText(); + if ("InputMuteStateChanged".equals(type)) { + var data = d.path("eventData"); + onMuteChange.accept(new OBSMuteEvent( + data.path("inputName").asText(), + data.path("inputMuted").asBoolean())); + } + } + + private static String computeAuth(String password, String salt, String challenge) throws Exception { + var md = MessageDigest.getInstance("SHA-256"); + var secret = Base64.getEncoder().encodeToString( + md.digest((password + salt).getBytes(StandardCharsets.UTF_8))); + md.reset(); + return Base64.getEncoder().encodeToString( + md.digest((secret + challenge).getBytes(StandardCharsets.UTF_8))); + } + + private void send(Object obj) { + try { + webSocket.sendText(mapper.writeValueAsString(obj), true); + } catch (Exception e) { + log.warn("OBS: failed to send message", e); + } + } + + private JsonNode request(String type, ObjectNode fields) throws Exception { + var id = UUID.randomUUID().toString(); + var msg = mapper.createObjectNode(); + msg.put("op", OP_REQUEST); + var d = msg.putObject("d"); + d.put("requestType", type); + d.put("requestId", id); + if (fields != null) { + d.set("requestData", fields); + } + var future = new CompletableFuture(); + pending.put(id, future); + send(msg); + return future.get(5, TimeUnit.SECONDS); + } + + // --- High-level OBS operations --- + + public List getSourcesWithAudio() { + try { + var resp = request("GetInputList", null); + var list = new ArrayList(); + resp.path("inputs").forEach(n -> list.add(n.path("inputName").asText())); + return list; + } catch (Exception e) { + log.warn("OBS: GetInputList failed: {}", e.getMessage()); + return List.of(); + } + } + + public Map getSourcesWithMuteState() { + var map = new LinkedHashMap(); + for (var source : getSourcesWithAudio()) { + try { + var fields = mapper.createObjectNode().put("inputName", source); + var resp = request("GetInputMute", fields); + map.put(source, resp.path("inputMuted").asBoolean()); + } catch (Exception e) { + log.trace("OBS: GetInputMute failed for {}: {}", source, e.getMessage()); + } + } + return map; + } + + public List getScenes() { + try { + var resp = request("GetSceneList", null); + var list = new ArrayList(); + resp.path("scenes").forEach(n -> list.add(n.path("sceneName").asText())); + return list; + } catch (Exception e) { + log.warn("OBS: GetSceneList failed: {}", e.getMessage()); + return List.of(); + } + } + + /** vol is 0–100; converted to OBS volume multiplier 0.0–1.0. */ + public void setSourceVolume(String sourceName, int vol) { + try { + var fields = mapper.createObjectNode() + .put("inputName", sourceName) + .put("inputVolumeMultiplier", vol / 100.0); + request("SetInputVolume", fields); + } catch (Exception e) { + log.warn("OBS: SetInputVolume failed: {}", e.getMessage()); + } + } + + public void toggleSourceMute(String sourceName) { + try { + var fields = mapper.createObjectNode().put("inputName", sourceName); + request("ToggleInputMute", fields); + } catch (Exception e) { + log.warn("OBS: ToggleInputMute failed: {}", e.getMessage()); + } + } + + public void setSourceMute(String sourceName, boolean mute) { + try { + var fields = mapper.createObjectNode() + .put("inputName", sourceName) + .put("inputMuted", mute); + request("SetInputMute", fields); + } catch (Exception e) { + log.warn("OBS: SetInputMute failed: {}", e.getMessage()); + } + } + + public void setCurrentScene(String sceneName) { + try { + var fields = mapper.createObjectNode().put("sceneName", sceneName); + request("SetCurrentProgramScene", fields); + } catch (Exception e) { + log.warn("OBS: SetCurrentProgramScene failed: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/com/getpcpanel/osc/OSCService.java b/src/main/java/com/getpcpanel/osc/OSCService.java index 0b01a9c6..77e498c0 100644 --- a/src/main/java/com/getpcpanel/osc/OSCService.java +++ b/src/main/java/com/getpcpanel/osc/OSCService.java @@ -10,14 +10,11 @@ import javax.annotation.Nonnull; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; -import org.springframework.util.CollectionUtils; - -import com.getpcpanel.hid.DeviceCommunicationHandler; -import com.getpcpanel.profile.OSCBinding; -import com.getpcpanel.profile.OSCConnectionInfo; +import com.getpcpanel.hid.DeviceCommunicationHandler.ButtonPressEvent; +import com.getpcpanel.hid.DeviceCommunicationHandler.KnobRotateEvent; import com.getpcpanel.profile.SaveService; +import com.getpcpanel.profile.dto.OSCBinding; +import com.getpcpanel.profile.dto.OSCConnectionInfo; import com.getpcpanel.util.Util; import com.illposed.osc.OSCBadDataEvent; import com.illposed.osc.OSCBundle; @@ -29,16 +26,18 @@ import com.illposed.osc.transport.OSCPortOut; import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; import lombok.Getter; -import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import one.util.streamex.StreamEx; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public class OSCService { - private final SaveService saveService; + @Inject + SaveService saveService; private OSCPortIn portIn; private List ports = List.of(); private Integer prevListenPort; @@ -46,7 +45,6 @@ public class OSCService { @Getter private final Set addresses = new HashSet<>(); @PostConstruct - @EventListener(SaveService.SaveEvent.class) public void saveChanged() { log.trace("Save changed, restarting OSC"); initSend(); @@ -105,14 +103,13 @@ private void stopPortIn() { } } - @EventListener - public void dialAction(DeviceCommunicationHandler.KnobRotateEvent dial) { - if (dial.initial() || CollectionUtils.isEmpty(ports)) { + public void dialAction(@Observes KnobRotateEvent dial) { + if (dial.initial() || ports == null || ports.isEmpty()) { return; } saveService.getProfile(dial.serialNum()).ifPresent(profile -> { - var knobLength = profile.getLightingConfig().getKnobConfigs().length; + var knobLength = profile.lightingConfig().knobConfigs().length; var idx = dial.knob() < knobLength ? dial.knob() * 2 : dial.knob() + knobLength; var target = profile.getOscBinding().get(idx); @@ -122,9 +119,8 @@ public void dialAction(DeviceCommunicationHandler.KnobRotateEvent dial) { }); } - @EventListener - public void dialAction(DeviceCommunicationHandler.ButtonPressEvent button) { - if (CollectionUtils.isEmpty(ports)) { + public void dialAction(@Observes ButtonPressEvent button) { + if (ports == null || ports.isEmpty()) { return; } var idx = button.button() * 2 + 1; @@ -152,7 +148,8 @@ private float determineValue(@Nonnull OSCBinding target, float val) { return Util.map(val, 0, 1, target.min(), target.max()); } - private static @Nonnull OSCMessage buildMessage(OSCBinding target, String defaultTarget, float val) { + @Nonnull + private static OSCMessage buildMessage(OSCBinding target, String defaultTarget, float val) { var targetString = target == null ? defaultTarget : target.address(); try { return new OSCMessage(targetString, List.of(val)); diff --git a/src/main/java/com/getpcpanel/overlay/Overlay.java b/src/main/java/com/getpcpanel/overlay/Overlay.java new file mode 100644 index 00000000..aa6a72bc --- /dev/null +++ b/src/main/java/com/getpcpanel/overlay/Overlay.java @@ -0,0 +1,122 @@ +package com.getpcpanel.overlay; + +import java.awt.Image; +import java.awt.Toolkit; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import javax.swing.SwingUtilities; + +import com.getpcpanel.commands.Commands; +import com.getpcpanel.commands.IconService; +import com.getpcpanel.commands.PCPanelControlEvent; +import com.getpcpanel.commands.command.ButtonAction; +import com.getpcpanel.commands.command.DialAction; +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.profile.SaveService.SaveEvent; +import com.getpcpanel.profile.dto.OverlayPosition; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import lombok.RequiredArgsConstructor; +import one.util.streamex.StreamEx; + +@ApplicationScoped +@RequiredArgsConstructor +public class Overlay { + private final SaveService save; + private final IconService iconService; + private VolumeOverlay overlay = new VolumeOverlay(); + + // @PostConstruct + // public void init() { + // SwingUtilities.invokeLater(() -> { + // overlay; + // }); + // } + + public void updateSaveValues(@Observes SaveEvent event) { + updateStyle(null); + determinePosition(); + } + + private void determinePosition() { + var window = Toolkit.getDefaultToolkit().getScreenSize(); + var x = window.width; + var y = window.height; + var width = overlay.getWidth(); + var height = overlay.getHeight(); + + var position = save == null ? OverlayPosition.topLeft : save.get().getOverlayPosition(); + var padding = save == null ? 0 : save.get().getOverlayPadding(); + var newY = switch (position) { + case topLeft, topMiddle, topRight -> padding; + case middleLeft, middleMiddle, middleRight -> y / 2 - height / 2; + case bottomLeft, bottomMiddle, bottomRight -> y - overlay.getHeight() - padding; + }; + var newX = switch (position) { + case topLeft, middleLeft, bottomLeft -> padding; + case topMiddle, middleMiddle, bottomMiddle -> x / 2 - width / 2; + case topRight, middleRight, bottomRight -> x - width - padding; + }; + setXY(newX, newY); + } + + private void setXY(int x, int y) { + var b = overlay.getBounds(); + b.x = x; + b.y = y; + overlay.setBounds(b); + } + + public void show(float value) { + showDebounced(value, () -> CommandAndIcon.DEFAULT, x -> true); + } + + public void updateStyle(@Nullable @Observes SaveEvent event) { + SwingUtilities.invokeLater(() -> overlay.setStyles(save.get())); + } + + public void handleControl(@Observes PCPanelControlEvent event) { + if (event.initial()) { + return; + } + var vol = event.vol(); + var value = vol == null ? -1 : save.get().isOverlayUseLog() ? vol.getValue(null, 0, 1) : vol.value() / 255f; + showDebounced(value, () -> determineIconImage(event), command -> true); + } + + private void showDebounced(float value, Supplier pre, Predicate pred) { + if (!save.get().isOverlayEnabled()) { + return; + } + SwingUtilities.invokeLater(() -> { + var cai = pre.get(); + if (hasOverlay(cai.command) && pred.test(cai.command)) { + overlay.show(value, cai.icon); + } + }); + } + + private boolean hasOverlay(Commands commands) { + return Commands.hasCommands(commands) && + StreamEx.of(commands.getCommands()).anyMatch(command -> command instanceof DialAction da && da.hasOverlay() + || command instanceof ButtonAction ba && ba.hasOverlay()); + } + + @Nonnull + private CommandAndIcon determineIconImage(PCPanelControlEvent event) { + return save.getProfile(event.serialNum()).map(profile -> { + var data = event.cmd(); + var setting = event.vol() == null ? null : profile.getKnobSettings(event.knob()); + return new CommandAndIcon(data, iconService.getImageFrom(data, setting)); + }).orElse(CommandAndIcon.DEFAULT); + } + + private record CommandAndIcon(Commands command, Image icon) { + static final CommandAndIcon DEFAULT = new CommandAndIcon(Commands.EMPTY, null); + } +} diff --git a/src/main/java/com/getpcpanel/overlay/VolumeOverlay.java b/src/main/java/com/getpcpanel/overlay/VolumeOverlay.java new file mode 100644 index 00000000..8e9fe608 --- /dev/null +++ b/src/main/java/com/getpcpanel/overlay/VolumeOverlay.java @@ -0,0 +1,265 @@ +package com.getpcpanel.overlay; + +import java.awt.Color; +import java.awt.Font; +import java.awt.GradientPaint; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.RenderingHints; +import java.awt.Toolkit; +import java.awt.geom.Ellipse2D; +import java.awt.geom.RoundRectangle2D; +import java.util.regex.Pattern; + +import javax.swing.JPanel; +import javax.swing.JWindow; +import javax.swing.Timer; +import javax.swing.UIManager; + +import com.getpcpanel.profile.Save; + +public class VolumeOverlay extends JWindow { + // Install the cross-platform (Metal) Look and Feel before any Swing + // component is constructed. This static block runs when VolumeOverlay is + // first loaded – before the implicit JWindow() super-constructor call – + // so JPanel / JRootPane can find their ComponentUI delegates. + static { + try { + UIManager.setLookAndFeel(UIManager.getCrossPlatformLookAndFeelClassName()); + } catch (Exception ignored) { + // If Metal L&F is unavailable in native image, swallow and continue. + } + } + + private static final int WIDTH = 340; + private static final int DEFAULT_HEIGHT = 56; + private static final int DEFAULT_CORNER_RADIUS = 28; + private static final int CONTENT_PADDING = 10; + private static final int ICON_SIZE = 36; + private static final int DEFAULT_BAR_HEIGHT = 10; + private static final int DEFAULT_BAR_CORNER_RADIUS = DEFAULT_BAR_HEIGHT; + private static final int VALUE_LABEL_WIDTH = 36; + private static final int VALUE_GAP = 8; + private static final int DISMISS_MS = 2000; // auto-hide after 2 s + private static final Pattern RGB_PATTERN = Pattern.compile("rgba?\\(([^)]+)\\)", Pattern.CASE_INSENSITIVE); + private static final Pattern COLOR_COMPONENT_SEPARATOR = Pattern.compile("\\s*,\\s*"); + + private static final Color DEFAULT_BG_COLOR = new Color(80, 80, 90, 210); + private static final Color DEFAULT_BAR_COLOR = new Color(0, 200, 230, 255); + private static final Color DEFAULT_BAR_TRACK_COLOR = new Color(255, 255, 255, 50); + private static final Color DEFAULT_TEXT_COLOR = new Color(230, 230, 230, 255); + + private int value; + private final Timer dismissTimer; + private Image icon; + private boolean showNumber = true; + private int windowCornerRadius = DEFAULT_CORNER_RADIUS; + private int barHeight = DEFAULT_BAR_HEIGHT; + private int barCornerRadius = DEFAULT_BAR_CORNER_RADIUS; + private Color backgroundColor = DEFAULT_BG_COLOR; + private Color barColor = DEFAULT_BAR_COLOR; + private Color barTrackColor = DEFAULT_BAR_TRACK_COLOR; + private Color textColor = DEFAULT_TEXT_COLOR; + + VolumeOverlay() { + setAlwaysOnTop(true); + setSize(WIDTH, DEFAULT_HEIGHT); + setBackground(new Color(0, 0, 0, 0)); + + JPanel panel = new OverlayPanel(); + panel.setOpaque(false); + setContentPane(panel); + + var screen = Toolkit.getDefaultToolkit().getScreenSize(); + setLocation((screen.width - WIDTH) / 2, 48); + + dismissTimer = new Timer(DISMISS_MS, _ -> setVisible(false)); + dismissTimer.setRepeats(false); + } + + public void show(float v, Image icon) { + this.icon = icon; + update(Math.round(v * 100f)); + } + + public void setStyles(Save save) { + showNumber = save.isOverlayShowNumber(); + backgroundColor = parseColor(save.getOverlayBackgroundColor(), DEFAULT_BG_COLOR); + textColor = parseColor(save.getOverlayTextColor(), DEFAULT_TEXT_COLOR); + barColor = parseColor(save.getOverlayBarColor(), DEFAULT_BAR_COLOR); + barTrackColor = parseColor(save.getOverlayBarBackgroundColor(), DEFAULT_BAR_TRACK_COLOR); + windowCornerRadius = Math.max(0, save.getOverlayWindowCornerRounding()); + barHeight = Math.max(2, save.getOverlayBarHeight()); + barCornerRadius = Math.max(0, save.getOverlayBarCornerRounding()); + + var computedHeight = Math.max(DEFAULT_HEIGHT, CONTENT_PADDING * 2 + Math.max(ICON_SIZE, barHeight)); + setSize(WIDTH, computedHeight); + revalidate(); + repaint(); + } + + private void update(int v) { + value = Math.clamp(v, 0, 100); + repaint(); + setVisible(true); + dismissTimer.restart(); + } + + private class OverlayPanel extends JPanel { + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + var g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_LCD_HRGB); + + var w = getWidth(); + var h = getHeight(); + var windowArc = Math.min(windowCornerRadius, Math.min(w, h)); + var barArc = Math.min(barCornerRadius, barHeight); + + // ── Background pill ────────────────────────────────────────── + g2.setColor(backgroundColor); + g2.fill(new RoundRectangle2D.Float(0, 0, w, h, windowArc, windowArc)); + + // Subtle top highlight (glass shimmer) + var gloss = new GradientPaint( + 0, 0, withAlpha(Color.WHITE, Math.clamp(backgroundColor.getAlpha() / 4, 18, 60)), + 0, h / 2f, withAlpha(Color.WHITE, 0)); + g2.setPaint(gloss); + g2.fill(new RoundRectangle2D.Float(0, 0, w, h / 2f, windowArc, windowArc)); + + // ── Icon area ──────────────────────────────────────────────── + var iconX = CONTENT_PADDING + 2; + var iconY = (h - ICON_SIZE) / 2; + if (icon != null) { + g2.drawImage(icon, iconX, iconY, ICON_SIZE, ICON_SIZE, null); + } + + // ── Layout constants ───────────────────────────────────────── + var afterIcon = iconX + ICON_SIZE + CONTENT_PADDING; + var valueWidth = showNumber ? VALUE_LABEL_WIDTH : 0; + var barEndX = w - CONTENT_PADDING - valueWidth - (showNumber ? VALUE_GAP : 0); + var barY = (h - barHeight) / 2; + var barWidth = barEndX - afterIcon; + + // ── Progress bar track ─────────────────────────────────────── + g2.setColor(barTrackColor); + g2.fill(new RoundRectangle2D.Float(afterIcon, barY, barWidth, barHeight, barArc, barArc)); + + // ── Progress bar fill ──────────────────────────────────────── + var fillWidth = Math.round(barWidth * (value / 100f)); + if (fillWidth > 0) { + var fillGrad = new GradientPaint( + afterIcon, 0, scaleColor(barColor, 1.15f), + afterIcon + fillWidth, 0, scaleColor(barColor, 0.82f)); + g2.setPaint(fillGrad); + g2.fill(new RoundRectangle2D.Float(afterIcon, barY, fillWidth, barHeight, barArc, barArc)); + + // Bright leading cap + if (fillWidth >= barHeight) { + g2.setColor(withAlpha(scaleColor(barColor, 1.35f), Math.clamp(barColor.getAlpha(), 120, 220))); + var capX = afterIcon + fillWidth - barHeight; + g2.fill(new Ellipse2D.Float(capX, barY, barHeight, barHeight)); + } + } + + // ── Value label ────────────────────────────────────────────── + if (showNumber) { + g2.setColor(textColor); + g2.setFont(new Font("SF Pro Display", Font.BOLD, 16)); + // Fallback font chain + if (!g2.getFont().getFamily().equals("SF Pro Display")) { + g2.setFont(new Font("Segoe UI", Font.BOLD, 16)); + } + var label = String.valueOf(value); + var fm = g2.getFontMetrics(); + var labelX = w - CONTENT_PADDING - valueWidth + (valueWidth - fm.stringWidth(label)) / 2; + var labelY = (h + fm.getAscent() - fm.getDescent()) / 2; + g2.drawString(label, labelX, labelY); + } + + g2.dispose(); + } + } + + private static Color parseColor(String value, Color fallback) { + if (value == null || value.isBlank()) { + return fallback; + } + + try { + var trimmed = value.trim(); + if (trimmed.startsWith("#")) { + return parseHexColor(trimmed); + } + + var matcher = RGB_PATTERN.matcher(trimmed); + if (matcher.matches()) { + var parts = COLOR_COMPONENT_SEPARATOR.split(matcher.group(1)); + if (parts.length == 3 || parts.length == 4) { + var red = clampChannel(Integer.parseInt(parts[0])); + var green = clampChannel(Integer.parseInt(parts[1])); + var blue = clampChannel(Integer.parseInt(parts[2])); + var alpha = parts.length == 4 ? parseAlpha(parts[3]) : 255; + return new Color(red, green, blue, alpha); + } + } + } catch (RuntimeException ignored) { + // Fall back to default styling for invalid persisted values. + } + + return fallback; + } + + private static Color parseHexColor(String value) { + var hex = value.substring(1); + return switch (hex.length()) { + case 3 -> new Color( + Integer.parseInt(hex.substring(0, 1).repeat(2), 16), + Integer.parseInt(hex.substring(1, 2).repeat(2), 16), + Integer.parseInt(hex.substring(2, 3).repeat(2), 16)); + case 4 -> new Color( + Integer.parseInt(hex.substring(0, 1).repeat(2), 16), + Integer.parseInt(hex.substring(1, 2).repeat(2), 16), + Integer.parseInt(hex.substring(2, 3).repeat(2), 16), + Integer.parseInt(hex.substring(3, 4).repeat(2), 16)); + case 6 -> new Color( + Integer.parseInt(hex.substring(0, 2), 16), + Integer.parseInt(hex.substring(2, 4), 16), + Integer.parseInt(hex.substring(4, 6), 16)); + case 8 -> new Color( + Integer.parseInt(hex.substring(0, 2), 16), + Integer.parseInt(hex.substring(2, 4), 16), + Integer.parseInt(hex.substring(4, 6), 16), + Integer.parseInt(hex.substring(6, 8), 16)); + default -> throw new IllegalArgumentException("Unsupported color format: " + value); + }; + } + + private static int parseAlpha(String value) { + var alpha = Double.parseDouble(value); + return clampChannel(roundToInt(alpha <= 1 ? alpha * 255 : alpha)); + } + + private static int roundToInt(double value) { + return Long.valueOf(Math.round(value)).intValue(); + } + + private static int clampChannel(int value) { + return Math.clamp(value, 0, 255); + } + + private static Color withAlpha(Color color, int alpha) { + return new Color(color.getRed(), color.getGreen(), color.getBlue(), clampChannel(alpha)); + } + + private static Color scaleColor(Color color, float factor) { + return new Color( + clampChannel(Math.round(color.getRed() * factor)), + clampChannel(Math.round(color.getGreen() * factor)), + clampChannel(Math.round(color.getBlue() * factor)), + color.getAlpha()); + } +} diff --git a/src/main/java/com/getpcpanel/platform/LinuxBuild.java b/src/main/java/com/getpcpanel/platform/LinuxBuild.java new file mode 100644 index 00000000..52690ac5 --- /dev/null +++ b/src/main/java/com/getpcpanel/platform/LinuxBuild.java @@ -0,0 +1,19 @@ +package com.getpcpanel.platform; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import io.quarkus.arc.properties.IfBuildProperty; +import jakarta.enterprise.inject.Stereotype; + +@Stereotype +@IfBuildProperty(name = "pcpanel.build.os", stringValue = "linux") +@Retention(RUNTIME) +@Target(TYPE) +public @interface LinuxBuild { +} + + diff --git a/src/main/java/com/getpcpanel/platform/WindowsBuild.java b/src/main/java/com/getpcpanel/platform/WindowsBuild.java new file mode 100644 index 00000000..13319075 --- /dev/null +++ b/src/main/java/com/getpcpanel/platform/WindowsBuild.java @@ -0,0 +1,18 @@ +package com.getpcpanel.platform; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import io.quarkus.arc.properties.IfBuildProperty; +import jakarta.enterprise.inject.Stereotype; + +@Stereotype +@IfBuildProperty(name = "pcpanel.build.os", stringValue = "windows") +@Retention(RUNTIME) +@Target(TYPE) +public @interface WindowsBuild { +} + diff --git a/src/main/java/com/getpcpanel/profile/DeviceSave.java b/src/main/java/com/getpcpanel/profile/DeviceSave.java index 10411cf4..9dc517e8 100644 --- a/src/main/java/com/getpcpanel/profile/DeviceSave.java +++ b/src/main/java/com/getpcpanel/profile/DeviceSave.java @@ -46,14 +46,14 @@ public Optional getProfile(@Nullable String name) { if (name == null) { return Optional.empty(); } - return StreamEx.of(getProfiles()).findFirst(p -> p.getName().equals(name)); + return StreamEx.of(profiles).findFirst(p -> p.getName().equals(name)); } @JsonIgnore private Optional getCurrentProfile() { var p = getProfile(currentProfileName); if (!profiles.isEmpty() && p.isEmpty()) { - return Optional.of(getProfiles().get(0)); + return Optional.of(profiles.get(0)); } return p; } diff --git a/src/main/java/com/getpcpanel/profile/KnobSettingMapDeserializer.java b/src/main/java/com/getpcpanel/profile/KnobSettingMapDeserializer.java index 207c60b1..3f214743 100644 --- a/src/main/java/com/getpcpanel/profile/KnobSettingMapDeserializer.java +++ b/src/main/java/com/getpcpanel/profile/KnobSettingMapDeserializer.java @@ -9,6 +9,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; +import com.getpcpanel.profile.dto.KnobSetting; public class KnobSettingMapDeserializer extends JsonDeserializer> { @Override diff --git a/src/main/java/com/getpcpanel/profile/LightingChangedToDefaultEvent.java b/src/main/java/com/getpcpanel/profile/LightingChangedToDefaultEvent.java new file mode 100644 index 00000000..e356d4eb --- /dev/null +++ b/src/main/java/com/getpcpanel/profile/LightingChangedToDefaultEvent.java @@ -0,0 +1,8 @@ +package com.getpcpanel.profile; + +/** + * Fired when lighting is changed back to its default/profile setting. + * Moved from com.getpcpanel.ui to profile package as part of Quarkus migration. + */ +public record LightingChangedToDefaultEvent(String serialNum) { +} diff --git a/src/main/java/com/getpcpanel/profile/Profile.java b/src/main/java/com/getpcpanel/profile/Profile.java index 42196830..8176e6c8 100644 --- a/src/main/java/com/getpcpanel/profile/Profile.java +++ b/src/main/java/com/getpcpanel/profile/Profile.java @@ -9,6 +9,9 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.getpcpanel.commands.Commands; import com.getpcpanel.device.DeviceType; +import com.getpcpanel.profile.dto.KnobSetting; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.profile.dto.OSCBinding; import lombok.Data; @@ -34,7 +37,7 @@ public Profile(String name, DeviceType dt) { protected Profile() { } - public LightingConfig getLightingConfig() { + public LightingConfig lightingConfig() { return lightingConfig.deepCopy(); } diff --git a/src/main/java/com/getpcpanel/profile/ProfileWindowFocusService.java b/src/main/java/com/getpcpanel/profile/ProfileWindowFocusService.java index ffb4064e..fc95107c 100644 --- a/src/main/java/com/getpcpanel/profile/ProfileWindowFocusService.java +++ b/src/main/java/com/getpcpanel/profile/ProfileWindowFocusService.java @@ -1,21 +1,21 @@ package com.getpcpanel.profile; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; +import jakarta.inject.Inject; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.context.ApplicationScoped; import com.getpcpanel.cpp.windows.WindowFocusChangedEvent; import com.getpcpanel.hid.DeviceHolder; import lombok.RequiredArgsConstructor; -@Service -@RequiredArgsConstructor +@ApplicationScoped public class ProfileWindowFocusService { - private final DeviceHolder devices; + @Inject + DeviceHolder devices; private String previousApplication = ""; - @EventListener - public void onFocusChanged(WindowFocusChangedEvent event) { + public void onFocusChanged(@Observes WindowFocusChangedEvent event) { devices.values().forEach(d -> d.focusChanged(previousApplication, event.application())); previousApplication = event.application(); } diff --git a/src/main/java/com/getpcpanel/profile/Save.java b/src/main/java/com/getpcpanel/profile/Save.java index df9bb4a1..b8791689 100644 --- a/src/main/java/com/getpcpanel/profile/Save.java +++ b/src/main/java/com/getpcpanel/profile/Save.java @@ -9,7 +9,10 @@ import javax.annotation.Nullable; import com.getpcpanel.device.DeviceType; -import com.getpcpanel.ui.OverlayPosition; +import com.getpcpanel.profile.dto.MqttSettings; +import com.getpcpanel.profile.dto.OSCConnectionInfo; +import com.getpcpanel.profile.dto.OverlayPosition; +import com.getpcpanel.profile.dto.WaveLinkSettings; import lombok.Data; import lombok.extern.log4j.Log4j2; @@ -53,7 +56,7 @@ public class Save { private String overlayTextColor = DEFAULT_OVERLAY_TEXT_COLOR; private String overlayBarColor = DEFAULT_OVERLAY_BAR_COLOR; private String overlayBarBackgroundColor = DEFAULT_OVERLAY_BAR_BACKGROUND_COLOR; - @Nullable private Integer overlayWindowCornerRounding = 0; + private int overlayWindowCornerRounding; @Nullable private Integer overlayBarHeight = DEFAULT_OVERLAY_BAR_HEIGHT; @Nullable private Integer overlayBarCornerRounding = 0; @Nullable private OverlayPosition overlayPosition = DEFAULT_OVERLAY_POSITION; @@ -85,10 +88,6 @@ public void setSendOnlyIfDelta(Integer sendOnlyIfDelta) { this.sendOnlyIfDelta = sendOnlyIfDelta == null || sendOnlyIfDelta == 0 ? null : sendOnlyIfDelta; } - public int getOverlayWindowCornerRounding() { - return overlayWindowCornerRounding == null ? 0 : overlayWindowCornerRounding; - } - public int getOverlayBarCornerRounding() { return overlayBarCornerRounding == null ? 0 : overlayBarCornerRounding; } diff --git a/src/main/java/com/getpcpanel/profile/SaveService.java b/src/main/java/com/getpcpanel/profile/SaveService.java index 773029b5..a77309bf 100644 --- a/src/main/java/com/getpcpanel/profile/SaveService.java +++ b/src/main/java/com/getpcpanel/profile/SaveService.java @@ -11,35 +11,35 @@ import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.annotation.Lazy; -import org.springframework.stereotype.Service; import com.getpcpanel.Json; import com.getpcpanel.hid.DeviceHolder; import com.getpcpanel.util.Debouncer; import com.getpcpanel.util.FileUtil; +import io.quarkus.runtime.StartupEvent; import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import lombok.Setter; +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; import lombok.extern.log4j.Log4j2; import one.util.streamex.StreamEx; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public class SaveService { private static final String saveFileName = "profiles.json"; - private final ApplicationEventPublisher eventPublisher; - private final FileUtil fileUtil; - private final Json json; - private final Debouncer debouncer; - @Autowired @Lazy @Setter private DeviceHolder devices; + @Inject Event eventBus; + @Inject FileUtil fileUtil; + @Inject Json json; + @Inject Debouncer debouncer; + @Inject DeviceHolder devices; @SuppressWarnings("StaticNonFinalField") private static String oldVersionEncountered; private Save save; + private boolean isNew = false; public Save get() { return save; @@ -54,27 +54,45 @@ public void load() { if (!saveFile.exists()) { log.info("No save file found, creating new one"); save = new Save(); - eventPublisher.publishEvent(new SaveEvent(save, true)); + isNew = true; return; } try { save = json.read(FileUtils.readFileToString(saveFile, Charset.defaultCharset()), Save.class); handleOldVersionEncountered(); - StreamEx.ofValues(save.getDevices()).forEach(d -> StreamEx.of(d.getProfiles()).findFirst(Profile::isMainProfile).ifPresent(p -> d.setCurrentProfile(p.getName()))); - eventPublisher.publishEvent(new SaveEvent(save, false)); + StreamEx.ofValues(save.getDevices()).forEach(d -> StreamEx.of(d.getProfiles()).findFirst(p -> p.isMainProfile()).ifPresent(p -> d.setCurrentProfile(p.getName()))); } catch (Exception e) { log.error("Unable to read file", e); save = new Save(); + isNew = true; } } + /** + * Fire the initial SaveEvent after all beans are fully initialized. + * Using @Priority(1) to ensure this runs before DeviceScanner.onStart() (default priority ~2000). + */ + @Priority(1) + public void onStart(@Observes StartupEvent ev) { + eventBus.fire(new SaveEvent(save, isNew)); + } + private void handleOldVersionEncountered() { if (StringUtils.isBlank(oldVersionEncountered)) { return; } backup(); - save(); + writeToFile(); // write file only, SaveEvent will be fired from onStart() + } + + private void writeToFile() { + var saveFile = fileUtil.getFile(saveFileName); + try { + FileUtils.writeStringToFile(saveFile, json.writePretty(save), Charset.defaultCharset()); + } catch (IOException e) { + log.error("Unable to save file", e); + } } private void backup() { @@ -108,14 +126,8 @@ private void tryMigrate(File saveFile) { } public void save() { - var saveFile = fileUtil.getFile(saveFileName); - try { - FileUtils.writeStringToFile(saveFile, json.writePretty(save), Charset.defaultCharset()); - } catch (IOException e) { - log.error("Unable to save file", e); - } - - eventPublisher.publishEvent(new SaveEvent(save, false)); + writeToFile(); + eventBus.fire(new SaveEvent(save, false)); } public void debouncedSave() { @@ -123,10 +135,9 @@ public void debouncedSave() { } public Optional getProfile(String serialNum) { - return devices.getDevice(serialNum).map(device -> get().getDeviceSave(serialNum).ensureCurrentProfile(device.getDeviceType())); + return devices.getDevice(serialNum).map(device -> get().getDeviceSave(serialNum).ensureCurrentProfile(device.deviceType())); } public record SaveEvent(Save save, boolean isNew) { } } - diff --git a/src/main/java/com/getpcpanel/profile/KnobSetting.java b/src/main/java/com/getpcpanel/profile/dto/KnobSetting.java similarity index 85% rename from src/main/java/com/getpcpanel/profile/KnobSetting.java rename to src/main/java/com/getpcpanel/profile/dto/KnobSetting.java index 03706ff6..052b76cb 100644 --- a/src/main/java/com/getpcpanel/profile/KnobSetting.java +++ b/src/main/java/com/getpcpanel/profile/dto/KnobSetting.java @@ -1,4 +1,4 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; import lombok.Data; diff --git a/src/main/java/com/getpcpanel/profile/LightingConfig.java b/src/main/java/com/getpcpanel/profile/dto/LightingConfig.java similarity index 77% rename from src/main/java/com/getpcpanel/profile/LightingConfig.java rename to src/main/java/com/getpcpanel/profile/dto/LightingConfig.java index eb0a8f86..4d1dd77f 100644 --- a/src/main/java/com/getpcpanel/profile/LightingConfig.java +++ b/src/main/java/com/getpcpanel/profile/dto/LightingConfig.java @@ -1,11 +1,11 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; import java.util.Arrays; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; import com.getpcpanel.device.DeviceType; -import com.getpcpanel.util.Util; -import javafx.scene.paint.Color; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -13,6 +13,7 @@ @AllArgsConstructor @Builder(toBuilder = true) +@JsonAutoDetect(fieldVisibility = Visibility.ANY) public class LightingConfig { private LightingMode lightingMode; private String[] individualColors = {}; @@ -89,31 +90,6 @@ public static LightingConfig defaultLightingConfig(DeviceType dt) { throw new IllegalArgumentException("unknown deviceType"); } - public static LightingConfig createSingleColor(Color[] color, boolean[] volumeTracking) { - var lc = new LightingConfig(); - lc.lightingMode = LightingMode.SINGLE_COLOR; - lc.individualColors = new String[color.length]; - for (var i = 0; i < color.length; i++) - lc.individualColors[i] = Util.formatHexString(color[i]); - lc.volumeBrightnessTrackingEnabled = volumeTracking; - return lc; - } - - public static LightingConfig createAllColor(Color color, boolean[] volumeTracking) { - var lc = new LightingConfig(); - lc.lightingMode = LightingMode.ALL_COLOR; - lc.allColor = Util.formatHexString(color); - lc.volumeBrightnessTrackingEnabled = volumeTracking; - return lc; - } - - public static LightingConfig createAllColor(Color color) { - var lc = new LightingConfig(); - lc.lightingMode = LightingMode.ALL_COLOR; - lc.allColor = Util.formatHexString(color); - return lc; - } - public static LightingConfig createRainbowAnimation(byte phaseShift, byte brightness, byte speed, boolean reverse) { var lc = new LightingConfig(); lc.lightingMode = LightingMode.ALL_RAINBOW; @@ -155,7 +131,14 @@ public static LightingConfig createBreathAnimation(byte hue, byte brightness, by return lc; } - public LightingMode getLightingMode() { + public static LightingConfig createAllColor(String color) { + var lc = new LightingConfig(); + lc.lightingMode = LightingMode.ALL_COLOR; + lc.allColor = color; + return lc; + } + + public LightingMode lightingMode() { return lightingMode; } @@ -163,37 +146,37 @@ public void setLightingMode(LightingMode lightingMode) { this.lightingMode = lightingMode; } - public String[] getIndividualColors() { + public String[] individualColors() { return individualColors; } - public String getAllColor() { + public String allColor() { return allColor; } - public boolean[] getVolumeBrightnessTrackingEnabled() { + public boolean[] volumeBrightnessTrackingEnabled() { if (volumeBrightnessTrackingEnabled == null) volumeBrightnessTrackingEnabled = new boolean[0]; return volumeBrightnessTrackingEnabled; } - public byte getRainbowPhaseShift() { + public byte rainbowPhaseShift() { return rainbowPhaseShift; } - public byte getRainbowBrightness() { + public byte rainbowBrightness() { return rainbowBrightness; } - public byte getRainbowSpeed() { + public byte rainbowSpeed() { return rainbowSpeed; } - public byte getRainbowReverse() { + public byte rainbowReverse() { return rainbowReverse; } - public byte getRainbowVertical() { + public byte rainbowVertical() { return rainbowVertical; } @@ -209,52 +192,54 @@ public void setRainbowSpeed(byte rainbowSpeed) { this.rainbowSpeed = rainbowSpeed; } - public byte getWaveHue() { + public byte waveHue() { return waveHue; } - public byte getWaveBrightness() { + public byte waveBrightness() { return waveBrightness; } - public byte getWaveSpeed() { + public byte waveSpeed() { return waveSpeed; } - public byte getWaveReverse() { + public byte waveReverse() { return waveReverse; } - public byte getWaveBounce() { + public byte waveBounce() { return waveBounce; } - public byte getBreathHue() { + public byte breathHue() { return breathHue; } - public byte getBreathBrightness() { + public byte breathBrightness() { return breathBrightness; } - public byte getBreathSpeed() { + public byte breathSpeed() { return breathSpeed; } - public SingleKnobLightingConfig[] getKnobConfigs() { + public SingleKnobLightingConfig[] knobConfigs() { return knobConfigs; } - public SingleSliderLabelLightingConfig[] getSliderLabelConfigs() { + public SingleSliderLabelLightingConfig[] sliderLabelConfigs() { return sliderLabelConfigs; } - public SingleSliderLightingConfig[] getSliderConfigs() { + public SingleSliderLightingConfig[] sliderConfigs() { return sliderConfigs; } - public SingleLogoLightingConfig getLogoConfig() { + public SingleLogoLightingConfig logoConfig() { + if (logoConfig == null) { + logoConfig = new SingleLogoLightingConfig(); + } return logoConfig; } } - diff --git a/src/main/java/com/getpcpanel/profile/MqttSettings.java b/src/main/java/com/getpcpanel/profile/dto/MqttSettings.java similarity index 96% rename from src/main/java/com/getpcpanel/profile/MqttSettings.java rename to src/main/java/com/getpcpanel/profile/dto/MqttSettings.java index ca89202c..9284dd70 100644 --- a/src/main/java/com/getpcpanel/profile/MqttSettings.java +++ b/src/main/java/com/getpcpanel/profile/dto/MqttSettings.java @@ -1,4 +1,4 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; public record MqttSettings(boolean enabled, String host, Integer port, String username, String password, boolean secure, String baseTopic, diff --git a/src/main/java/com/getpcpanel/profile/OSCBinding.java b/src/main/java/com/getpcpanel/profile/dto/OSCBinding.java similarity index 81% rename from src/main/java/com/getpcpanel/profile/OSCBinding.java rename to src/main/java/com/getpcpanel/profile/dto/OSCBinding.java index 8ed7249e..6b0c4b69 100644 --- a/src/main/java/com/getpcpanel/profile/OSCBinding.java +++ b/src/main/java/com/getpcpanel/profile/dto/OSCBinding.java @@ -1,4 +1,4 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; public record OSCBinding(String address, float min, float max, boolean toggle) { public static final OSCBinding EMPTY = new OSCBinding("", 0, 1, false); diff --git a/src/main/java/com/getpcpanel/profile/OSCConnectionInfo.java b/src/main/java/com/getpcpanel/profile/dto/OSCConnectionInfo.java similarity index 62% rename from src/main/java/com/getpcpanel/profile/OSCConnectionInfo.java rename to src/main/java/com/getpcpanel/profile/dto/OSCConnectionInfo.java index 059301ce..d522db31 100644 --- a/src/main/java/com/getpcpanel/profile/OSCConnectionInfo.java +++ b/src/main/java/com/getpcpanel/profile/dto/OSCConnectionInfo.java @@ -1,4 +1,4 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; public record OSCConnectionInfo(String host, int port) { } diff --git a/src/main/java/com/getpcpanel/profile/dto/OverlayPosition.java b/src/main/java/com/getpcpanel/profile/dto/OverlayPosition.java new file mode 100644 index 00000000..8170d8b5 --- /dev/null +++ b/src/main/java/com/getpcpanel/profile/dto/OverlayPosition.java @@ -0,0 +1,9 @@ +package com.getpcpanel.profile.dto; + +/** + * Position options for the on-screen overlay. + * Moved from com.getpcpanel.ui to profile package as part of Quarkus migration. + */ +public enum OverlayPosition { + topLeft, topMiddle, topRight, middleLeft, middleMiddle, middleRight, bottomLeft, bottomMiddle, bottomRight +} diff --git a/src/main/java/com/getpcpanel/profile/SingleKnobLightingConfig.java b/src/main/java/com/getpcpanel/profile/dto/SingleKnobLightingConfig.java similarity index 50% rename from src/main/java/com/getpcpanel/profile/SingleKnobLightingConfig.java rename to src/main/java/com/getpcpanel/profile/dto/SingleKnobLightingConfig.java index 1234b62b..133605aa 100644 --- a/src/main/java/com/getpcpanel/profile/SingleKnobLightingConfig.java +++ b/src/main/java/com/getpcpanel/profile/dto/SingleKnobLightingConfig.java @@ -1,11 +1,7 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; import javax.annotation.Nullable; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.getpcpanel.util.Util; - -import javafx.scene.paint.Color; import lombok.Data; @Data @@ -24,25 +20,6 @@ public enum SINGLE_KNOB_MODE { NONE, STATIC, VOLUME_GRADIENT } - @JsonIgnore - public void setColor1FromColor(Color color1) { - this.color1 = Util.formatHexString(color1); - } - - @JsonIgnore - public void setColor2FromColor(Color color2) { - this.color2 = Util.formatHexString(color2); - } - - @JsonIgnore - public void setMuteOverrideColorFromColor(Color color) { - if (color == null) { - muteOverrideColor = null; - } else { - muteOverrideColor = Util.formatHexString(color); - } - } - public void set(SingleKnobLightingConfig c) { color1 = c.color1; color2 = c.color2; @@ -50,4 +27,3 @@ public void set(SingleKnobLightingConfig c) { mode = c.mode; } } - diff --git a/src/main/java/com/getpcpanel/profile/SingleLogoLightingConfig.java b/src/main/java/com/getpcpanel/profile/dto/SingleLogoLightingConfig.java similarity index 68% rename from src/main/java/com/getpcpanel/profile/SingleLogoLightingConfig.java rename to src/main/java/com/getpcpanel/profile/dto/SingleLogoLightingConfig.java index 105bc463..ea6935a3 100644 --- a/src/main/java/com/getpcpanel/profile/SingleLogoLightingConfig.java +++ b/src/main/java/com/getpcpanel/profile/dto/SingleLogoLightingConfig.java @@ -1,8 +1,5 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; -import com.getpcpanel.util.Util; - -import javafx.scene.paint.Color; import lombok.Data; @Data @@ -21,11 +18,6 @@ public enum SINGLE_LOGO_MODE { NONE, STATIC, RAINBOW, BREATH } - public SingleLogoLightingConfig setColor(Color color) { - this.color = Util.formatHexString(color); - return this; - } - /** * Used by Jackson */ @@ -34,4 +26,3 @@ public SingleLogoLightingConfig setColor(String color) { return this; } } - diff --git a/src/main/java/com/getpcpanel/profile/SingleSliderLabelLightingConfig.java b/src/main/java/com/getpcpanel/profile/dto/SingleSliderLabelLightingConfig.java similarity index 55% rename from src/main/java/com/getpcpanel/profile/SingleSliderLabelLightingConfig.java rename to src/main/java/com/getpcpanel/profile/dto/SingleSliderLabelLightingConfig.java index f3ad63cc..4f934176 100644 --- a/src/main/java/com/getpcpanel/profile/SingleSliderLabelLightingConfig.java +++ b/src/main/java/com/getpcpanel/profile/dto/SingleSliderLabelLightingConfig.java @@ -1,9 +1,5 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.getpcpanel.util.Util; - -import javafx.scene.paint.Color; import lombok.Data; @Data @@ -22,19 +18,8 @@ public enum SINGLE_SLIDER_LABEL_MODE { NONE, STATIC } - @JsonIgnore - public void setColorFromColor(Color color) { - this.color = Util.formatHexString(color); - } - - @JsonIgnore - public void setMuteOverrideColorFromColor(Color color) { - muteOverrideColor = Util.formatHexString(color); - } - public void set(SingleSliderLabelLightingConfig c) { color = c.color; mode = c.mode; } } - diff --git a/src/main/java/com/getpcpanel/profile/SingleSliderLightingConfig.java b/src/main/java/com/getpcpanel/profile/dto/SingleSliderLightingConfig.java similarity index 51% rename from src/main/java/com/getpcpanel/profile/SingleSliderLightingConfig.java rename to src/main/java/com/getpcpanel/profile/dto/SingleSliderLightingConfig.java index d3f76174..98047c7c 100644 --- a/src/main/java/com/getpcpanel/profile/SingleSliderLightingConfig.java +++ b/src/main/java/com/getpcpanel/profile/dto/SingleSliderLightingConfig.java @@ -1,9 +1,5 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.getpcpanel.util.Util; - -import javafx.scene.paint.Color; import lombok.Data; @Data @@ -22,25 +18,9 @@ public enum SINGLE_SLIDER_MODE { NONE, STATIC, STATIC_GRADIENT, VOLUME_GRADIENT } - @JsonIgnore - public void setColor1FromColor(Color color1) { - this.color1 = Util.formatHexString(color1); - } - - @JsonIgnore - public void setColor2FromColor(Color color2) { - this.color2 = Util.formatHexString(color2); - } - - @JsonIgnore - public void setMuteOverrideColorFromColor(Color color) { - muteOverrideColor = Util.formatHexString(color); - } - public void set(SingleSliderLightingConfig c) { color1 = c.color1; color2 = c.color2; mode = c.mode; } } - diff --git a/src/main/java/com/getpcpanel/profile/WaveLinkSettings.java b/src/main/java/com/getpcpanel/profile/dto/WaveLinkSettings.java similarity index 78% rename from src/main/java/com/getpcpanel/profile/WaveLinkSettings.java rename to src/main/java/com/getpcpanel/profile/dto/WaveLinkSettings.java index cae6a94e..cf5a3965 100644 --- a/src/main/java/com/getpcpanel/profile/WaveLinkSettings.java +++ b/src/main/java/com/getpcpanel/profile/dto/WaveLinkSettings.java @@ -1,4 +1,4 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; public record WaveLinkSettings(boolean enabled) { public static final WaveLinkSettings DEFAULT = new WaveLinkSettings(false); diff --git a/src/main/java/com/getpcpanel/rest/AudioResource.java b/src/main/java/com/getpcpanel/rest/AudioResource.java new file mode 100644 index 00000000..5317e3bb --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/AudioResource.java @@ -0,0 +1,56 @@ +package com.getpcpanel.rest; + +import java.util.Collection; +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import com.getpcpanel.cpp.AudioDevice; +import com.getpcpanel.cpp.AudioSession; +import com.getpcpanel.cpp.ISndCtrl; + +import one.util.streamex.StreamEx; + +@Path("/api/audio") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class AudioResource { + @Inject ISndCtrl sndCtrl; + + @GET + @Path("/devices") + public Collection listAudioDevices() { + return sndCtrl.devices(); + } + + @GET + @Path("/devices/output") + public List listOutputDevices() { + return StreamEx.of(sndCtrl.devices()).filter(AudioDevice::isOutput).toList(); + } + + @GET + @Path("/devices/input") + public List listInputDevices() { + return StreamEx.of(sndCtrl.devices()).filter(AudioDevice::isInput).toList(); + } + + @GET + @Path("/sessions") + public Collection listAudioSessions() { + return sndCtrl.getAllSessions(); + } + + @GET + @Path("/applications") + public List listRunningApplications() { + return sndCtrl.getRunningApplications(); + } +} diff --git a/src/main/java/com/getpcpanel/rest/CommandsResource.java b/src/main/java/com/getpcpanel/rest/CommandsResource.java new file mode 100644 index 00000000..e35e1cdc --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/CommandsResource.java @@ -0,0 +1,98 @@ +package com.getpcpanel.rest; + +import java.util.Collection; +import java.util.List; + +import com.getpcpanel.commands.command.CommandBrightness; +import com.getpcpanel.commands.command.CommandEndProgram; +import com.getpcpanel.commands.command.CommandKeystroke; +import com.getpcpanel.commands.command.CommandMedia; +import com.getpcpanel.commands.command.CommandObsMuteSource; +import com.getpcpanel.commands.command.CommandObsSetScene; +import com.getpcpanel.commands.command.CommandObsSetSourceVolume; +import com.getpcpanel.commands.command.CommandRun; +import com.getpcpanel.commands.command.CommandShortcut; +import com.getpcpanel.commands.command.CommandVoiceMeeterAdvanced; +import com.getpcpanel.commands.command.CommandVoiceMeeterAdvancedButton; +import com.getpcpanel.commands.command.CommandVoiceMeeterBasic; +import com.getpcpanel.commands.command.CommandVoiceMeeterBasicButton; +import com.getpcpanel.commands.command.CommandVolumeApplicationDeviceToggle; +import com.getpcpanel.commands.command.CommandVolumeDefaultDevice; +import com.getpcpanel.commands.command.CommandVolumeDefaultDeviceAdvanced; +import com.getpcpanel.commands.command.CommandVolumeDefaultDeviceToggle; +import com.getpcpanel.commands.command.CommandVolumeDefaultDeviceToggleAdvanced; +import com.getpcpanel.commands.command.CommandVolumeDevice; +import com.getpcpanel.commands.command.CommandVolumeDeviceMute; +import com.getpcpanel.commands.command.CommandVolumeFocus; +import com.getpcpanel.commands.command.CommandVolumeFocusMute; +import com.getpcpanel.commands.command.CommandVolumeProcess; +import com.getpcpanel.commands.command.CommandVolumeProcessMute; +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.rest.EventBroadcaster.AssignmentChangedEvent.Kinds; +import com.getpcpanel.rest.model.dto.CommandType; +import com.getpcpanel.rest.model.dto.CommandType.CommandCategory; +import com.getpcpanel.wavelink.command.CommandWaveLinkAddFocusToChannel; +import com.getpcpanel.wavelink.command.CommandWaveLinkChangeLevel; +import com.getpcpanel.wavelink.command.CommandWaveLinkChangeMute; +import com.getpcpanel.wavelink.command.CommandWaveLinkChannelEffect; +import com.getpcpanel.wavelink.command.CommandWaveLinkMainOutput; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import one.util.streamex.StreamEx; + +@Path("/api/commands") +@ApplicationScoped +public class CommandsResource { + @Inject SaveService saveService; + + private static final List commandTypes = List.of( + new CommandType("Brightness", CommandBrightness.class.getName(), CommandCategory.standard, Kinds.dial), + new CommandType("Process volume", CommandVolumeProcess.class.getName(), CommandCategory.standard, Kinds.dial), + new CommandType("Focus volume", CommandVolumeFocus.class.getName(), CommandCategory.standard, Kinds.dial), + new CommandType("Device volume", CommandVolumeDevice.class.getName(), CommandCategory.standard, Kinds.dial), + new CommandType("Obs Source Volume", CommandObsSetSourceVolume.class.getName(), CommandCategory.obs, Kinds.dial), + new CommandType("VoiceMeeter Advanced", CommandVoiceMeeterAdvanced.class.getName(), CommandCategory.voicemeeter, Kinds.dial), + new CommandType("VoiceMeeter Basic", CommandVoiceMeeterBasic.class.getName(), CommandCategory.voicemeeter, Kinds.dial), + new CommandType("WaveLink Change Level", CommandWaveLinkChangeLevel.class.getName(), CommandCategory.wavelink, Kinds.dial), + + new CommandType("End Program", CommandEndProgram.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Keystroke", CommandKeystroke.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Run", CommandRun.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Shortcut", CommandShortcut.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Media", CommandMedia.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Toggle application device", CommandVolumeApplicationDeviceToggle.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Default Device", CommandVolumeDefaultDevice.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Default Device Advanced", CommandVolumeDefaultDeviceAdvanced.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Default Device Toggle", CommandVolumeDefaultDeviceToggle.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Default Device Toggle Advanced", CommandVolumeDefaultDeviceToggleAdvanced.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Device Mute", CommandVolumeDeviceMute.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Focus Mute", CommandVolumeFocusMute.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Process Mute", CommandVolumeProcessMute.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Obs Mute Source", CommandObsMuteSource.class.getName(), CommandCategory.obs, Kinds.button), + new CommandType("Obs Set Scene", CommandObsSetScene.class.getName(), CommandCategory.obs, Kinds.button), + new CommandType("VoiceMeeter Advanced", CommandVoiceMeeterAdvancedButton.class.getName(), CommandCategory.voicemeeter, Kinds.button), + new CommandType("VoiceMeeter Basic", CommandVoiceMeeterBasicButton.class.getName(), CommandCategory.voicemeeter, Kinds.button), + new CommandType("WaveLink Add Focus To Channel", CommandWaveLinkAddFocusToChannel.class.getName(), CommandCategory.wavelink, Kinds.button), + new CommandType("WaveLink Change Mute", CommandWaveLinkChangeMute.class.getName(), CommandCategory.wavelink, Kinds.button), + new CommandType("WaveLink Channel Effect", CommandWaveLinkChannelEffect.class.getName(), CommandCategory.wavelink, Kinds.button), + new CommandType("WaveLink Main Output", CommandWaveLinkMainOutput.class.getName(), CommandCategory.wavelink, Kinds.button) + ); + + @GET + @Path("/available") + public Collection listAvailableCommands() { + return StreamEx.of(commandTypes).filter(this::enabled).toList(); + } + + private boolean enabled(CommandType commandType) { + return switch (commandType.category()) { + case standard -> true; + case obs -> saveService.get().isObsEnabled(); + case voicemeeter -> saveService.get().isVoicemeeterEnabled(); + case wavelink -> saveService.get().getWaveLink().enabled(); + }; + } +} diff --git a/src/main/java/com/getpcpanel/rest/DeviceResource.java b/src/main/java/com/getpcpanel/rest/DeviceResource.java new file mode 100644 index 00000000..873e0fb2 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/DeviceResource.java @@ -0,0 +1,283 @@ +package com.getpcpanel.rest; + +import java.util.List; +import java.util.Optional; + +import com.getpcpanel.commands.Commands; +import com.getpcpanel.device.Device; +import com.getpcpanel.hid.DeviceHolder; +import com.getpcpanel.profile.DeviceSave; +import com.getpcpanel.profile.Profile; +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.profile.dto.KnobSetting; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.rest.EventBroadcaster.AssignmentChangedEvent; +import com.getpcpanel.rest.EventBroadcaster.AssignmentChangedEvent.Kinds; +import com.getpcpanel.rest.EventBroadcaster.DeviceRenamedEvent; +import com.getpcpanel.rest.EventBroadcaster.LightingChangedEvent; +import com.getpcpanel.rest.EventBroadcaster.ProfileSwitchedEvent; +import com.getpcpanel.rest.EventBroadcaster.KnobSettingChangedEvent; +import com.getpcpanel.rest.model.dto.ControlAssignmentsUpdateDto; +import com.getpcpanel.rest.model.dto.DeviceDto; +import com.getpcpanel.rest.model.dto.ProfileDto; +import com.getpcpanel.rest.model.dto.ProfileSnapshotDto; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import one.util.streamex.StreamEx; + +@Path("/api/devices") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class DeviceResource { + @Inject DeviceHolder deviceHolder; + @Inject SaveService saveService; + @Inject Event eventBus; + + @GET + public List listDevices() { + var save = saveService.get(); + return StreamEx.of(deviceHolder.all()) + .map(d -> DeviceDto.from(d, save.getDeviceSave(d.getSerialNumber()))) + .toList(); + } + + @GET + @Path("/{serial}") + public DeviceDto getDevice(@PathParam("serial") String serial) { + var device = deviceHolder.getDevice(serial).orElseThrow(NotFoundException::new); + return DeviceDto.from(device, saveService.get().getDeviceSave(serial)); + } + + @PUT + @Path("/{serial}/name") + public Response renameDevice(@PathParam("serial") String serial, String name) { + var device = deviceHolder.getDevice(serial).orElseThrow(NotFoundException::new); + device.setDisplayName(name); + saveService.save(); + eventBus.fire(new DeviceRenamedEvent(serial, name)); + return Response.ok().build(); + } + + // ── Profiles ────────────────────────────────────────────────────────────── + + @GET + @Path("/{serial}/profiles") + public List listProfiles(@PathParam("serial") String serial) { + var deviceSave = getDeviceSave(serial); + return StreamEx.of(deviceSave.getProfiles()).map(ProfileDto::from).toList(); + } + + @POST + @Path("/{serial}/profiles") + public Response createProfile(@PathParam("serial") String serial, String name) { + var deviceSave = getDeviceSave(serial); + var device = deviceHolder.getDevice(serial).orElseThrow(NotFoundException::new); + var profile = new Profile(name, device.deviceType()); + deviceSave.getProfiles().add(profile); + saveService.save(); + return Response.ok(ProfileDto.from(profile)).build(); + } + + @DELETE + @Path("/{serial}/profiles/{name}") + public Response deleteProfile(@PathParam("serial") String serial, @PathParam("name") String name) { + var deviceSave = getDeviceSave(serial); + deviceSave.getProfiles().removeIf(p -> p.getName().equals(name)); + saveService.save(); + return Response.noContent().build(); + } + + @PUT + @Path("/{serial}/profiles/current") + public Response switchProfile(@PathParam("serial") String serial, String name) { + var deviceSave = getDeviceSave(serial); + var profile = deviceSave.setCurrentProfile(name).orElseThrow(() -> new NotFoundException("Profile not found: " + name)); + saveService.save(); + eventBus.fire(new ProfileSwitchedEvent(serial, name, ProfileSnapshotDto.from(profile))); + return Response.ok().build(); + } + + // ── Button/Dial assignments ──────────────────────────────────────────────── + + @GET + @Path("/{serial}/profiles/{profile}/buttons/{index}") + public Commands getButton(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index) { + return getProfile(serial, profileName).getButtonData(index); + } + + @PUT + @Path("/{serial}/profiles/{profile}/buttons/{index}") + public Response setButton(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index, + Commands commands) { + getProfile(serial, profileName).setButtonData(index, commands); + saveService.save(); + eventBus.fire(new AssignmentChangedEvent(serial, Kinds.button, index, commands)); + return Response.ok().build(); + } + + @GET + @Path("/{serial}/profiles/{profile}/dblbuttons/{index}") + public Commands getDblButton(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index) { + return Optional.ofNullable(getProfile(serial, profileName).getDblButtonData(index)) + .orElse(Commands.EMPTY); + } + + @PUT + @Path("/{serial}/profiles/{profile}/dblbuttons/{index}") + public Response setDblButton(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index, + Commands commands) { + getProfile(serial, profileName).setDblButtonData(index, commands); + saveService.save(); + eventBus.fire(new AssignmentChangedEvent(serial, Kinds.dblbutton, index, commands)); + return Response.ok().build(); + } + + @GET + @Path("/{serial}/profiles/{profile}/dials/{index}") + public Commands getDial(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index) { + return Optional.ofNullable(getProfile(serial, profileName).getDialData(index)) + .orElse(Commands.EMPTY); + } + + @PUT + @Path("/{serial}/profiles/{profile}/dials/{index}") + public Response setDial(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index, + Commands commands) { + getProfile(serial, profileName).setDialData(index, commands); + saveService.save(); + eventBus.fire(new AssignmentChangedEvent(serial, Kinds.dial, index, commands)); + return Response.ok().build(); + } + + @GET + @Path("/{serial}/profiles/{profile}/knobsettings/{index}") + public KnobSetting getKnobSettings(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index) { + return getProfile(serial, profileName).getKnobSettings(index); + } + + @PUT + @Path("/{serial}/profiles/{profile}/knobsettings/{index}") + public Response setKnobSettings(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index, + KnobSetting settings) { + var knob = getProfile(serial, profileName).getKnobSettings(index); + knob.setMinTrim(settings.getMinTrim()); + knob.setMaxTrim(settings.getMaxTrim()); + knob.setLogarithmic(settings.isLogarithmic()); + knob.setOverlayIcon(settings.getOverlayIcon()); + knob.setButtonDebounce(settings.getButtonDebounce()); + saveService.save(); + eventBus.fire(new KnobSettingChangedEvent(serial, index, knob)); + return Response.ok().build(); + } + + // ── Lighting ────────────────────────────────────────────────────────────── + + @GET + @Path("/{serial}/lighting") + public LightingConfig getLighting(@PathParam("serial") String serial) { + return deviceHolder.getDevice(serial) + .map(Device::getSavedLightingConfig) + .orElseThrow(NotFoundException::new); + } + + @PUT + @Path("/{serial}/lighting") + public Response setLighting(@PathParam("serial") String serial, LightingConfig config) { + var device = deviceHolder.getDevice(serial).orElseThrow(NotFoundException::new); + device.setSavedLighting(config); + saveService.save(); + eventBus.fire(new LightingChangedEvent(serial, config)); + return Response.ok().build(); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private DeviceSave getDeviceSave(String serial) { + var save = saveService.get(); + var deviceSave = save.getDevices().get(serial); + if (deviceSave == null) { + throw new NotFoundException("Device not found: " + serial); + } + return deviceSave; + } + + private Profile getProfile(String serial, String profileName) { + return getDeviceSave(serial).getProfile(profileName) + .orElseThrow(() -> new NotFoundException("Profile not found: " + profileName)); + } + + @PUT + @Path("/{serial}/profiles/{profile}/controls/{index}") + public Response setControlAssignments(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index, + ControlAssignmentsUpdateDto update) { + var profile = getProfile(serial, profileName); + var changed = false; + + if (update.analog() != null) { + profile.setDialData(index, update.analog()); + eventBus.fire(new AssignmentChangedEvent(serial, Kinds.dial, index, update.analog())); + changed = true; + } + + if (update.button() != null) { + profile.setButtonData(index, update.button()); + eventBus.fire(new AssignmentChangedEvent(serial, Kinds.button, index, update.button())); + changed = true; + } + + if (update.dblButton() != null) { + profile.setDblButtonData(index, update.dblButton()); + eventBus.fire(new AssignmentChangedEvent(serial, Kinds.dblbutton, index, update.dblButton())); + changed = true; + } + + if (update.knobSetting() != null) { + var knob = profile.getKnobSettings(index); + knob.setMinTrim(update.knobSetting().getMinTrim()); + knob.setMaxTrim(update.knobSetting().getMaxTrim()); + knob.setLogarithmic(update.knobSetting().isLogarithmic()); + knob.setOverlayIcon(update.knobSetting().getOverlayIcon()); + knob.setButtonDebounce(update.knobSetting().getButtonDebounce()); + eventBus.fire(new KnobSettingChangedEvent(serial, index, knob)); + changed = true; + } + + if (changed) { + saveService.save(); + } + + return Response.ok().build(); + } +} diff --git a/src/main/java/com/getpcpanel/rest/EventBroadcaster.java b/src/main/java/com/getpcpanel/rest/EventBroadcaster.java new file mode 100644 index 00000000..71ebb408 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/EventBroadcaster.java @@ -0,0 +1,134 @@ +package com.getpcpanel.rest; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.getpcpanel.commands.Commands; +import com.getpcpanel.hid.DeviceCommunicationHandler.ButtonPressEvent; +import com.getpcpanel.hid.DeviceCommunicationHandler.KnobRotateEvent; +import com.getpcpanel.hid.DeviceHolder; +import com.getpcpanel.hid.DeviceHolder.DeviceFullyConnectedEvent; +import com.getpcpanel.hid.DeviceScanner.DeviceDisconnectedEvent; +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.profile.dto.KnobSetting; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.rest.ProVisualColorsService.ProVisualColors; +import com.getpcpanel.rest.model.dto.DeviceSnapshotDto; +import com.getpcpanel.rest.model.dto.ProfileSnapshotDto; +import com.getpcpanel.rest.model.ws.WsAssignmentChangedEvent; +import com.getpcpanel.rest.model.ws.WsButtonEvent; +import com.getpcpanel.rest.model.ws.WsControlSettingChangedEvent; +import com.getpcpanel.rest.model.ws.WsDeviceConnectedEvent; +import com.getpcpanel.rest.model.ws.WsDeviceDisconnectedEvent; +import com.getpcpanel.rest.model.ws.WsDeviceRenamedEvent; +import com.getpcpanel.rest.model.ws.WsKnobEvent; +import com.getpcpanel.rest.model.ws.WsLightingChangedEvent; +import com.getpcpanel.rest.model.ws.WsProfileSwitchedEvent; +import com.getpcpanel.rest.model.ws.WsVisualColorsChangedEvent; +import com.getpcpanel.util.AppShutdownState; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +@ApplicationScoped +public class EventBroadcaster { + @Inject ObjectMapper objectMapper; + @Inject SaveService saveService; + @Inject DeviceHolder deviceHolder; + @Inject ProVisualColorsService proVisualColorsService; + + private boolean shouldSkipBroadcast() { + return AppShutdownState.isShuttingDown(); + } + + private void broadcast(Object event) { + if (shouldSkipBroadcast()) + return; + EventWebSocket.broadcast(event, objectMapper); + } + + // ── Existing operational events ──────────────────────────────────────────── + + public void onDeviceConnected(@Observes DeviceFullyConnectedEvent event) { + var serial = event.device().getSerialNumber(); + var save = saveService.get().getDeviceSave(serial); + if (save == null) { + log.debug("Skipping device_connected broadcast for {} because no device save exists", serial); + return; + } + + var snapshot = DeviceSnapshotDto.from(event.device(), save, proVisualColorsService); + broadcast(new WsDeviceConnectedEvent(snapshot)); + } + + public void onDeviceDisconnected(@Observes DeviceDisconnectedEvent event) { + broadcast(new WsDeviceDisconnectedEvent(event.serialNum())); + } + + public void onKnobRotate(@Observes KnobRotateEvent event) { + broadcast(new WsKnobEvent(event.serialNum(), event.knob(), event.value())); + } + + public void onButtonPress(@Observes ButtonPressEvent event) { + broadcast(new WsButtonEvent(event.serialNum(), event.button(), event.pressed())); + } + + // ── Mutation patch events ────────────────────────────────────────────────── + + public void onDeviceRenamed(@Observes DeviceRenamedEvent event) { + broadcast(new WsDeviceRenamedEvent(event.serial(), event.displayName())); + } + + public void onProfileSwitched(@Observes ProfileSwitchedEvent event) { + var colors = colorsFor(event.serial()); + broadcast(new WsProfileSwitchedEvent(event.serial(), event.profileName(), event.profileSnapshot(), colors.dialColors(), colors.sliderLabelColors(), colors.sliderColors(), colors.logoColor())); + } + + public void onLightingChanged(@Observes LightingChangedEvent event) { + var colors = colorsFor(event.serial()); + broadcast(new WsLightingChangedEvent(event.serial(), event.lightingConfig(), colors.dialColors(), colors.sliderLabelColors(), colors.sliderColors(), colors.logoColor())); + } + + public void onVisualColorsChanged(@Observes VisualColorsChangedEvent event) { + var colors = colorsFor(event.serial()); + broadcast(new WsVisualColorsChangedEvent(event.serial(), colors.dialColors(), colors.sliderLabelColors(), colors.sliderColors(), colors.logoColor())); + } + + public void onAssignmentChanged(@Observes AssignmentChangedEvent event) { + broadcast(new WsAssignmentChangedEvent(event.serial(), event.kind(), event.index(), event.commands())); + } + + public void onSettingChanged(@Observes KnobSettingChangedEvent event) { + broadcast(new WsControlSettingChangedEvent(event.serial(), event.index(), event.settings())); + } + + // ── CDI mutation events (fired by DeviceResource) ───────────────────────── + + public record DeviceRenamedEvent(String serial, String displayName) { + } + + public record ProfileSwitchedEvent(String serial, String profileName, ProfileSnapshotDto profileSnapshot) { + } + + public record LightingChangedEvent(String serial, LightingConfig lightingConfig) { + } + + public record VisualColorsChangedEvent(String serial) { + } + + public record KnobSettingChangedEvent(String serial, int index, KnobSetting settings) { + } + + public record AssignmentChangedEvent(String serial, Kinds kind, int index, Commands commands) { + public enum Kinds { + dial, button, dblbutton + } + } + + private ProVisualColors colorsFor(String serial) { + return deviceHolder.getDevice(serial) + .map(proVisualColorsService::resolve) + .orElse(ProVisualColors.empty()); + } +} diff --git a/src/main/java/com/getpcpanel/rest/EventWebSocket.java b/src/main/java/com/getpcpanel/rest/EventWebSocket.java new file mode 100644 index 00000000..8fdb5c94 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/EventWebSocket.java @@ -0,0 +1,86 @@ +package com.getpcpanel.rest; + +import java.util.concurrent.CopyOnWriteArraySet; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.getpcpanel.hid.DeviceHolder; +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.rest.model.dto.DeviceSnapshotDto; +import com.getpcpanel.rest.model.ws.WsDeviceConnectedEvent; +import com.getpcpanel.util.AppShutdownState; + +import io.quarkus.websockets.next.OnClose; +import io.quarkus.websockets.next.OnOpen; +import io.quarkus.websockets.next.WebSocket; +import io.quarkus.websockets.next.WebSocketConnection; +import jakarta.inject.Inject; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +@WebSocket(path = "/ws/events") +public class EventWebSocket { + private static final CopyOnWriteArraySet connections = new CopyOnWriteArraySet<>(); + + @Inject ObjectMapper objectMapper; + @Inject DeviceHolder deviceHolder; + @Inject SaveService saveService; + @Inject ProVisualColorsService proVisualColorsService; + + @OnOpen + public void onOpen(WebSocketConnection connection) { + if (AppShutdownState.isShuttingDown()) { + log.debug("Ignoring websocket connection {} because shutdown is in progress", connection.id()); + return; + } + connections.add(connection); + log.debug("WebSocket client connected: {} (total connections: {})", connection.id(), connections.size()); + sendInitialSnapshots(connection); + } + + @OnClose + public void onClose(WebSocketConnection connection) { + connections.remove(connection); + log.debug("WebSocket client disconnected: {} (remaining connections: {})", connection.id(), connections.size()); + } + + private void sendInitialSnapshots(WebSocketConnection connection) { + var save = saveService.get(); + deviceHolder.all().forEach(device -> { + try { + var deviceSave = save.getDeviceSave(device.getSerialNumber()); + if (deviceSave == null) { + log.debug("Skipping initial device_connected for {} because no device save exists", device.getSerialNumber()); + return; + } + + var snapshot = DeviceSnapshotDto.from(device, deviceSave, proVisualColorsService); + var connectedEvent = new WsDeviceConnectedEvent(snapshot); + var json = objectMapper.writeValueAsString(connectedEvent); + connection.sendTextAndAwait(json); + } catch (Exception e) { + log.warn("Failed to send initial device_connected for {} to new WS connection {}", device.getSerialNumber(), connection.id(), e); + } + }); + } + + public static void broadcast(Object event, ObjectMapper mapper) { + if (AppShutdownState.isShuttingDown()) { + connections.clear(); + return; + } + try { + var json = mapper.writeValueAsString(event); + log.debug("Broadcasting event to {} WebSocket clients: {}", connections.size(), json); + connections.forEach(c -> { + try { + c.sendTextAndAwait(json); + } catch (Exception e) { + log.debug("Failed to send event to WS client {}", c.id(), e); + } + }); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize event", e); + } + } +} diff --git a/src/main/java/com/getpcpanel/rest/IconResource.java b/src/main/java/com/getpcpanel/rest/IconResource.java new file mode 100644 index 00000000..b3276f01 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/IconResource.java @@ -0,0 +1,67 @@ +package com.getpcpanel.rest; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; + +import javax.imageio.ImageIO; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Response; + +import com.getpcpanel.iconextract.IIconService; + +import lombok.extern.log4j.Log4j2; + +@Log4j2 +@Path("/api/icons") +@ApplicationScoped +public class IconResource { + @Inject IIconService iconService; + + @GET + @Produces("image/png") + public Response getIcon(@QueryParam("path") String filePath, + @QueryParam("size") @DefaultValue("32") int size) { + if (filePath == null || filePath.isBlank()) { + throw new NotFoundException(); + } + // Resolve canonical path to prevent path traversal sequences (e.g. "../") + File file; + try { + file = new File(filePath).getCanonicalFile(); + } catch (IOException e) { + throw new NotFoundException(); + } + // Restrict to files with known safe extensions to prevent arbitrary file access + var name = file.getName().toLowerCase(); + var allowed = name.endsWith(".exe") || name.endsWith(".lnk") || name.endsWith(".ico") + || name.endsWith(".png") || name.endsWith(".jpg") || name.endsWith(".jpeg") + || name.endsWith(".bmp") || name.endsWith(".gif"); + if (!allowed) { + throw new NotFoundException(); + } + if (!file.isFile()) { + throw new NotFoundException(); + } + var img = iconService.getIconForFile(size, size, file); + if (img == null) { + throw new NotFoundException(); + } + try { + var baos = new ByteArrayOutputStream(); + ImageIO.write(img, "png", baos); + return Response.ok(baos.toByteArray()).type("image/png").build(); + } catch (Exception e) { + log.error("Failed to encode icon for {}", filePath, e); + return Response.serverError().build(); + } + } +} diff --git a/src/main/java/com/getpcpanel/rest/ObsResource.java b/src/main/java/com/getpcpanel/rest/ObsResource.java new file mode 100644 index 00000000..17fd7221 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/ObsResource.java @@ -0,0 +1,37 @@ +package com.getpcpanel.rest; + +import java.util.List; + +import com.getpcpanel.obs.OBS; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("/api/obs") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +public class ObsResource { + @Inject OBS obs; + + @GET + @Path("/scenes") + public List listScenes() { + if (!obs.isConnected()) { + return List.of(); + } + return obs.getScenes(); + } + + @GET + @Path("/sources") + public List listSources() { + if (!obs.isConnected()) { + return List.of(); + } + return obs.getSourcesWithAudio(); + } +} diff --git a/src/main/java/com/getpcpanel/rest/OverlayResource.java b/src/main/java/com/getpcpanel/rest/OverlayResource.java new file mode 100644 index 00000000..aa2948b4 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/OverlayResource.java @@ -0,0 +1,37 @@ +package com.getpcpanel.rest; + +import com.getpcpanel.overlay.Overlay; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +@Path("/api/overlay") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +public class OverlayResource { + @Inject Overlay overlay; + + @GET + public Response testOverlay() { + System.out.println("Overlay!"); + overlay.show(0); + return Response.ok().build(); + } + + @POST + public Response showOverlay(OverlayDto params) { + overlay.show(params.value()); + return Response.ok().build(); + } + + @RegisterForReflection + public record OverlayDto(int value, String icon) { + } +} diff --git a/src/main/java/com/getpcpanel/rest/ProVisualColorsService.java b/src/main/java/com/getpcpanel/rest/ProVisualColorsService.java new file mode 100644 index 00000000..e4cea3a0 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/ProVisualColorsService.java @@ -0,0 +1,302 @@ +package com.getpcpanel.rest; + +import java.awt.Color; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import com.getpcpanel.device.Device; +import com.getpcpanel.device.DeviceType; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.profile.dto.SingleKnobLightingConfig; +import com.getpcpanel.profile.dto.SingleLogoLightingConfig; +import com.getpcpanel.profile.dto.SingleSliderLabelLightingConfig; +import com.getpcpanel.profile.dto.SingleSliderLightingConfig; +import com.getpcpanel.util.Util; +import com.getpcpanel.util.coloroverride.OverrideColorService; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +@ApplicationScoped +public class ProVisualColorsService { + private static final String BLACK = "#000000"; + private static final int PRO_DIAL_COUNT = 5; + private static final int PRO_SLIDER_COUNT = 4; + private static final int PRO_SLIDER_SEGMENT_COUNT = 5; + + @Inject + OverrideColorService overrideColorService; + + public ProVisualColors resolve(Device device) { + if (device == null || device.deviceType() != DeviceType.PCPANEL_PRO) { + return ProVisualColors.empty(); + } + + var config = device.lightingConfig(); + if (config == null || config.lightingMode() == null) { + return ProVisualColors.defaultForPro(); + } + + return switch (config.lightingMode()) { + case ALL_COLOR -> monochrome(colorOrDefault(config.allColor())); + case ALL_WAVE -> fromWave(config); + case ALL_BREATH -> monochrome(colorFromHue(config.breathHue(), config.breathBrightness())); + case ALL_RAINBOW -> fromRainbow(config); + case CUSTOM -> fromCustom(device.getSerialNumber(), config); + default -> ProVisualColors.defaultForPro(); + }; + } + + private ProVisualColors fromRainbow(LightingConfig config) { + var baseHue = unitByte(config.rainbowPhaseShift()); + var reverse = config.rainbowReverse() == 1; + var vertical = config.rainbowVertical() == 1; + var brightness = unitByte(config.rainbowBrightness()); + + var dialColors = new ArrayList(PRO_DIAL_COUNT); + for (var i = 0; i < PRO_DIAL_COUNT; i++) { + dialColors.add(rainbowColor(baseHue, reverse, i, PRO_DIAL_COUNT, brightness)); + } + + var sliderLabelColors = new ArrayList(PRO_SLIDER_COUNT); + for (var i = 0; i < PRO_SLIDER_COUNT; i++) { + sliderLabelColors.add(rainbowColor(baseHue, reverse, i + PRO_DIAL_COUNT, PRO_DIAL_COUNT + PRO_SLIDER_COUNT, brightness)); + } + + var sliderColors = new ArrayList>(PRO_SLIDER_COUNT); + for (var s = 0; s < PRO_SLIDER_COUNT; s++) { + var segmentColors = new ArrayList(PRO_SLIDER_SEGMENT_COUNT); + for (var seg = 0; seg < PRO_SLIDER_SEGMENT_COUNT; seg++) { + var idx = vertical ? seg : (s * PRO_SLIDER_SEGMENT_COUNT + seg); + var total = vertical ? PRO_SLIDER_SEGMENT_COUNT : (PRO_SLIDER_COUNT * PRO_SLIDER_SEGMENT_COUNT); + segmentColors.add(rainbowColor(baseHue, reverse, idx, total, brightness)); + } + sliderColors.add(List.copyOf(segmentColors)); + } + + var logoColor = rainbowColor(baseHue, reverse, PRO_DIAL_COUNT + PRO_SLIDER_COUNT, PRO_DIAL_COUNT + PRO_SLIDER_COUNT + 1, brightness); + return new ProVisualColors(List.copyOf(dialColors), List.copyOf(sliderLabelColors), List.copyOf(sliderColors), logoColor); + } + + private ProVisualColors fromWave(LightingConfig config) { + var centerHue = unitByte(config.waveHue()); + var brightness = unitByte(config.waveBrightness()); + var reverse = config.waveReverse() == 1; + var bounce = config.waveBounce() == 1; + + var dialColors = new ArrayList(PRO_DIAL_COUNT); + for (var i = 0; i < PRO_DIAL_COUNT; i++) { + dialColors.add(waveColor(centerHue, brightness, i, PRO_DIAL_COUNT, reverse, bounce)); + } + + var sliderLabelColors = new ArrayList(PRO_SLIDER_COUNT); + for (var i = 0; i < PRO_SLIDER_COUNT; i++) { + sliderLabelColors.add(waveColor(centerHue, brightness, i, PRO_SLIDER_COUNT, reverse, bounce)); + } + + var sliderColors = new ArrayList>(PRO_SLIDER_COUNT); + for (var s = 0; s < PRO_SLIDER_COUNT; s++) { + var segmentColors = new ArrayList(PRO_SLIDER_SEGMENT_COUNT); + for (var seg = 0; seg < PRO_SLIDER_SEGMENT_COUNT; seg++) { + segmentColors.add(waveColor(centerHue, brightness, seg, PRO_SLIDER_SEGMENT_COUNT, reverse, bounce)); + } + sliderColors.add(List.copyOf(segmentColors)); + } + + var logoColor = colorFromHue(config.waveHue(), config.waveBrightness()); + return new ProVisualColors(List.copyOf(dialColors), List.copyOf(sliderLabelColors), List.copyOf(sliderColors), logoColor); + } + + private ProVisualColors monochrome(String color) { + var c = colorOrDefault(color); + var dials = nCopies(PRO_DIAL_COUNT, c); + var labels = nCopies(PRO_SLIDER_COUNT, c); + var sliders = new ArrayList>(PRO_SLIDER_COUNT); + for (var i = 0; i < PRO_SLIDER_COUNT; i++) { + sliders.add(nCopies(PRO_SLIDER_SEGMENT_COUNT, c)); + } + return new ProVisualColors(dials, labels, List.copyOf(sliders), c); + } + + private ProVisualColors fromCustom(String serial, LightingConfig config) { + var dialColors = new ArrayList(PRO_DIAL_COUNT); + var knobConfigs = config.knobConfigs(); + for (var i = 0; i < PRO_DIAL_COUNT; i++) { + var knob = i < knobConfigs.length ? knobConfigs[i] : new SingleKnobLightingConfig(); + knob = overrideColorService.getDialOverride(serial, i).orElse(knob); + dialColors.add(resolveDialColor(knob)); + } + + var sliderLabelColors = new ArrayList(PRO_SLIDER_COUNT); + var labelConfigs = config.sliderLabelConfigs(); + for (var i = 0; i < PRO_SLIDER_COUNT; i++) { + var label = i < labelConfigs.length ? labelConfigs[i] : new SingleSliderLabelLightingConfig(); + label = overrideColorService.getSliderLabelOverride(serial, i).orElse(label); + sliderLabelColors.add(resolveSliderLabelColor(label)); + } + + var sliderColors = new ArrayList>(PRO_SLIDER_COUNT); + var sliderConfigs = config.sliderConfigs(); + for (var i = 0; i < PRO_SLIDER_COUNT; i++) { + var slider = i < sliderConfigs.length ? sliderConfigs[i] : new SingleSliderLightingConfig(); + slider = overrideColorService.getSliderOverride(serial, i).orElse(slider); + sliderColors.add(resolveSliderColors(slider)); + } + + var logo = overrideColorService.getLogoOverride(serial).orElse(config.logoConfig()); + var logoColor = resolveLogoColor(logo); + + return new ProVisualColors(List.copyOf(dialColors), List.copyOf(sliderLabelColors), List.copyOf(sliderColors), logoColor); + } + + private String resolveDialColor(SingleKnobLightingConfig config) { + if (config == null || config.getMode() == null) { + return BLACK; + } + return switch (config.getMode()) { + case NONE -> BLACK; + case STATIC, VOLUME_GRADIENT -> firstColor(config.getColor1(), config.getColor2()); + }; + } + + private String resolveSliderLabelColor(SingleSliderLabelLightingConfig config) { + if (config == null || config.getMode() == null) { + return BLACK; + } + return switch (config.getMode()) { + case NONE -> BLACK; + case STATIC -> colorOrDefault(config.getColor()); + }; + } + + private List resolveSliderColors(SingleSliderLightingConfig config) { + if (config == null || config.getMode() == null) { + return nCopies(PRO_SLIDER_SEGMENT_COUNT, BLACK); + } + + return switch (config.getMode()) { + case NONE -> nCopies(PRO_SLIDER_SEGMENT_COUNT, BLACK); + case STATIC -> nCopies(PRO_SLIDER_SEGMENT_COUNT, colorOrDefault(config.getColor1())); + case STATIC_GRADIENT, VOLUME_GRADIENT -> gradient(config.getColor1(), config.getColor2(), PRO_SLIDER_SEGMENT_COUNT); + }; + } + + String resolveLogoColor(SingleLogoLightingConfig config) { + if (config == null || config.getMode() == null) { + return BLACK; + } + + return switch (config.getMode()) { + case NONE -> BLACK; + case STATIC -> colorOrDefault(config.getColor()); + case RAINBOW -> "$RAINBOW!"; + case BREATH -> "$BREATH"; + }; + } + + private List gradient(String startColor, String endColor, int steps) { + var start = Util.parseColorComponents(colorOrDefault(startColor)); + var end = Util.parseColorComponents(colorOrDefault(endColor)); + + if (start == null || end == null) { + return nCopies(steps, BLACK); + } + + var result = new ArrayList(steps); + for (var i = 0; i < steps; i++) { + var ratio = steps == 1 ? 0f : (float) i / (steps - 1); + var r = Math.round(start[0] + (end[0] - start[0]) * ratio); + var g = Math.round(start[1] + (end[1] - start[1]) * ratio); + var b = Math.round(start[2] + (end[2] - start[2]) * ratio); + result.add(toHex(r, g, b)); + } + return List.copyOf(result); + } + + private String colorFromHue(byte hue, byte brightness) { + return colorFromHsb(unitByte(hue), 1f, unitByte(brightness)); + } + + private String rainbowColor(float baseHue, boolean reverse, int index, int total, float brightness) { + var span = 0.7f; + var shift = total <= 1 ? 0f : (span * index / (total - 1)); + var hue = reverse ? baseHue - shift : baseHue + shift; + return colorFromHsb(normalizeHue(hue), 1f, brightness); + } + + private String waveColor(float centerHue, float brightness, int index, int total, boolean reverse, boolean bounce) { + var progress = total <= 1 ? 0f : (float) index / (total - 1); + if (reverse) { + progress = 1f - progress; + } + var spread = 0.12f; + var offset = bounce + ? (Math.abs(progress - 0.5f) * 2f * spread) + : ((progress - 0.5f) * 2f * spread); + return colorFromHsb(normalizeHue(centerHue + offset), 1f, brightness); + } + + private String colorFromHsb(float hue, float saturation, float brightness) { + var rgb = Color.HSBtoRGB(hue, saturation, brightness); + var r = (rgb >> 16) & 0xFF; + var g = (rgb >> 8) & 0xFF; + var b = rgb & 0xFF; + return toHex(r, g, b); + } + + private String toHex(int r, int g, int b) { + var hex = Util.formatHexString(r, g, b); + return hex == null ? BLACK : hex; + } + + private float unitByte(byte value) { + return (value & 0xFF) / 255f; + } + + private float normalizeHue(float hue) { + var normalized = hue % 1f; + return normalized < 0 ? normalized + 1f : normalized; + } + + private String firstColor(String color1, String color2) { + var c1 = colorOrDefault(color1); + if (!BLACK.equals(c1)) { + return c1; + } + return colorOrDefault(color2); + } + + private String colorOrDefault(String color) { + var parsed = Util.parseColorComponents(color); + if (parsed == null) { + return BLACK; + } + return color.startsWith("#") ? color : "#" + color; + } + + private static List nCopies(int count, String color) { + return Collections.nCopies(count, color); + } + + public record ProVisualColors( + List dialColors, + List sliderLabelColors, + List> sliderColors, + String logoColor + ) { + public static ProVisualColors empty() { + return new ProVisualColors(List.of(), List.of(), List.of(), BLACK); + } + + public static ProVisualColors defaultForPro() { + var blackDials = nCopies(PRO_DIAL_COUNT, BLACK); + var blackLabels = nCopies(PRO_SLIDER_COUNT, BLACK); + var blackSliders = new ArrayList>(PRO_SLIDER_COUNT); + for (var i = 0; i < PRO_SLIDER_COUNT; i++) { + blackSliders.add(nCopies(PRO_SLIDER_SEGMENT_COUNT, BLACK)); + } + return new ProVisualColors(blackDials, blackLabels, List.copyOf(blackSliders), BLACK); + } + } +} diff --git a/src/main/java/com/getpcpanel/rest/ProcessResource.java b/src/main/java/com/getpcpanel/rest/ProcessResource.java new file mode 100644 index 00000000..2abfbbd8 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/ProcessResource.java @@ -0,0 +1,55 @@ +package com.getpcpanel.rest; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Base64; +import java.util.List; + +import javax.imageio.ImageIO; + +import com.getpcpanel.cpp.ISndCtrl; +import com.getpcpanel.iconextract.IIconService; +import com.getpcpanel.rest.model.dto.ProcessDto; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +@Path("/api/processes") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +public class ProcessResource { + @Inject ISndCtrl sndCtrl; + @Inject IIconService iconService; + + @GET + public List listProcesses() { + return sndCtrl.getRunningApplications().stream() + .map(app -> new ProcessDto( + app.pid(), + app.file().getAbsolutePath(), + app.name(), + encodeIcon(iconService.getIconForFile(32, 32, app.file())))) + .toList(); + } + + static String encodeIcon(BufferedImage img) { + if (img == null) { + return null; + } + try { + var baos = new ByteArrayOutputStream(); + ImageIO.write(img, "png", baos); + return "data:image/png;base64," + Base64.getEncoder().encodeToString(baos.toByteArray()); + } catch (IOException e) { + log.debug("Failed to encode process icon", e); + return null; + } + } +} diff --git a/src/main/java/com/getpcpanel/rest/SettingsResource.java b/src/main/java/com/getpcpanel/rest/SettingsResource.java new file mode 100644 index 00000000..25a5bfc0 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/SettingsResource.java @@ -0,0 +1,65 @@ +package com.getpcpanel.rest; + +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.profile.dto.MqttSettings; +import com.getpcpanel.profile.dto.WaveLinkSettings; +import com.getpcpanel.rest.model.dto.SettingsDto; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +@Path("/api/settings") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class SettingsResource { + @Inject SaveService saveService; + + @GET + public SettingsDto getSettings() { + return SettingsDto.from(saveService.get()); + } + + @PUT + public Response updateSettings(SettingsDto dto) { + var save = saveService.get(); + dto.applyTo(save); + saveService.save(); + return Response.ok().build(); + } + + @GET + @Path("/mqtt") + public MqttSettings getMqttSettings() { + return saveService.get().getMqtt(); + } + + @PUT + @Path("/mqtt") + public Response updateMqttSettings(MqttSettings settings) { + saveService.get().setMqtt(settings); + saveService.save(); + return Response.ok().build(); + } + + @GET + @Path("/wavelink") + public WaveLinkSettings getWaveLinkSettings() { + return saveService.get().getWaveLink(); + } + + @PUT + @Path("/wavelink") + public Response updateWaveLinkSettings(WaveLinkSettings settings) { + saveService.get().setWaveLink(settings); + saveService.save(); + return Response.ok().build(); + } +} diff --git a/src/main/java/com/getpcpanel/rest/VoiceMeeterResource.java b/src/main/java/com/getpcpanel/rest/VoiceMeeterResource.java new file mode 100644 index 00000000..5e4792b2 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/VoiceMeeterResource.java @@ -0,0 +1,39 @@ +package com.getpcpanel.rest; + +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("/api/voicemeeter") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +public class VoiceMeeterResource { + + public static class VoiceMeeterParam { + public String name; + public List params; + + public VoiceMeeterParam(String name, List params) { + this.name = name; + this.params = params; + } + } + + @GET + @Path("/basic") + public List getBasicParams() { + // Return empty list for now - these are typically obtained from user configuration + return List.of(); + } + + @GET + @Path("/advanced") + public List getAdvancedParams() { + // Return empty list for now - these are typically obtained from user configuration + return List.of(); + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/CommandType.java b/src/main/java/com/getpcpanel/rest/model/dto/CommandType.java new file mode 100644 index 00000000..4b0217d6 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/CommandType.java @@ -0,0 +1,15 @@ +package com.getpcpanel.rest.model.dto; + +import com.getpcpanel.rest.EventBroadcaster.AssignmentChangedEvent.Kinds; + +public record CommandType( + String name, + String command, + CommandCategory category, + Kinds kind +) { + + public enum CommandCategory { + standard, voicemeeter, obs, wavelink + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/ControlAssignmentsUpdateDto.java b/src/main/java/com/getpcpanel/rest/model/dto/ControlAssignmentsUpdateDto.java new file mode 100644 index 00000000..9c2378b0 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/ControlAssignmentsUpdateDto.java @@ -0,0 +1,14 @@ +package com.getpcpanel.rest.model.dto; + +import com.getpcpanel.commands.Commands; +import com.getpcpanel.profile.dto.KnobSetting; + +import jakarta.annotation.Nullable; + +public record ControlAssignmentsUpdateDto( + @Nullable Commands analog, + @Nullable Commands button, + @Nullable Commands dblButton, + @Nullable KnobSetting knobSetting +) { +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/DeviceDto.java b/src/main/java/com/getpcpanel/rest/model/dto/DeviceDto.java new file mode 100644 index 00000000..0e26655e --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/DeviceDto.java @@ -0,0 +1,34 @@ +package com.getpcpanel.rest.model.dto; + +import java.util.List; + +import com.getpcpanel.device.Device; +import com.getpcpanel.device.DeviceType; +import com.getpcpanel.profile.DeviceSave; + +import one.util.streamex.StreamEx; + +public record DeviceDto( + String serial, + String displayName, + DeviceType deviceType, + int analogCount, + int buttonCount, + boolean hasLogoLed, + String currentProfile, + List profiles +) { + public static DeviceDto from(Device device, DeviceSave deviceSave) { + var type = device.deviceType(); + return new DeviceDto( + device.getSerialNumber(), + device.getDisplayName(), + type, + type.getAnalogCount(), + type.getButtonCount(), + type.isHasLogoLed(), + deviceSave.getCurrentProfileName(), + StreamEx.of(deviceSave.getProfiles()).map(p -> p.getName()).toList() + ); + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/DeviceSnapshotDto.java b/src/main/java/com/getpcpanel/rest/model/dto/DeviceSnapshotDto.java new file mode 100644 index 00000000..99364d4d --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/DeviceSnapshotDto.java @@ -0,0 +1,77 @@ +package com.getpcpanel.rest.model.dto; + +import java.util.List; +import java.util.stream.IntStream; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.getpcpanel.device.Device; +import com.getpcpanel.profile.DeviceSave; +import com.getpcpanel.profile.Profile; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.rest.ProVisualColorsService; +import com.getpcpanel.rest.model.ws.WsEvent; + +import one.util.streamex.StreamEx; + +/** + * Full device state snapshot sent over WebSocket on connection. + * Combines DeviceDto fields with lighting config, the active profile's + * assignments, and the current analog knob values — so the frontend + * never needs separate HTTP calls just to display device state. + */ +@JsonTypeName("device_snapshot") +public record DeviceSnapshotDto( + // ── core device fields (same as DeviceDto) ────────────────────────── + String serial, + String displayName, + String deviceType, + int analogCount, + int buttonCount, + boolean hasLogoLed, + String currentProfile, + List profiles, + // ── extra snapshot fields ──────────────────────────────────────────── + LightingConfig lightingConfig, + ProfileSnapshotDto currentProfileSnapshot, + List analogValues, + List dialColors, + List sliderLabelColors, + List> sliderColors, + String logoColor +) implements WsEvent { + /** + * WsEvent type discriminator understood by the frontend. + */ + public String type() { + return "device_snapshot"; + } + + public static DeviceSnapshotDto from(Device device, DeviceSave deviceSave, ProVisualColorsService proVisualColorsService) { + var dt = device.deviceType(); + var profile = device.currentProfile(); + var analogCount = dt.getAnalogCount(); + var visualColors = proVisualColorsService.resolve(device); + + var knobValues = IntStream.range(0, analogCount) + .mapToObj(device::getKnobRotation) + .toList(); + + return new DeviceSnapshotDto( + device.getSerialNumber(), + device.getDisplayName(), + dt.name(), + analogCount, + dt.getButtonCount(), + dt.isHasLogoLed(), + deviceSave.getCurrentProfileName(), + StreamEx.of(deviceSave.getProfiles()).map(Profile::getName).toList(), + device.getSavedLightingConfig(), + ProfileSnapshotDto.from(profile), + knobValues, + visualColors.dialColors(), + visualColors.sliderLabelColors(), + visualColors.sliderColors(), + visualColors.logoColor() + ); + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/ProcessDto.java b/src/main/java/com/getpcpanel/rest/model/dto/ProcessDto.java new file mode 100644 index 00000000..b7f31d90 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/ProcessDto.java @@ -0,0 +1,6 @@ +package com.getpcpanel.rest.model.dto; + +import javax.annotation.Nullable; + +public record ProcessDto(int pid, String path, String name, @Nullable String icon) { +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/ProfileDto.java b/src/main/java/com/getpcpanel/rest/model/dto/ProfileDto.java new file mode 100644 index 00000000..d902cfc8 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/ProfileDto.java @@ -0,0 +1,9 @@ +package com.getpcpanel.rest.model.dto; + +import com.getpcpanel.profile.Profile; + +public record ProfileDto(String name, boolean isMainProfile) { + public static ProfileDto from(Profile profile) { + return new ProfileDto(profile.getName(), profile.isMainProfile()); + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/ProfileSnapshotDto.java b/src/main/java/com/getpcpanel/rest/model/dto/ProfileSnapshotDto.java new file mode 100644 index 00000000..a6e8a8ae --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/ProfileSnapshotDto.java @@ -0,0 +1,32 @@ +package com.getpcpanel.rest.model.dto; + +import java.util.Map; + +import com.getpcpanel.commands.Commands; +import com.getpcpanel.profile.Profile; +import com.getpcpanel.profile.dto.KnobSetting; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +/** + * Snapshot of the currently active profile — all assignment data the frontend + * needs to render the device page without any additional HTTP calls. + */ +@RegisterForReflection +public record ProfileSnapshotDto( + String name, + Map dialData, + Map buttonData, + Map dblButtonData, + Map knobSettings +) { + public static ProfileSnapshotDto from(Profile profile) { + return new ProfileSnapshotDto( + profile.getName(), + profile.getDialData(), + profile.getButtonData(), + profile.getDblButtonData(), + profile.getKnobSettings() + ); + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/SettingsDto.java b/src/main/java/com/getpcpanel/rest/model/dto/SettingsDto.java new file mode 100644 index 00000000..2bf947bc --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/SettingsDto.java @@ -0,0 +1,125 @@ +package com.getpcpanel.rest.model.dto; + +import java.util.List; + +import javax.annotation.Nullable; + +import com.getpcpanel.profile.Save; +import com.getpcpanel.profile.dto.MqttSettings; +import com.getpcpanel.profile.dto.OSCConnectionInfo; +import com.getpcpanel.profile.dto.OverlayPosition; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class SettingsDto { + // General + private boolean mainUIIcons; + private boolean startupVersionCheck; + private boolean forceVolume; + private Long dblClickInterval; + private boolean preventClickWhenDblClick; + @Nullable private Integer preventSliderTwitchDelay; + @Nullable private Integer sliderRollingAverage; + @Nullable private Integer sendOnlyIfDelta; + private boolean workaroundsOnlySliders; + + // OBS + private boolean obsEnabled; + private String obsAddress; + private String obsPort; + private String obsPassword; + + // VoiceMeeter + private boolean voicemeeterEnabled; + private String voicemeeterPath; + + // OSC + private Integer oscListenPort; + private List oscConnections; + + // Overlay + private boolean overlayEnabled; + private boolean overlayUseLog; + private boolean overlayShowNumber; + private String overlayBackgroundColor; + private String overlayTextColor; + private String overlayBarColor; + private String overlayBarBackgroundColor; + private int overlayWindowCornerRounding; + @Nullable private Integer overlayBarHeight; + @Nullable private Integer overlayBarCornerRounding; + @Nullable private OverlayPosition overlayPosition; + @Nullable private Integer overlayPadding; + private MqttSettings mqtt; + + public static SettingsDto from(Save save) { + var dto = new SettingsDto(); + dto.mainUIIcons = save.isMainUIIcons(); + dto.startupVersionCheck = save.isStartupVersionCheck(); + dto.forceVolume = save.isForceVolume(); + dto.dblClickInterval = save.getDblClickInterval(); + dto.preventClickWhenDblClick = save.isPreventClickWhenDblClick(); + dto.preventSliderTwitchDelay = save.getPreventSliderTwitchDelay(); + dto.sliderRollingAverage = save.getSliderRollingAverage(); + dto.sendOnlyIfDelta = save.getSendOnlyIfDelta(); + dto.workaroundsOnlySliders = save.isWorkaroundsOnlySliders(); + dto.obsEnabled = save.isObsEnabled(); + dto.obsAddress = save.getObsAddress(); + dto.obsPort = save.getObsPort(); + dto.obsPassword = save.getObsPassword(); + dto.voicemeeterEnabled = save.isVoicemeeterEnabled(); + dto.voicemeeterPath = save.getVoicemeeterPath(); + dto.oscListenPort = save.getOscListenPort(); + dto.oscConnections = save.getOscConnections(); + dto.overlayEnabled = save.isOverlayEnabled(); + dto.overlayUseLog = save.isOverlayUseLog(); + dto.overlayShowNumber = save.isOverlayShowNumber(); + dto.overlayBackgroundColor = save.getOverlayBackgroundColor(); + dto.overlayTextColor = save.getOverlayTextColor(); + dto.overlayBarColor = save.getOverlayBarColor(); + dto.overlayBarBackgroundColor = save.getOverlayBarBackgroundColor(); + dto.overlayWindowCornerRounding = save.getOverlayWindowCornerRounding(); + dto.overlayBarHeight = save.getOverlayBarHeight(); + dto.overlayBarCornerRounding = save.getOverlayBarCornerRounding(); + dto.overlayPosition = save.getOverlayPosition(); + dto.overlayPadding = save.getOverlayPadding(); + dto.mqtt = save.getMqtt(); + return dto; + } + + public void applyTo(Save save) { + save.setMainUIIcons(mainUIIcons); + save.setStartupVersionCheck(startupVersionCheck); + save.setForceVolume(forceVolume); + save.setDblClickInterval(dblClickInterval); + save.setPreventClickWhenDblClick(preventClickWhenDblClick); + save.setPreventSliderTwitchDelay(preventSliderTwitchDelay); + save.setSliderRollingAverage(sliderRollingAverage); + save.setSendOnlyIfDelta(sendOnlyIfDelta); + save.setWorkaroundsOnlySliders(workaroundsOnlySliders); + save.setObsEnabled(obsEnabled); + save.setObsAddress(obsAddress); + save.setObsPort(obsPort); + save.setObsPassword(obsPassword); + save.setVoicemeeterEnabled(voicemeeterEnabled); + save.setVoicemeeterPath(voicemeeterPath); + save.setOscListenPort(oscListenPort); + save.setOscConnections(oscConnections); + save.setOverlayEnabled(overlayEnabled); + save.setOverlayUseLog(overlayUseLog); + save.setOverlayShowNumber(overlayShowNumber); + save.setOverlayBackgroundColor(overlayBackgroundColor); + save.setOverlayTextColor(overlayTextColor); + save.setOverlayBarColor(overlayBarColor); + save.setOverlayBarBackgroundColor(overlayBarBackgroundColor); + save.setOverlayWindowCornerRounding(overlayWindowCornerRounding); + save.setOverlayBarHeight(overlayBarHeight); + save.setOverlayBarCornerRounding(overlayBarCornerRounding); + save.setOverlayPosition(overlayPosition); + save.setOverlayPadding(overlayPadding); + save.setMqtt(mqtt); + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsAssignmentChangedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsAssignmentChangedEvent.java new file mode 100644 index 00000000..a5d246ed --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsAssignmentChangedEvent.java @@ -0,0 +1,9 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.getpcpanel.commands.Commands; +import com.getpcpanel.rest.EventBroadcaster.AssignmentChangedEvent.Kinds; + +@JsonTypeName("assignment_changed") +public record WsAssignmentChangedEvent(String serial, Kinds kind, int index, Commands commands) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsButtonEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsButtonEvent.java new file mode 100644 index 00000000..d1aa0328 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsButtonEvent.java @@ -0,0 +1,7 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; + +@JsonTypeName("button_press") +public record WsButtonEvent(String serial, int button, boolean pressed) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsControlSettingChangedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsControlSettingChangedEvent.java new file mode 100644 index 00000000..66b37b08 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsControlSettingChangedEvent.java @@ -0,0 +1,8 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.getpcpanel.profile.dto.KnobSetting; + +@JsonTypeName("control_setting_changed") +public record WsControlSettingChangedEvent(String serial, int index, KnobSetting settings) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceConnectedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceConnectedEvent.java new file mode 100644 index 00000000..be407264 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceConnectedEvent.java @@ -0,0 +1,10 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.getpcpanel.rest.model.dto.DeviceSnapshotDto; + +@JsonTypeName("device_connected") +public record WsDeviceConnectedEvent( + DeviceSnapshotDto deviceSnapshot +) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceDisconnectedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceDisconnectedEvent.java new file mode 100644 index 00000000..c5fa06fd --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceDisconnectedEvent.java @@ -0,0 +1,7 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; + +@JsonTypeName("device_disconnected") +public record WsDeviceDisconnectedEvent(String serial) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceRenamedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceRenamedEvent.java new file mode 100644 index 00000000..73f2ebc4 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceRenamedEvent.java @@ -0,0 +1,7 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; + +@JsonTypeName("device_renamed") +public record WsDeviceRenamedEvent(String serial, String displayName) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsEvent.java new file mode 100644 index 00000000..ad235d06 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsEvent.java @@ -0,0 +1,25 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.getpcpanel.rest.model.dto.DeviceSnapshotDto; + +@JsonTypeInfo(use = Id.NAME, include = As.PROPERTY, property = "type") +@JsonSubTypes({ + @Type(value = WsAssignmentChangedEvent.class, name = "assignment_changed"), + @Type(value = WsButtonEvent.class, name = "button_press"), + @Type(value = WsDeviceConnectedEvent.class, name = "device_connected"), + @Type(value = WsDeviceDisconnectedEvent.class, name = "device_disconnected"), + @Type(value = WsDeviceRenamedEvent.class, name = "device_renamed"), + @Type(value = WsKnobEvent.class, name = "knob_rotate"), + @Type(value = WsLightingChangedEvent.class, name = "lighting_changed"), + @Type(value = WsProfileSwitchedEvent.class, name = "profile_switched"), + @Type(value = WsVisualColorsChangedEvent.class, name = "visual_colors_changed"), + @Type(value = DeviceSnapshotDto.class, name = "device_snapshot"), + @Type(value = WsControlSettingChangedEvent.class, name = "control_setting_changed") +}) +public interface WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsKnobEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsKnobEvent.java new file mode 100644 index 00000000..745a8088 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsKnobEvent.java @@ -0,0 +1,7 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; + +@JsonTypeName("knob_rotate") +public record WsKnobEvent(String serial, int knob, int value) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsLightingChangedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsLightingChangedEvent.java new file mode 100644 index 00000000..6bdbb137 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsLightingChangedEvent.java @@ -0,0 +1,17 @@ +package com.getpcpanel.rest.model.ws; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.getpcpanel.profile.dto.LightingConfig; + +@JsonTypeName("lighting_changed") +public record WsLightingChangedEvent( + String serial, + LightingConfig lightingConfig, + List dialColors, + List sliderLabelColors, + List> sliderColors, + String logoColor +) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsProfileSwitchedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsProfileSwitchedEvent.java new file mode 100644 index 00000000..4c30bb4a --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsProfileSwitchedEvent.java @@ -0,0 +1,18 @@ +package com.getpcpanel.rest.model.ws; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.getpcpanel.rest.model.dto.ProfileSnapshotDto; + +@JsonTypeName("profile_switched") +public record WsProfileSwitchedEvent( + String serial, + String profileName, + ProfileSnapshotDto profileSnapshot, + List dialColors, + List sliderLabelColors, + List
Connects automatically when {@code obsEnabled=true} in the user's settings. + * Reconnects every 30 s if the connection is lost. + * Fires {@link OBSConnectEvent} and {@link OBSMuteEvent} CDI events. + */ @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public final class OBS { - private static final long WAIT_TIME_MS = 1000L; - private static final ObsIdHelper OBS_ID_HELPER = new ObsIdHelper(); + private static final long CONNECT_TIMEOUT_MS = 5_000; + + @Inject SaveService save; + @Inject Event connectEvent; + @Inject Event muteEvent; + @Inject ObjectMapper objectMapper; - private final SaveService save; - private final ApplicationEventPublisher eventPublisher; - private List previousSettings = List.of(); - private boolean connected; - private boolean shuttingDown; - @Nullable private OBSRemoteController controller; + private ObsWebSocketClient client; @PostConstruct public void init() { - Runtime.getRuntime().addShutdownHook(new Thread(this::applicationEnding, "OBS Shutdown hook")); + reconnectIfNeeded(); } - @Scheduled(fixedRateString = "${pcpanel.obs.rate:2500}") - public void connect() { - if (!connected && !shuttingDown) { - buildAndConnectObsController(); + @PreDestroy + public void destroy() { + if (client != null) { + client.disconnect(); } } - private void applicationEnding() { - shuttingDown = true; - disconnectController(); - } - - private void buildAndConnectObsController() { - var save = this.save.get(); - if (!save.isObsEnabled() || (connected && !settingsStillSame(false)) || shuttingDown) { - log.trace("Obs is disabled({})/already connected({})/we are shutting down({})", save.isObsEnabled(), connected, shuttingDown); - disconnectController(); + @Scheduled(every = "30s") + public void reconnectIfNeeded() { + var settings = save.get(); + if (!settings.isObsEnabled()) { return; } - - try { - doBuildAndConnectObsController(); - } catch (Exception e) { - doConnected(false); - connected = false; - log.debug("Connecting failed", e); - } - } - - private void doBuildAndConnectObsController() { - var save = this.save.get(); - log.debug("Connecting to OBS"); - if (settingsStillSame(true) && controller != null) { - if (connected) { - return; - } - connected = true; - controller.connect(); + if (client != null && client.isConnected()) { return; } - - disconnectController(); - connected = true; - var port = NumberUtils.toInt(save.getObsPort(), -1); - var address = save.getObsAddress(); - var password = StringUtils.trimToNull(save.getObsPassword()); - - if (port != -1 && StringUtils.isNotBlank(address)) { - var currentIdx = OBS_ID_HELPER.incAndGet(); - controller = buildController(address, port, password).lifecycle() - .onReady(this::connected) - .onDisconnect(() -> OBS_ID_HELPER.runIfIdEq(currentIdx, () -> { - doConnected(false); - connected = false; - })) - .onControllerError(e -> OBS_ID_HELPER.runIfIdEq(currentIdx, () -> onError(e))) - .and() - .registerEventListener(InputMuteStateChangedEvent.class, this::onMuteChanged) - .build(); - controller.connect(); - } else { - doConnected(false); - connected = false; - } - } - - private void onMuteChanged(InputMuteStateChangedEvent t) { - eventPublisher.publishEvent(new OBSMuteEvent(t.getMessageData().getEventData().getInputName(), t.getMessageData().getEventData().getInputMuted())); - } - - private void disconnectController() { - doConnected(false); - connected = false; - if (controller != null) { - controller.disconnect(); - controller.stop(); - controller = null; - } + connect(settings.getObsAddress(), parsePort(settings.getObsPort()), settings.getObsPassword()); } - @Nullable - public String test(String address, int port, String password, long timeout) { - var latch = new CountDownLatch(1); - var result = new String[1]; - Consumer doResult = str -> { - result[0] = str; - latch.countDown(); - }; - - var controller = buildController(address, port, password).lifecycle() - .onReady(() -> doResult.accept(null)) - .onDisconnect(latch::countDown) - .onControllerError(e -> doResult.accept(e.getReason())) - .onCommunicatorError(e -> doResult.accept(e.getReason())) - .onClose(e -> doResult.accept(e.name())) - .and().build(); - controller.connect(); - + private void connect(String host, int port, String password) { try { - var waitSuccess = latch.await(timeout, TimeUnit.MILLISECONDS); - var message = waitSuccess && result[0] == null ? null : result[0]; - controller.disconnect(); - controller.stop(); - return message; - } catch (InterruptedException e) { - log.warn("Unable to wait for the latch"); - } - return null; - } - - private OBSRemoteControllerBuilder buildController(String address, int port, String password) { - return OBSRemoteController.builder() - .autoConnect(false) - .host(address) - .port(port) - .password(password) - .lifecycle() - .withControllerDefaultLogging(false) - .withCommunicatorDefaultLogging(false) - .and(); - } - - private void onError(ReasonThrowable reasonThrowable) { - var exception = reasonThrowable.getThrowable(); - if (exception instanceof ExecutionException exEx) { - exception = exEx.getCause(); - } - - if (exception instanceof SocketTimeoutException || exception instanceof TimeoutException || exception instanceof ConnectException) { - log.debug("Timeout/connect exception occurred", exception); - } else { - log.warn("Unknown OBS error, stack is logged in debug"); - log.debug("Unknown OBS error", exception); + if (client != null) { + client.disconnect(); + } + client = new ObsWebSocketClient(objectMapper, password, + connected -> connectEvent.fire(new OBSConnectEvent(connected)), + event -> muteEvent.fire(event)); + client.connect(host, port, CONNECT_TIMEOUT_MS); + log.info("OBS: connecting to {}:{}", host, port); + } catch (Exception e) { + log.debug("OBS: connection attempt failed: {}", e.getMessage()); } - doConnected(false); - connected = false; } - private boolean settingsStillSame(boolean updatePrevious) { - var port = NumberUtils.toInt(save.get().getObsPort(), -1); - var address = save.get().getObsAddress(); - var password = StringUtils.trimToNull(save.get().getObsPassword()); - var settings = List.of(port, Objects.requireNonNullElse(address, "-"), Objects.requireNonNullElse(password, "-")); - if (settings.equals(previousSettings)) { - return true; - } - if (updatePrevious) { - previousSettings = settings; + private static int parsePort(String port) { + try { + return Integer.parseInt(port); + } catch (NumberFormatException e) { + return 4455; } - return false; } - private void connected() { - log.info("Connected to OBS"); - doConnected(true); - connected = true; - } + // --- Public API used by command classes --- - @EventListener(SaveService.SaveEvent.class) - public void saveUpdated() { - buildAndConnectObsController(); + public boolean isConnected() { + return client != null && client.isConnected(); } public List getSourcesWithAudio() { - var nameToMute = getSourcesWithMuteState(); - return new ArrayList<>(nameToMute.keySet()); + return isConnected() ? client.getSourcesWithAudio() : List.of(); } public Map getSourcesWithMuteState() { - if (!isConnected() || controller == null) { - return Map.of(); - } - var result = controller.getInputList(null, WAIT_TIME_MS); - if (result == null) { - return Map.of(); - } - var sources = result.getInputs(); - return getNameToMuteState(sources); + return isConnected() ? client.getSourcesWithMuteState() : Map.of(); } public List getScenes() { - if (!isConnected() || controller == null) { - return List.of(); - } - - return Optional.ofNullable(controller.getSceneList(WAIT_TIME_MS)) - .map(ss -> StreamEx.of(ss.getScenes()).map(Scene::getSceneName).toList()) - .orElse(List.of()); + return isConnected() ? client.getScenes() : List.of(); } public void setSourceVolume(String sourceName, int vol) { - if (!isConnected() || controller == null) { - return; - } - try { - var decimal = (float) Util.map(vol, 0, 100, -97, 0); - controller.setInputVolume(sourceName, null, decimal, WAIT_TIME_MS); - } catch (Exception e) { - log.error("Unable to get source volume", e); + if (isConnected()) { + client.setSourceVolume(sourceName, vol); } } public void toggleSourceMute(String sourceName) { - if (!isConnected() || controller == null) { - return; - } - try { - controller.toggleInputMute(sourceName, WAIT_TIME_MS); - } catch (Exception e) { - log.error("Unable to toggle source mute {}", sourceName, e); + if (isConnected()) { + client.toggleSourceMute(sourceName); } } public void setSourceMute(String sourceName, boolean mute) { - if (!isConnected() || controller == null) { - return; - } - try { - controller.setInputMute(sourceName, mute, WAIT_TIME_MS); - } catch (Exception e) { - log.error("Unable to set source mute {} {}", sourceName, mute, e); + if (isConnected()) { + client.setSourceMute(sourceName, mute); } } public void setCurrentScene(String sceneName) { - if (!isConnected() || controller == null) { - return; - } - try { - controller.setCurrentProgramScene(sceneName, WAIT_TIME_MS); - } catch (Exception e) { - log.error("Unable to set current scene to {}", sceneName, e); + if (isConnected()) { + client.setCurrentScene(sceneName); } } - public boolean isConnected() { - return save.get().isObsEnabled() && controller != null && connected; - } - - private void doConnected(boolean connected) { - new Thread(() -> { - eventPublisher.publishEvent(new OBSConnectEvent(connected)); - if (connected) { - getSourcesWithMuteState(); - } - }).start(); - } - - private Map getNameToMuteState(List sources) { - if (controller == null) { - return Map.of(); - } - record RequestAndName(GetInputMuteRequest request, String name) { - } - - var muteRequests = StreamEx.of(sources) - .map(source -> { - var req = GetInputMuteRequest.builder().inputName(source.getInputName()).build(); - return new RequestAndName(req, source.getInputName()); - }) - .mapToEntry(rn -> rn.request.getRequestId(), Function.identity()) - .toMap(); - - var latch = new ArrayBlockingQueue(1); - controller.sendRequestBatch(RequestBatch.builder().requests(StreamEx.ofValues(muteRequests).map(RequestAndName::request).toList()).build(), latch::offer); - + /** Returns null on success, or an error message on failure. */ + public String test(String address, int port, String password, long timeout) { + var tester = new ObsWebSocketClient(objectMapper, password, c -> {}, e -> {}); try { - var result = latch.poll(WAIT_TIME_MS, TimeUnit.MILLISECONDS); - if (result == null) { - return Map.of(); - } - return StreamEx.of(result.getData().getResults()) - .mapToEntry(rs -> muteRequests.get(rs.getRequestId()), RequestResponse.Data::getResponseData) - .nonNullKeys().mapKeys(rn -> rn.name) - .nonNullValues() - .selectValues(GetInputMuteResponse.SpecificData.class) - .mapValues(GetInputMuteResponse.SpecificData::getInputMuted) - .toMap(); - } catch (InterruptedException e) { - return Map.of(); - } - } - - static class ObsIdHelper { - private int activeIdx; - - private int incAndGet() { - activeIdx++; - return activeIdx; - } - - private void runIfIdEq(int id, Runnable toRun) { - if (activeIdx == id) { - toRun.run(); - } + tester.connect(address, port, timeout); + Thread.sleep(500); // allow hello/identify exchange + return tester.isConnected() ? null : "Connected but not authenticated"; + } catch (Exception e) { + return e.getMessage(); + } finally { + tester.disconnect(); } } } + diff --git a/src/main/java/com/getpcpanel/obs/ObsConnectedVolumeService.java b/src/main/java/com/getpcpanel/obs/ObsConnectedVolumeService.java index 00e0f57c..4570c183 100644 --- a/src/main/java/com/getpcpanel/obs/ObsConnectedVolumeService.java +++ b/src/main/java/com/getpcpanel/obs/ObsConnectedVolumeService.java @@ -1,27 +1,22 @@ package com.getpcpanel.obs; import java.util.function.Function; - -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; - -import com.getpcpanel.commands.AbstractNewXVolumeService; import com.getpcpanel.commands.command.CommandObsSetSourceVolume; import com.getpcpanel.hid.DeviceHolder; -import com.getpcpanel.spring.ConditionalOnWindows; +import com.getpcpanel.platform.WindowsBuild; -@Service -@ConditionalOnWindows -public class ObsConnectedVolumeService extends AbstractNewXVolumeService { - public ObsConnectedVolumeService(DeviceHolder devices, ApplicationEventPublisher eventPublisher) { - super(devices, eventPublisher); - } +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; + +@ApplicationScoped +@WindowsBuild +public class ObsConnectedVolumeService { + @Inject DeviceHolder devices; - @EventListener - public void onVoiceMeeterConnected(OBSConnectEvent event) { + public void onVoiceMeeterConnected(@Observes OBSConnectEvent event) { if (event.connected()) { - triggerCommandsOf(CommandObsSetSourceVolume.class, Function.identity()); + devices.triggerCommandsOf(CommandObsSetSourceVolume.class, Function.identity()); } } } diff --git a/src/main/java/com/getpcpanel/obs/ObsWebSocketClient.java b/src/main/java/com/getpcpanel/obs/ObsWebSocketClient.java new file mode 100644 index 00000000..bae473cd --- /dev/null +++ b/src/main/java/com/getpcpanel/obs/ObsWebSocketClient.java @@ -0,0 +1,284 @@ +package com.getpcpanel.obs; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import lombok.extern.log4j.Log4j2; + +/** + * OBS WebSocket protocol 5 client built on java.net.http.WebSocket. + * + * Handles: hello/identify handshake, optional SHA-256 password auth, + * request/response correlation, InputMuteStateChanged events. + */ +@Log4j2 +public class ObsWebSocketClient implements WebSocket.Listener { + + // OBS WebSocket 5 opcodes + private static final int OP_HELLO = 0; + private static final int OP_IDENTIFY = 1; + private static final int OP_IDENTIFIED = 2; + private static final int OP_EVENT = 5; + private static final int OP_REQUEST = 6; + private static final int OP_REQUEST_RESPONSE = 7; + + // EventSubscriptions bit: Inputs (for InputMuteStateChanged) + private static final int EVENT_SUB_INPUTS = 1 << 3; + + private final ObjectMapper mapper; + private final String password; + private final Consumer onConnected; + private final Consumer onMuteChange; + + private WebSocket webSocket; + private final ConcurrentHashMap> pending = new ConcurrentHashMap<>(); + private final StringBuilder textBuffer = new StringBuilder(); + private volatile boolean connected = false; + + public ObsWebSocketClient(ObjectMapper mapper, String password, + Consumer onConnected, Consumer onMuteChange) { + this.mapper = mapper; + this.password = password; + this.onConnected = onConnected; + this.onMuteChange = onMuteChange; + } + + public void connect(String host, int port, long timeoutMs) throws Exception { + var uri = URI.create("ws://" + host + ":" + port); + webSocket = HttpClient.newHttpClient() + .newWebSocketBuilder() + .buildAsync(uri, this) + .get(timeoutMs, TimeUnit.MILLISECONDS); + } + + public void disconnect() { + connected = false; + if (webSocket != null) { + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "bye"); + } + } + + public boolean isConnected() { + return connected; + } + + // --- WebSocket.Listener --- + + @Override + public CompletionStage> onText(WebSocket ws, CharSequence data, boolean last) { + textBuffer.append(data); + ws.request(1); + if (!last) { + return null; + } + var text = textBuffer.toString(); + textBuffer.setLength(0); + try { + handleMessage(mapper.readTree(text)); + } catch (Exception e) { + log.warn("OBS: failed to handle message", e); + } + return null; + } + + @Override + public CompletionStage> onClose(WebSocket ws, int statusCode, String reason) { + if (connected) { + connected = false; + onConnected.accept(false); + } + return null; + } + + @Override + public void onError(WebSocket ws, Throwable error) { + log.warn("OBS WebSocket error: {}", error.getMessage()); + if (connected) { + connected = false; + onConnected.accept(false); + } + pending.values().forEach(f -> f.completeExceptionally(error)); + pending.clear(); + } + + // --- Protocol handling --- + + private void handleMessage(JsonNode msg) throws Exception { + var op = msg.path("op").asInt(-1); + var d = msg.path("d"); + switch (op) { + case OP_HELLO -> identify(d); + case OP_IDENTIFIED -> { + connected = true; + log.info("OBS: connected and authenticated"); + onConnected.accept(true); + } + case OP_EVENT -> handleEvent(d); + case OP_REQUEST_RESPONSE -> { + var id = d.path("requestId").asText(null); + var future = id != null ? pending.remove(id) : null; + if (future != null) { + future.complete(d.path("responseData")); + } + } + default -> log.trace("OBS: unhandled opcode {}", op); + } + } + + private void identify(JsonNode hello) throws Exception { + var msg = mapper.createObjectNode(); + msg.put("op", OP_IDENTIFY); + var data = msg.putObject("d"); + data.put("rpcVersion", 1); + data.put("eventSubscriptions", EVENT_SUB_INPUTS); + var authNode = hello.path("authentication"); + if (!authNode.isMissingNode() && !authNode.isNull() && password != null && !password.isBlank()) { + data.put("authentication", computeAuth(password, + authNode.path("salt").asText(), + authNode.path("challenge").asText())); + } + send(msg); + } + + private void handleEvent(JsonNode d) { + var type = d.path("eventType").asText(); + if ("InputMuteStateChanged".equals(type)) { + var data = d.path("eventData"); + onMuteChange.accept(new OBSMuteEvent( + data.path("inputName").asText(), + data.path("inputMuted").asBoolean())); + } + } + + private static String computeAuth(String password, String salt, String challenge) throws Exception { + var md = MessageDigest.getInstance("SHA-256"); + var secret = Base64.getEncoder().encodeToString( + md.digest((password + salt).getBytes(StandardCharsets.UTF_8))); + md.reset(); + return Base64.getEncoder().encodeToString( + md.digest((secret + challenge).getBytes(StandardCharsets.UTF_8))); + } + + private void send(Object obj) { + try { + webSocket.sendText(mapper.writeValueAsString(obj), true); + } catch (Exception e) { + log.warn("OBS: failed to send message", e); + } + } + + private JsonNode request(String type, ObjectNode fields) throws Exception { + var id = UUID.randomUUID().toString(); + var msg = mapper.createObjectNode(); + msg.put("op", OP_REQUEST); + var d = msg.putObject("d"); + d.put("requestType", type); + d.put("requestId", id); + if (fields != null) { + d.set("requestData", fields); + } + var future = new CompletableFuture(); + pending.put(id, future); + send(msg); + return future.get(5, TimeUnit.SECONDS); + } + + // --- High-level OBS operations --- + + public List getSourcesWithAudio() { + try { + var resp = request("GetInputList", null); + var list = new ArrayList(); + resp.path("inputs").forEach(n -> list.add(n.path("inputName").asText())); + return list; + } catch (Exception e) { + log.warn("OBS: GetInputList failed: {}", e.getMessage()); + return List.of(); + } + } + + public Map getSourcesWithMuteState() { + var map = new LinkedHashMap(); + for (var source : getSourcesWithAudio()) { + try { + var fields = mapper.createObjectNode().put("inputName", source); + var resp = request("GetInputMute", fields); + map.put(source, resp.path("inputMuted").asBoolean()); + } catch (Exception e) { + log.trace("OBS: GetInputMute failed for {}: {}", source, e.getMessage()); + } + } + return map; + } + + public List getScenes() { + try { + var resp = request("GetSceneList", null); + var list = new ArrayList(); + resp.path("scenes").forEach(n -> list.add(n.path("sceneName").asText())); + return list; + } catch (Exception e) { + log.warn("OBS: GetSceneList failed: {}", e.getMessage()); + return List.of(); + } + } + + /** vol is 0–100; converted to OBS volume multiplier 0.0–1.0. */ + public void setSourceVolume(String sourceName, int vol) { + try { + var fields = mapper.createObjectNode() + .put("inputName", sourceName) + .put("inputVolumeMultiplier", vol / 100.0); + request("SetInputVolume", fields); + } catch (Exception e) { + log.warn("OBS: SetInputVolume failed: {}", e.getMessage()); + } + } + + public void toggleSourceMute(String sourceName) { + try { + var fields = mapper.createObjectNode().put("inputName", sourceName); + request("ToggleInputMute", fields); + } catch (Exception e) { + log.warn("OBS: ToggleInputMute failed: {}", e.getMessage()); + } + } + + public void setSourceMute(String sourceName, boolean mute) { + try { + var fields = mapper.createObjectNode() + .put("inputName", sourceName) + .put("inputMuted", mute); + request("SetInputMute", fields); + } catch (Exception e) { + log.warn("OBS: SetInputMute failed: {}", e.getMessage()); + } + } + + public void setCurrentScene(String sceneName) { + try { + var fields = mapper.createObjectNode().put("sceneName", sceneName); + request("SetCurrentProgramScene", fields); + } catch (Exception e) { + log.warn("OBS: SetCurrentProgramScene failed: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/com/getpcpanel/osc/OSCService.java b/src/main/java/com/getpcpanel/osc/OSCService.java index 0b01a9c6..77e498c0 100644 --- a/src/main/java/com/getpcpanel/osc/OSCService.java +++ b/src/main/java/com/getpcpanel/osc/OSCService.java @@ -10,14 +10,11 @@ import javax.annotation.Nonnull; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; -import org.springframework.util.CollectionUtils; - -import com.getpcpanel.hid.DeviceCommunicationHandler; -import com.getpcpanel.profile.OSCBinding; -import com.getpcpanel.profile.OSCConnectionInfo; +import com.getpcpanel.hid.DeviceCommunicationHandler.ButtonPressEvent; +import com.getpcpanel.hid.DeviceCommunicationHandler.KnobRotateEvent; import com.getpcpanel.profile.SaveService; +import com.getpcpanel.profile.dto.OSCBinding; +import com.getpcpanel.profile.dto.OSCConnectionInfo; import com.getpcpanel.util.Util; import com.illposed.osc.OSCBadDataEvent; import com.illposed.osc.OSCBundle; @@ -29,16 +26,18 @@ import com.illposed.osc.transport.OSCPortOut; import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; import lombok.Getter; -import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import one.util.streamex.StreamEx; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public class OSCService { - private final SaveService saveService; + @Inject + SaveService saveService; private OSCPortIn portIn; private List ports = List.of(); private Integer prevListenPort; @@ -46,7 +45,6 @@ public class OSCService { @Getter private final Set addresses = new HashSet<>(); @PostConstruct - @EventListener(SaveService.SaveEvent.class) public void saveChanged() { log.trace("Save changed, restarting OSC"); initSend(); @@ -105,14 +103,13 @@ private void stopPortIn() { } } - @EventListener - public void dialAction(DeviceCommunicationHandler.KnobRotateEvent dial) { - if (dial.initial() || CollectionUtils.isEmpty(ports)) { + public void dialAction(@Observes KnobRotateEvent dial) { + if (dial.initial() || ports == null || ports.isEmpty()) { return; } saveService.getProfile(dial.serialNum()).ifPresent(profile -> { - var knobLength = profile.getLightingConfig().getKnobConfigs().length; + var knobLength = profile.lightingConfig().knobConfigs().length; var idx = dial.knob() < knobLength ? dial.knob() * 2 : dial.knob() + knobLength; var target = profile.getOscBinding().get(idx); @@ -122,9 +119,8 @@ public void dialAction(DeviceCommunicationHandler.KnobRotateEvent dial) { }); } - @EventListener - public void dialAction(DeviceCommunicationHandler.ButtonPressEvent button) { - if (CollectionUtils.isEmpty(ports)) { + public void dialAction(@Observes ButtonPressEvent button) { + if (ports == null || ports.isEmpty()) { return; } var idx = button.button() * 2 + 1; @@ -152,7 +148,8 @@ private float determineValue(@Nonnull OSCBinding target, float val) { return Util.map(val, 0, 1, target.min(), target.max()); } - private static @Nonnull OSCMessage buildMessage(OSCBinding target, String defaultTarget, float val) { + @Nonnull + private static OSCMessage buildMessage(OSCBinding target, String defaultTarget, float val) { var targetString = target == null ? defaultTarget : target.address(); try { return new OSCMessage(targetString, List.of(val)); diff --git a/src/main/java/com/getpcpanel/overlay/Overlay.java b/src/main/java/com/getpcpanel/overlay/Overlay.java new file mode 100644 index 00000000..aa6a72bc --- /dev/null +++ b/src/main/java/com/getpcpanel/overlay/Overlay.java @@ -0,0 +1,122 @@ +package com.getpcpanel.overlay; + +import java.awt.Image; +import java.awt.Toolkit; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import javax.swing.SwingUtilities; + +import com.getpcpanel.commands.Commands; +import com.getpcpanel.commands.IconService; +import com.getpcpanel.commands.PCPanelControlEvent; +import com.getpcpanel.commands.command.ButtonAction; +import com.getpcpanel.commands.command.DialAction; +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.profile.SaveService.SaveEvent; +import com.getpcpanel.profile.dto.OverlayPosition; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import lombok.RequiredArgsConstructor; +import one.util.streamex.StreamEx; + +@ApplicationScoped +@RequiredArgsConstructor +public class Overlay { + private final SaveService save; + private final IconService iconService; + private VolumeOverlay overlay = new VolumeOverlay(); + + // @PostConstruct + // public void init() { + // SwingUtilities.invokeLater(() -> { + // overlay; + // }); + // } + + public void updateSaveValues(@Observes SaveEvent event) { + updateStyle(null); + determinePosition(); + } + + private void determinePosition() { + var window = Toolkit.getDefaultToolkit().getScreenSize(); + var x = window.width; + var y = window.height; + var width = overlay.getWidth(); + var height = overlay.getHeight(); + + var position = save == null ? OverlayPosition.topLeft : save.get().getOverlayPosition(); + var padding = save == null ? 0 : save.get().getOverlayPadding(); + var newY = switch (position) { + case topLeft, topMiddle, topRight -> padding; + case middleLeft, middleMiddle, middleRight -> y / 2 - height / 2; + case bottomLeft, bottomMiddle, bottomRight -> y - overlay.getHeight() - padding; + }; + var newX = switch (position) { + case topLeft, middleLeft, bottomLeft -> padding; + case topMiddle, middleMiddle, bottomMiddle -> x / 2 - width / 2; + case topRight, middleRight, bottomRight -> x - width - padding; + }; + setXY(newX, newY); + } + + private void setXY(int x, int y) { + var b = overlay.getBounds(); + b.x = x; + b.y = y; + overlay.setBounds(b); + } + + public void show(float value) { + showDebounced(value, () -> CommandAndIcon.DEFAULT, x -> true); + } + + public void updateStyle(@Nullable @Observes SaveEvent event) { + SwingUtilities.invokeLater(() -> overlay.setStyles(save.get())); + } + + public void handleControl(@Observes PCPanelControlEvent event) { + if (event.initial()) { + return; + } + var vol = event.vol(); + var value = vol == null ? -1 : save.get().isOverlayUseLog() ? vol.getValue(null, 0, 1) : vol.value() / 255f; + showDebounced(value, () -> determineIconImage(event), command -> true); + } + + private void showDebounced(float value, Supplier pre, Predicate pred) { + if (!save.get().isOverlayEnabled()) { + return; + } + SwingUtilities.invokeLater(() -> { + var cai = pre.get(); + if (hasOverlay(cai.command) && pred.test(cai.command)) { + overlay.show(value, cai.icon); + } + }); + } + + private boolean hasOverlay(Commands commands) { + return Commands.hasCommands(commands) && + StreamEx.of(commands.getCommands()).anyMatch(command -> command instanceof DialAction da && da.hasOverlay() + || command instanceof ButtonAction ba && ba.hasOverlay()); + } + + @Nonnull + private CommandAndIcon determineIconImage(PCPanelControlEvent event) { + return save.getProfile(event.serialNum()).map(profile -> { + var data = event.cmd(); + var setting = event.vol() == null ? null : profile.getKnobSettings(event.knob()); + return new CommandAndIcon(data, iconService.getImageFrom(data, setting)); + }).orElse(CommandAndIcon.DEFAULT); + } + + private record CommandAndIcon(Commands command, Image icon) { + static final CommandAndIcon DEFAULT = new CommandAndIcon(Commands.EMPTY, null); + } +} diff --git a/src/main/java/com/getpcpanel/overlay/VolumeOverlay.java b/src/main/java/com/getpcpanel/overlay/VolumeOverlay.java new file mode 100644 index 00000000..8e9fe608 --- /dev/null +++ b/src/main/java/com/getpcpanel/overlay/VolumeOverlay.java @@ -0,0 +1,265 @@ +package com.getpcpanel.overlay; + +import java.awt.Color; +import java.awt.Font; +import java.awt.GradientPaint; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.RenderingHints; +import java.awt.Toolkit; +import java.awt.geom.Ellipse2D; +import java.awt.geom.RoundRectangle2D; +import java.util.regex.Pattern; + +import javax.swing.JPanel; +import javax.swing.JWindow; +import javax.swing.Timer; +import javax.swing.UIManager; + +import com.getpcpanel.profile.Save; + +public class VolumeOverlay extends JWindow { + // Install the cross-platform (Metal) Look and Feel before any Swing + // component is constructed. This static block runs when VolumeOverlay is + // first loaded – before the implicit JWindow() super-constructor call – + // so JPanel / JRootPane can find their ComponentUI delegates. + static { + try { + UIManager.setLookAndFeel(UIManager.getCrossPlatformLookAndFeelClassName()); + } catch (Exception ignored) { + // If Metal L&F is unavailable in native image, swallow and continue. + } + } + + private static final int WIDTH = 340; + private static final int DEFAULT_HEIGHT = 56; + private static final int DEFAULT_CORNER_RADIUS = 28; + private static final int CONTENT_PADDING = 10; + private static final int ICON_SIZE = 36; + private static final int DEFAULT_BAR_HEIGHT = 10; + private static final int DEFAULT_BAR_CORNER_RADIUS = DEFAULT_BAR_HEIGHT; + private static final int VALUE_LABEL_WIDTH = 36; + private static final int VALUE_GAP = 8; + private static final int DISMISS_MS = 2000; // auto-hide after 2 s + private static final Pattern RGB_PATTERN = Pattern.compile("rgba?\\(([^)]+)\\)", Pattern.CASE_INSENSITIVE); + private static final Pattern COLOR_COMPONENT_SEPARATOR = Pattern.compile("\\s*,\\s*"); + + private static final Color DEFAULT_BG_COLOR = new Color(80, 80, 90, 210); + private static final Color DEFAULT_BAR_COLOR = new Color(0, 200, 230, 255); + private static final Color DEFAULT_BAR_TRACK_COLOR = new Color(255, 255, 255, 50); + private static final Color DEFAULT_TEXT_COLOR = new Color(230, 230, 230, 255); + + private int value; + private final Timer dismissTimer; + private Image icon; + private boolean showNumber = true; + private int windowCornerRadius = DEFAULT_CORNER_RADIUS; + private int barHeight = DEFAULT_BAR_HEIGHT; + private int barCornerRadius = DEFAULT_BAR_CORNER_RADIUS; + private Color backgroundColor = DEFAULT_BG_COLOR; + private Color barColor = DEFAULT_BAR_COLOR; + private Color barTrackColor = DEFAULT_BAR_TRACK_COLOR; + private Color textColor = DEFAULT_TEXT_COLOR; + + VolumeOverlay() { + setAlwaysOnTop(true); + setSize(WIDTH, DEFAULT_HEIGHT); + setBackground(new Color(0, 0, 0, 0)); + + JPanel panel = new OverlayPanel(); + panel.setOpaque(false); + setContentPane(panel); + + var screen = Toolkit.getDefaultToolkit().getScreenSize(); + setLocation((screen.width - WIDTH) / 2, 48); + + dismissTimer = new Timer(DISMISS_MS, _ -> setVisible(false)); + dismissTimer.setRepeats(false); + } + + public void show(float v, Image icon) { + this.icon = icon; + update(Math.round(v * 100f)); + } + + public void setStyles(Save save) { + showNumber = save.isOverlayShowNumber(); + backgroundColor = parseColor(save.getOverlayBackgroundColor(), DEFAULT_BG_COLOR); + textColor = parseColor(save.getOverlayTextColor(), DEFAULT_TEXT_COLOR); + barColor = parseColor(save.getOverlayBarColor(), DEFAULT_BAR_COLOR); + barTrackColor = parseColor(save.getOverlayBarBackgroundColor(), DEFAULT_BAR_TRACK_COLOR); + windowCornerRadius = Math.max(0, save.getOverlayWindowCornerRounding()); + barHeight = Math.max(2, save.getOverlayBarHeight()); + barCornerRadius = Math.max(0, save.getOverlayBarCornerRounding()); + + var computedHeight = Math.max(DEFAULT_HEIGHT, CONTENT_PADDING * 2 + Math.max(ICON_SIZE, barHeight)); + setSize(WIDTH, computedHeight); + revalidate(); + repaint(); + } + + private void update(int v) { + value = Math.clamp(v, 0, 100); + repaint(); + setVisible(true); + dismissTimer.restart(); + } + + private class OverlayPanel extends JPanel { + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + var g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_LCD_HRGB); + + var w = getWidth(); + var h = getHeight(); + var windowArc = Math.min(windowCornerRadius, Math.min(w, h)); + var barArc = Math.min(barCornerRadius, barHeight); + + // ── Background pill ────────────────────────────────────────── + g2.setColor(backgroundColor); + g2.fill(new RoundRectangle2D.Float(0, 0, w, h, windowArc, windowArc)); + + // Subtle top highlight (glass shimmer) + var gloss = new GradientPaint( + 0, 0, withAlpha(Color.WHITE, Math.clamp(backgroundColor.getAlpha() / 4, 18, 60)), + 0, h / 2f, withAlpha(Color.WHITE, 0)); + g2.setPaint(gloss); + g2.fill(new RoundRectangle2D.Float(0, 0, w, h / 2f, windowArc, windowArc)); + + // ── Icon area ──────────────────────────────────────────────── + var iconX = CONTENT_PADDING + 2; + var iconY = (h - ICON_SIZE) / 2; + if (icon != null) { + g2.drawImage(icon, iconX, iconY, ICON_SIZE, ICON_SIZE, null); + } + + // ── Layout constants ───────────────────────────────────────── + var afterIcon = iconX + ICON_SIZE + CONTENT_PADDING; + var valueWidth = showNumber ? VALUE_LABEL_WIDTH : 0; + var barEndX = w - CONTENT_PADDING - valueWidth - (showNumber ? VALUE_GAP : 0); + var barY = (h - barHeight) / 2; + var barWidth = barEndX - afterIcon; + + // ── Progress bar track ─────────────────────────────────────── + g2.setColor(barTrackColor); + g2.fill(new RoundRectangle2D.Float(afterIcon, barY, barWidth, barHeight, barArc, barArc)); + + // ── Progress bar fill ──────────────────────────────────────── + var fillWidth = Math.round(barWidth * (value / 100f)); + if (fillWidth > 0) { + var fillGrad = new GradientPaint( + afterIcon, 0, scaleColor(barColor, 1.15f), + afterIcon + fillWidth, 0, scaleColor(barColor, 0.82f)); + g2.setPaint(fillGrad); + g2.fill(new RoundRectangle2D.Float(afterIcon, barY, fillWidth, barHeight, barArc, barArc)); + + // Bright leading cap + if (fillWidth >= barHeight) { + g2.setColor(withAlpha(scaleColor(barColor, 1.35f), Math.clamp(barColor.getAlpha(), 120, 220))); + var capX = afterIcon + fillWidth - barHeight; + g2.fill(new Ellipse2D.Float(capX, barY, barHeight, barHeight)); + } + } + + // ── Value label ────────────────────────────────────────────── + if (showNumber) { + g2.setColor(textColor); + g2.setFont(new Font("SF Pro Display", Font.BOLD, 16)); + // Fallback font chain + if (!g2.getFont().getFamily().equals("SF Pro Display")) { + g2.setFont(new Font("Segoe UI", Font.BOLD, 16)); + } + var label = String.valueOf(value); + var fm = g2.getFontMetrics(); + var labelX = w - CONTENT_PADDING - valueWidth + (valueWidth - fm.stringWidth(label)) / 2; + var labelY = (h + fm.getAscent() - fm.getDescent()) / 2; + g2.drawString(label, labelX, labelY); + } + + g2.dispose(); + } + } + + private static Color parseColor(String value, Color fallback) { + if (value == null || value.isBlank()) { + return fallback; + } + + try { + var trimmed = value.trim(); + if (trimmed.startsWith("#")) { + return parseHexColor(trimmed); + } + + var matcher = RGB_PATTERN.matcher(trimmed); + if (matcher.matches()) { + var parts = COLOR_COMPONENT_SEPARATOR.split(matcher.group(1)); + if (parts.length == 3 || parts.length == 4) { + var red = clampChannel(Integer.parseInt(parts[0])); + var green = clampChannel(Integer.parseInt(parts[1])); + var blue = clampChannel(Integer.parseInt(parts[2])); + var alpha = parts.length == 4 ? parseAlpha(parts[3]) : 255; + return new Color(red, green, blue, alpha); + } + } + } catch (RuntimeException ignored) { + // Fall back to default styling for invalid persisted values. + } + + return fallback; + } + + private static Color parseHexColor(String value) { + var hex = value.substring(1); + return switch (hex.length()) { + case 3 -> new Color( + Integer.parseInt(hex.substring(0, 1).repeat(2), 16), + Integer.parseInt(hex.substring(1, 2).repeat(2), 16), + Integer.parseInt(hex.substring(2, 3).repeat(2), 16)); + case 4 -> new Color( + Integer.parseInt(hex.substring(0, 1).repeat(2), 16), + Integer.parseInt(hex.substring(1, 2).repeat(2), 16), + Integer.parseInt(hex.substring(2, 3).repeat(2), 16), + Integer.parseInt(hex.substring(3, 4).repeat(2), 16)); + case 6 -> new Color( + Integer.parseInt(hex.substring(0, 2), 16), + Integer.parseInt(hex.substring(2, 4), 16), + Integer.parseInt(hex.substring(4, 6), 16)); + case 8 -> new Color( + Integer.parseInt(hex.substring(0, 2), 16), + Integer.parseInt(hex.substring(2, 4), 16), + Integer.parseInt(hex.substring(4, 6), 16), + Integer.parseInt(hex.substring(6, 8), 16)); + default -> throw new IllegalArgumentException("Unsupported color format: " + value); + }; + } + + private static int parseAlpha(String value) { + var alpha = Double.parseDouble(value); + return clampChannel(roundToInt(alpha <= 1 ? alpha * 255 : alpha)); + } + + private static int roundToInt(double value) { + return Long.valueOf(Math.round(value)).intValue(); + } + + private static int clampChannel(int value) { + return Math.clamp(value, 0, 255); + } + + private static Color withAlpha(Color color, int alpha) { + return new Color(color.getRed(), color.getGreen(), color.getBlue(), clampChannel(alpha)); + } + + private static Color scaleColor(Color color, float factor) { + return new Color( + clampChannel(Math.round(color.getRed() * factor)), + clampChannel(Math.round(color.getGreen() * factor)), + clampChannel(Math.round(color.getBlue() * factor)), + color.getAlpha()); + } +} diff --git a/src/main/java/com/getpcpanel/platform/LinuxBuild.java b/src/main/java/com/getpcpanel/platform/LinuxBuild.java new file mode 100644 index 00000000..52690ac5 --- /dev/null +++ b/src/main/java/com/getpcpanel/platform/LinuxBuild.java @@ -0,0 +1,19 @@ +package com.getpcpanel.platform; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import io.quarkus.arc.properties.IfBuildProperty; +import jakarta.enterprise.inject.Stereotype; + +@Stereotype +@IfBuildProperty(name = "pcpanel.build.os", stringValue = "linux") +@Retention(RUNTIME) +@Target(TYPE) +public @interface LinuxBuild { +} + + diff --git a/src/main/java/com/getpcpanel/platform/WindowsBuild.java b/src/main/java/com/getpcpanel/platform/WindowsBuild.java new file mode 100644 index 00000000..13319075 --- /dev/null +++ b/src/main/java/com/getpcpanel/platform/WindowsBuild.java @@ -0,0 +1,18 @@ +package com.getpcpanel.platform; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import io.quarkus.arc.properties.IfBuildProperty; +import jakarta.enterprise.inject.Stereotype; + +@Stereotype +@IfBuildProperty(name = "pcpanel.build.os", stringValue = "windows") +@Retention(RUNTIME) +@Target(TYPE) +public @interface WindowsBuild { +} + diff --git a/src/main/java/com/getpcpanel/profile/DeviceSave.java b/src/main/java/com/getpcpanel/profile/DeviceSave.java index 10411cf4..9dc517e8 100644 --- a/src/main/java/com/getpcpanel/profile/DeviceSave.java +++ b/src/main/java/com/getpcpanel/profile/DeviceSave.java @@ -46,14 +46,14 @@ public Optional getProfile(@Nullable String name) { if (name == null) { return Optional.empty(); } - return StreamEx.of(getProfiles()).findFirst(p -> p.getName().equals(name)); + return StreamEx.of(profiles).findFirst(p -> p.getName().equals(name)); } @JsonIgnore private Optional getCurrentProfile() { var p = getProfile(currentProfileName); if (!profiles.isEmpty() && p.isEmpty()) { - return Optional.of(getProfiles().get(0)); + return Optional.of(profiles.get(0)); } return p; } diff --git a/src/main/java/com/getpcpanel/profile/KnobSettingMapDeserializer.java b/src/main/java/com/getpcpanel/profile/KnobSettingMapDeserializer.java index 207c60b1..3f214743 100644 --- a/src/main/java/com/getpcpanel/profile/KnobSettingMapDeserializer.java +++ b/src/main/java/com/getpcpanel/profile/KnobSettingMapDeserializer.java @@ -9,6 +9,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; +import com.getpcpanel.profile.dto.KnobSetting; public class KnobSettingMapDeserializer extends JsonDeserializer> { @Override diff --git a/src/main/java/com/getpcpanel/profile/LightingChangedToDefaultEvent.java b/src/main/java/com/getpcpanel/profile/LightingChangedToDefaultEvent.java new file mode 100644 index 00000000..e356d4eb --- /dev/null +++ b/src/main/java/com/getpcpanel/profile/LightingChangedToDefaultEvent.java @@ -0,0 +1,8 @@ +package com.getpcpanel.profile; + +/** + * Fired when lighting is changed back to its default/profile setting. + * Moved from com.getpcpanel.ui to profile package as part of Quarkus migration. + */ +public record LightingChangedToDefaultEvent(String serialNum) { +} diff --git a/src/main/java/com/getpcpanel/profile/Profile.java b/src/main/java/com/getpcpanel/profile/Profile.java index 42196830..8176e6c8 100644 --- a/src/main/java/com/getpcpanel/profile/Profile.java +++ b/src/main/java/com/getpcpanel/profile/Profile.java @@ -9,6 +9,9 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.getpcpanel.commands.Commands; import com.getpcpanel.device.DeviceType; +import com.getpcpanel.profile.dto.KnobSetting; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.profile.dto.OSCBinding; import lombok.Data; @@ -34,7 +37,7 @@ public Profile(String name, DeviceType dt) { protected Profile() { } - public LightingConfig getLightingConfig() { + public LightingConfig lightingConfig() { return lightingConfig.deepCopy(); } diff --git a/src/main/java/com/getpcpanel/profile/ProfileWindowFocusService.java b/src/main/java/com/getpcpanel/profile/ProfileWindowFocusService.java index ffb4064e..fc95107c 100644 --- a/src/main/java/com/getpcpanel/profile/ProfileWindowFocusService.java +++ b/src/main/java/com/getpcpanel/profile/ProfileWindowFocusService.java @@ -1,21 +1,21 @@ package com.getpcpanel.profile; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; +import jakarta.inject.Inject; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.context.ApplicationScoped; import com.getpcpanel.cpp.windows.WindowFocusChangedEvent; import com.getpcpanel.hid.DeviceHolder; import lombok.RequiredArgsConstructor; -@Service -@RequiredArgsConstructor +@ApplicationScoped public class ProfileWindowFocusService { - private final DeviceHolder devices; + @Inject + DeviceHolder devices; private String previousApplication = ""; - @EventListener - public void onFocusChanged(WindowFocusChangedEvent event) { + public void onFocusChanged(@Observes WindowFocusChangedEvent event) { devices.values().forEach(d -> d.focusChanged(previousApplication, event.application())); previousApplication = event.application(); } diff --git a/src/main/java/com/getpcpanel/profile/Save.java b/src/main/java/com/getpcpanel/profile/Save.java index df9bb4a1..b8791689 100644 --- a/src/main/java/com/getpcpanel/profile/Save.java +++ b/src/main/java/com/getpcpanel/profile/Save.java @@ -9,7 +9,10 @@ import javax.annotation.Nullable; import com.getpcpanel.device.DeviceType; -import com.getpcpanel.ui.OverlayPosition; +import com.getpcpanel.profile.dto.MqttSettings; +import com.getpcpanel.profile.dto.OSCConnectionInfo; +import com.getpcpanel.profile.dto.OverlayPosition; +import com.getpcpanel.profile.dto.WaveLinkSettings; import lombok.Data; import lombok.extern.log4j.Log4j2; @@ -53,7 +56,7 @@ public class Save { private String overlayTextColor = DEFAULT_OVERLAY_TEXT_COLOR; private String overlayBarColor = DEFAULT_OVERLAY_BAR_COLOR; private String overlayBarBackgroundColor = DEFAULT_OVERLAY_BAR_BACKGROUND_COLOR; - @Nullable private Integer overlayWindowCornerRounding = 0; + private int overlayWindowCornerRounding; @Nullable private Integer overlayBarHeight = DEFAULT_OVERLAY_BAR_HEIGHT; @Nullable private Integer overlayBarCornerRounding = 0; @Nullable private OverlayPosition overlayPosition = DEFAULT_OVERLAY_POSITION; @@ -85,10 +88,6 @@ public void setSendOnlyIfDelta(Integer sendOnlyIfDelta) { this.sendOnlyIfDelta = sendOnlyIfDelta == null || sendOnlyIfDelta == 0 ? null : sendOnlyIfDelta; } - public int getOverlayWindowCornerRounding() { - return overlayWindowCornerRounding == null ? 0 : overlayWindowCornerRounding; - } - public int getOverlayBarCornerRounding() { return overlayBarCornerRounding == null ? 0 : overlayBarCornerRounding; } diff --git a/src/main/java/com/getpcpanel/profile/SaveService.java b/src/main/java/com/getpcpanel/profile/SaveService.java index 773029b5..a77309bf 100644 --- a/src/main/java/com/getpcpanel/profile/SaveService.java +++ b/src/main/java/com/getpcpanel/profile/SaveService.java @@ -11,35 +11,35 @@ import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.annotation.Lazy; -import org.springframework.stereotype.Service; import com.getpcpanel.Json; import com.getpcpanel.hid.DeviceHolder; import com.getpcpanel.util.Debouncer; import com.getpcpanel.util.FileUtil; +import io.quarkus.runtime.StartupEvent; import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import lombok.Setter; +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; import lombok.extern.log4j.Log4j2; import one.util.streamex.StreamEx; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public class SaveService { private static final String saveFileName = "profiles.json"; - private final ApplicationEventPublisher eventPublisher; - private final FileUtil fileUtil; - private final Json json; - private final Debouncer debouncer; - @Autowired @Lazy @Setter private DeviceHolder devices; + @Inject Event eventBus; + @Inject FileUtil fileUtil; + @Inject Json json; + @Inject Debouncer debouncer; + @Inject DeviceHolder devices; @SuppressWarnings("StaticNonFinalField") private static String oldVersionEncountered; private Save save; + private boolean isNew = false; public Save get() { return save; @@ -54,27 +54,45 @@ public void load() { if (!saveFile.exists()) { log.info("No save file found, creating new one"); save = new Save(); - eventPublisher.publishEvent(new SaveEvent(save, true)); + isNew = true; return; } try { save = json.read(FileUtils.readFileToString(saveFile, Charset.defaultCharset()), Save.class); handleOldVersionEncountered(); - StreamEx.ofValues(save.getDevices()).forEach(d -> StreamEx.of(d.getProfiles()).findFirst(Profile::isMainProfile).ifPresent(p -> d.setCurrentProfile(p.getName()))); - eventPublisher.publishEvent(new SaveEvent(save, false)); + StreamEx.ofValues(save.getDevices()).forEach(d -> StreamEx.of(d.getProfiles()).findFirst(p -> p.isMainProfile()).ifPresent(p -> d.setCurrentProfile(p.getName()))); } catch (Exception e) { log.error("Unable to read file", e); save = new Save(); + isNew = true; } } + /** + * Fire the initial SaveEvent after all beans are fully initialized. + * Using @Priority(1) to ensure this runs before DeviceScanner.onStart() (default priority ~2000). + */ + @Priority(1) + public void onStart(@Observes StartupEvent ev) { + eventBus.fire(new SaveEvent(save, isNew)); + } + private void handleOldVersionEncountered() { if (StringUtils.isBlank(oldVersionEncountered)) { return; } backup(); - save(); + writeToFile(); // write file only, SaveEvent will be fired from onStart() + } + + private void writeToFile() { + var saveFile = fileUtil.getFile(saveFileName); + try { + FileUtils.writeStringToFile(saveFile, json.writePretty(save), Charset.defaultCharset()); + } catch (IOException e) { + log.error("Unable to save file", e); + } } private void backup() { @@ -108,14 +126,8 @@ private void tryMigrate(File saveFile) { } public void save() { - var saveFile = fileUtil.getFile(saveFileName); - try { - FileUtils.writeStringToFile(saveFile, json.writePretty(save), Charset.defaultCharset()); - } catch (IOException e) { - log.error("Unable to save file", e); - } - - eventPublisher.publishEvent(new SaveEvent(save, false)); + writeToFile(); + eventBus.fire(new SaveEvent(save, false)); } public void debouncedSave() { @@ -123,10 +135,9 @@ public void debouncedSave() { } public Optional getProfile(String serialNum) { - return devices.getDevice(serialNum).map(device -> get().getDeviceSave(serialNum).ensureCurrentProfile(device.getDeviceType())); + return devices.getDevice(serialNum).map(device -> get().getDeviceSave(serialNum).ensureCurrentProfile(device.deviceType())); } public record SaveEvent(Save save, boolean isNew) { } } - diff --git a/src/main/java/com/getpcpanel/profile/KnobSetting.java b/src/main/java/com/getpcpanel/profile/dto/KnobSetting.java similarity index 85% rename from src/main/java/com/getpcpanel/profile/KnobSetting.java rename to src/main/java/com/getpcpanel/profile/dto/KnobSetting.java index 03706ff6..052b76cb 100644 --- a/src/main/java/com/getpcpanel/profile/KnobSetting.java +++ b/src/main/java/com/getpcpanel/profile/dto/KnobSetting.java @@ -1,4 +1,4 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; import lombok.Data; diff --git a/src/main/java/com/getpcpanel/profile/LightingConfig.java b/src/main/java/com/getpcpanel/profile/dto/LightingConfig.java similarity index 77% rename from src/main/java/com/getpcpanel/profile/LightingConfig.java rename to src/main/java/com/getpcpanel/profile/dto/LightingConfig.java index eb0a8f86..4d1dd77f 100644 --- a/src/main/java/com/getpcpanel/profile/LightingConfig.java +++ b/src/main/java/com/getpcpanel/profile/dto/LightingConfig.java @@ -1,11 +1,11 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; import java.util.Arrays; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; import com.getpcpanel.device.DeviceType; -import com.getpcpanel.util.Util; -import javafx.scene.paint.Color; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -13,6 +13,7 @@ @AllArgsConstructor @Builder(toBuilder = true) +@JsonAutoDetect(fieldVisibility = Visibility.ANY) public class LightingConfig { private LightingMode lightingMode; private String[] individualColors = {}; @@ -89,31 +90,6 @@ public static LightingConfig defaultLightingConfig(DeviceType dt) { throw new IllegalArgumentException("unknown deviceType"); } - public static LightingConfig createSingleColor(Color[] color, boolean[] volumeTracking) { - var lc = new LightingConfig(); - lc.lightingMode = LightingMode.SINGLE_COLOR; - lc.individualColors = new String[color.length]; - for (var i = 0; i < color.length; i++) - lc.individualColors[i] = Util.formatHexString(color[i]); - lc.volumeBrightnessTrackingEnabled = volumeTracking; - return lc; - } - - public static LightingConfig createAllColor(Color color, boolean[] volumeTracking) { - var lc = new LightingConfig(); - lc.lightingMode = LightingMode.ALL_COLOR; - lc.allColor = Util.formatHexString(color); - lc.volumeBrightnessTrackingEnabled = volumeTracking; - return lc; - } - - public static LightingConfig createAllColor(Color color) { - var lc = new LightingConfig(); - lc.lightingMode = LightingMode.ALL_COLOR; - lc.allColor = Util.formatHexString(color); - return lc; - } - public static LightingConfig createRainbowAnimation(byte phaseShift, byte brightness, byte speed, boolean reverse) { var lc = new LightingConfig(); lc.lightingMode = LightingMode.ALL_RAINBOW; @@ -155,7 +131,14 @@ public static LightingConfig createBreathAnimation(byte hue, byte brightness, by return lc; } - public LightingMode getLightingMode() { + public static LightingConfig createAllColor(String color) { + var lc = new LightingConfig(); + lc.lightingMode = LightingMode.ALL_COLOR; + lc.allColor = color; + return lc; + } + + public LightingMode lightingMode() { return lightingMode; } @@ -163,37 +146,37 @@ public void setLightingMode(LightingMode lightingMode) { this.lightingMode = lightingMode; } - public String[] getIndividualColors() { + public String[] individualColors() { return individualColors; } - public String getAllColor() { + public String allColor() { return allColor; } - public boolean[] getVolumeBrightnessTrackingEnabled() { + public boolean[] volumeBrightnessTrackingEnabled() { if (volumeBrightnessTrackingEnabled == null) volumeBrightnessTrackingEnabled = new boolean[0]; return volumeBrightnessTrackingEnabled; } - public byte getRainbowPhaseShift() { + public byte rainbowPhaseShift() { return rainbowPhaseShift; } - public byte getRainbowBrightness() { + public byte rainbowBrightness() { return rainbowBrightness; } - public byte getRainbowSpeed() { + public byte rainbowSpeed() { return rainbowSpeed; } - public byte getRainbowReverse() { + public byte rainbowReverse() { return rainbowReverse; } - public byte getRainbowVertical() { + public byte rainbowVertical() { return rainbowVertical; } @@ -209,52 +192,54 @@ public void setRainbowSpeed(byte rainbowSpeed) { this.rainbowSpeed = rainbowSpeed; } - public byte getWaveHue() { + public byte waveHue() { return waveHue; } - public byte getWaveBrightness() { + public byte waveBrightness() { return waveBrightness; } - public byte getWaveSpeed() { + public byte waveSpeed() { return waveSpeed; } - public byte getWaveReverse() { + public byte waveReverse() { return waveReverse; } - public byte getWaveBounce() { + public byte waveBounce() { return waveBounce; } - public byte getBreathHue() { + public byte breathHue() { return breathHue; } - public byte getBreathBrightness() { + public byte breathBrightness() { return breathBrightness; } - public byte getBreathSpeed() { + public byte breathSpeed() { return breathSpeed; } - public SingleKnobLightingConfig[] getKnobConfigs() { + public SingleKnobLightingConfig[] knobConfigs() { return knobConfigs; } - public SingleSliderLabelLightingConfig[] getSliderLabelConfigs() { + public SingleSliderLabelLightingConfig[] sliderLabelConfigs() { return sliderLabelConfigs; } - public SingleSliderLightingConfig[] getSliderConfigs() { + public SingleSliderLightingConfig[] sliderConfigs() { return sliderConfigs; } - public SingleLogoLightingConfig getLogoConfig() { + public SingleLogoLightingConfig logoConfig() { + if (logoConfig == null) { + logoConfig = new SingleLogoLightingConfig(); + } return logoConfig; } } - diff --git a/src/main/java/com/getpcpanel/profile/MqttSettings.java b/src/main/java/com/getpcpanel/profile/dto/MqttSettings.java similarity index 96% rename from src/main/java/com/getpcpanel/profile/MqttSettings.java rename to src/main/java/com/getpcpanel/profile/dto/MqttSettings.java index ca89202c..9284dd70 100644 --- a/src/main/java/com/getpcpanel/profile/MqttSettings.java +++ b/src/main/java/com/getpcpanel/profile/dto/MqttSettings.java @@ -1,4 +1,4 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; public record MqttSettings(boolean enabled, String host, Integer port, String username, String password, boolean secure, String baseTopic, diff --git a/src/main/java/com/getpcpanel/profile/OSCBinding.java b/src/main/java/com/getpcpanel/profile/dto/OSCBinding.java similarity index 81% rename from src/main/java/com/getpcpanel/profile/OSCBinding.java rename to src/main/java/com/getpcpanel/profile/dto/OSCBinding.java index 8ed7249e..6b0c4b69 100644 --- a/src/main/java/com/getpcpanel/profile/OSCBinding.java +++ b/src/main/java/com/getpcpanel/profile/dto/OSCBinding.java @@ -1,4 +1,4 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; public record OSCBinding(String address, float min, float max, boolean toggle) { public static final OSCBinding EMPTY = new OSCBinding("", 0, 1, false); diff --git a/src/main/java/com/getpcpanel/profile/OSCConnectionInfo.java b/src/main/java/com/getpcpanel/profile/dto/OSCConnectionInfo.java similarity index 62% rename from src/main/java/com/getpcpanel/profile/OSCConnectionInfo.java rename to src/main/java/com/getpcpanel/profile/dto/OSCConnectionInfo.java index 059301ce..d522db31 100644 --- a/src/main/java/com/getpcpanel/profile/OSCConnectionInfo.java +++ b/src/main/java/com/getpcpanel/profile/dto/OSCConnectionInfo.java @@ -1,4 +1,4 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; public record OSCConnectionInfo(String host, int port) { } diff --git a/src/main/java/com/getpcpanel/profile/dto/OverlayPosition.java b/src/main/java/com/getpcpanel/profile/dto/OverlayPosition.java new file mode 100644 index 00000000..8170d8b5 --- /dev/null +++ b/src/main/java/com/getpcpanel/profile/dto/OverlayPosition.java @@ -0,0 +1,9 @@ +package com.getpcpanel.profile.dto; + +/** + * Position options for the on-screen overlay. + * Moved from com.getpcpanel.ui to profile package as part of Quarkus migration. + */ +public enum OverlayPosition { + topLeft, topMiddle, topRight, middleLeft, middleMiddle, middleRight, bottomLeft, bottomMiddle, bottomRight +} diff --git a/src/main/java/com/getpcpanel/profile/SingleKnobLightingConfig.java b/src/main/java/com/getpcpanel/profile/dto/SingleKnobLightingConfig.java similarity index 50% rename from src/main/java/com/getpcpanel/profile/SingleKnobLightingConfig.java rename to src/main/java/com/getpcpanel/profile/dto/SingleKnobLightingConfig.java index 1234b62b..133605aa 100644 --- a/src/main/java/com/getpcpanel/profile/SingleKnobLightingConfig.java +++ b/src/main/java/com/getpcpanel/profile/dto/SingleKnobLightingConfig.java @@ -1,11 +1,7 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; import javax.annotation.Nullable; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.getpcpanel.util.Util; - -import javafx.scene.paint.Color; import lombok.Data; @Data @@ -24,25 +20,6 @@ public enum SINGLE_KNOB_MODE { NONE, STATIC, VOLUME_GRADIENT } - @JsonIgnore - public void setColor1FromColor(Color color1) { - this.color1 = Util.formatHexString(color1); - } - - @JsonIgnore - public void setColor2FromColor(Color color2) { - this.color2 = Util.formatHexString(color2); - } - - @JsonIgnore - public void setMuteOverrideColorFromColor(Color color) { - if (color == null) { - muteOverrideColor = null; - } else { - muteOverrideColor = Util.formatHexString(color); - } - } - public void set(SingleKnobLightingConfig c) { color1 = c.color1; color2 = c.color2; @@ -50,4 +27,3 @@ public void set(SingleKnobLightingConfig c) { mode = c.mode; } } - diff --git a/src/main/java/com/getpcpanel/profile/SingleLogoLightingConfig.java b/src/main/java/com/getpcpanel/profile/dto/SingleLogoLightingConfig.java similarity index 68% rename from src/main/java/com/getpcpanel/profile/SingleLogoLightingConfig.java rename to src/main/java/com/getpcpanel/profile/dto/SingleLogoLightingConfig.java index 105bc463..ea6935a3 100644 --- a/src/main/java/com/getpcpanel/profile/SingleLogoLightingConfig.java +++ b/src/main/java/com/getpcpanel/profile/dto/SingleLogoLightingConfig.java @@ -1,8 +1,5 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; -import com.getpcpanel.util.Util; - -import javafx.scene.paint.Color; import lombok.Data; @Data @@ -21,11 +18,6 @@ public enum SINGLE_LOGO_MODE { NONE, STATIC, RAINBOW, BREATH } - public SingleLogoLightingConfig setColor(Color color) { - this.color = Util.formatHexString(color); - return this; - } - /** * Used by Jackson */ @@ -34,4 +26,3 @@ public SingleLogoLightingConfig setColor(String color) { return this; } } - diff --git a/src/main/java/com/getpcpanel/profile/SingleSliderLabelLightingConfig.java b/src/main/java/com/getpcpanel/profile/dto/SingleSliderLabelLightingConfig.java similarity index 55% rename from src/main/java/com/getpcpanel/profile/SingleSliderLabelLightingConfig.java rename to src/main/java/com/getpcpanel/profile/dto/SingleSliderLabelLightingConfig.java index f3ad63cc..4f934176 100644 --- a/src/main/java/com/getpcpanel/profile/SingleSliderLabelLightingConfig.java +++ b/src/main/java/com/getpcpanel/profile/dto/SingleSliderLabelLightingConfig.java @@ -1,9 +1,5 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.getpcpanel.util.Util; - -import javafx.scene.paint.Color; import lombok.Data; @Data @@ -22,19 +18,8 @@ public enum SINGLE_SLIDER_LABEL_MODE { NONE, STATIC } - @JsonIgnore - public void setColorFromColor(Color color) { - this.color = Util.formatHexString(color); - } - - @JsonIgnore - public void setMuteOverrideColorFromColor(Color color) { - muteOverrideColor = Util.formatHexString(color); - } - public void set(SingleSliderLabelLightingConfig c) { color = c.color; mode = c.mode; } } - diff --git a/src/main/java/com/getpcpanel/profile/SingleSliderLightingConfig.java b/src/main/java/com/getpcpanel/profile/dto/SingleSliderLightingConfig.java similarity index 51% rename from src/main/java/com/getpcpanel/profile/SingleSliderLightingConfig.java rename to src/main/java/com/getpcpanel/profile/dto/SingleSliderLightingConfig.java index d3f76174..98047c7c 100644 --- a/src/main/java/com/getpcpanel/profile/SingleSliderLightingConfig.java +++ b/src/main/java/com/getpcpanel/profile/dto/SingleSliderLightingConfig.java @@ -1,9 +1,5 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.getpcpanel.util.Util; - -import javafx.scene.paint.Color; import lombok.Data; @Data @@ -22,25 +18,9 @@ public enum SINGLE_SLIDER_MODE { NONE, STATIC, STATIC_GRADIENT, VOLUME_GRADIENT } - @JsonIgnore - public void setColor1FromColor(Color color1) { - this.color1 = Util.formatHexString(color1); - } - - @JsonIgnore - public void setColor2FromColor(Color color2) { - this.color2 = Util.formatHexString(color2); - } - - @JsonIgnore - public void setMuteOverrideColorFromColor(Color color) { - muteOverrideColor = Util.formatHexString(color); - } - public void set(SingleSliderLightingConfig c) { color1 = c.color1; color2 = c.color2; mode = c.mode; } } - diff --git a/src/main/java/com/getpcpanel/profile/WaveLinkSettings.java b/src/main/java/com/getpcpanel/profile/dto/WaveLinkSettings.java similarity index 78% rename from src/main/java/com/getpcpanel/profile/WaveLinkSettings.java rename to src/main/java/com/getpcpanel/profile/dto/WaveLinkSettings.java index cae6a94e..cf5a3965 100644 --- a/src/main/java/com/getpcpanel/profile/WaveLinkSettings.java +++ b/src/main/java/com/getpcpanel/profile/dto/WaveLinkSettings.java @@ -1,4 +1,4 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; public record WaveLinkSettings(boolean enabled) { public static final WaveLinkSettings DEFAULT = new WaveLinkSettings(false); diff --git a/src/main/java/com/getpcpanel/rest/AudioResource.java b/src/main/java/com/getpcpanel/rest/AudioResource.java new file mode 100644 index 00000000..5317e3bb --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/AudioResource.java @@ -0,0 +1,56 @@ +package com.getpcpanel.rest; + +import java.util.Collection; +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import com.getpcpanel.cpp.AudioDevice; +import com.getpcpanel.cpp.AudioSession; +import com.getpcpanel.cpp.ISndCtrl; + +import one.util.streamex.StreamEx; + +@Path("/api/audio") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class AudioResource { + @Inject ISndCtrl sndCtrl; + + @GET + @Path("/devices") + public Collection listAudioDevices() { + return sndCtrl.devices(); + } + + @GET + @Path("/devices/output") + public List listOutputDevices() { + return StreamEx.of(sndCtrl.devices()).filter(AudioDevice::isOutput).toList(); + } + + @GET + @Path("/devices/input") + public List listInputDevices() { + return StreamEx.of(sndCtrl.devices()).filter(AudioDevice::isInput).toList(); + } + + @GET + @Path("/sessions") + public Collection listAudioSessions() { + return sndCtrl.getAllSessions(); + } + + @GET + @Path("/applications") + public List listRunningApplications() { + return sndCtrl.getRunningApplications(); + } +} diff --git a/src/main/java/com/getpcpanel/rest/CommandsResource.java b/src/main/java/com/getpcpanel/rest/CommandsResource.java new file mode 100644 index 00000000..e35e1cdc --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/CommandsResource.java @@ -0,0 +1,98 @@ +package com.getpcpanel.rest; + +import java.util.Collection; +import java.util.List; + +import com.getpcpanel.commands.command.CommandBrightness; +import com.getpcpanel.commands.command.CommandEndProgram; +import com.getpcpanel.commands.command.CommandKeystroke; +import com.getpcpanel.commands.command.CommandMedia; +import com.getpcpanel.commands.command.CommandObsMuteSource; +import com.getpcpanel.commands.command.CommandObsSetScene; +import com.getpcpanel.commands.command.CommandObsSetSourceVolume; +import com.getpcpanel.commands.command.CommandRun; +import com.getpcpanel.commands.command.CommandShortcut; +import com.getpcpanel.commands.command.CommandVoiceMeeterAdvanced; +import com.getpcpanel.commands.command.CommandVoiceMeeterAdvancedButton; +import com.getpcpanel.commands.command.CommandVoiceMeeterBasic; +import com.getpcpanel.commands.command.CommandVoiceMeeterBasicButton; +import com.getpcpanel.commands.command.CommandVolumeApplicationDeviceToggle; +import com.getpcpanel.commands.command.CommandVolumeDefaultDevice; +import com.getpcpanel.commands.command.CommandVolumeDefaultDeviceAdvanced; +import com.getpcpanel.commands.command.CommandVolumeDefaultDeviceToggle; +import com.getpcpanel.commands.command.CommandVolumeDefaultDeviceToggleAdvanced; +import com.getpcpanel.commands.command.CommandVolumeDevice; +import com.getpcpanel.commands.command.CommandVolumeDeviceMute; +import com.getpcpanel.commands.command.CommandVolumeFocus; +import com.getpcpanel.commands.command.CommandVolumeFocusMute; +import com.getpcpanel.commands.command.CommandVolumeProcess; +import com.getpcpanel.commands.command.CommandVolumeProcessMute; +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.rest.EventBroadcaster.AssignmentChangedEvent.Kinds; +import com.getpcpanel.rest.model.dto.CommandType; +import com.getpcpanel.rest.model.dto.CommandType.CommandCategory; +import com.getpcpanel.wavelink.command.CommandWaveLinkAddFocusToChannel; +import com.getpcpanel.wavelink.command.CommandWaveLinkChangeLevel; +import com.getpcpanel.wavelink.command.CommandWaveLinkChangeMute; +import com.getpcpanel.wavelink.command.CommandWaveLinkChannelEffect; +import com.getpcpanel.wavelink.command.CommandWaveLinkMainOutput; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import one.util.streamex.StreamEx; + +@Path("/api/commands") +@ApplicationScoped +public class CommandsResource { + @Inject SaveService saveService; + + private static final List commandTypes = List.of( + new CommandType("Brightness", CommandBrightness.class.getName(), CommandCategory.standard, Kinds.dial), + new CommandType("Process volume", CommandVolumeProcess.class.getName(), CommandCategory.standard, Kinds.dial), + new CommandType("Focus volume", CommandVolumeFocus.class.getName(), CommandCategory.standard, Kinds.dial), + new CommandType("Device volume", CommandVolumeDevice.class.getName(), CommandCategory.standard, Kinds.dial), + new CommandType("Obs Source Volume", CommandObsSetSourceVolume.class.getName(), CommandCategory.obs, Kinds.dial), + new CommandType("VoiceMeeter Advanced", CommandVoiceMeeterAdvanced.class.getName(), CommandCategory.voicemeeter, Kinds.dial), + new CommandType("VoiceMeeter Basic", CommandVoiceMeeterBasic.class.getName(), CommandCategory.voicemeeter, Kinds.dial), + new CommandType("WaveLink Change Level", CommandWaveLinkChangeLevel.class.getName(), CommandCategory.wavelink, Kinds.dial), + + new CommandType("End Program", CommandEndProgram.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Keystroke", CommandKeystroke.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Run", CommandRun.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Shortcut", CommandShortcut.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Media", CommandMedia.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Toggle application device", CommandVolumeApplicationDeviceToggle.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Default Device", CommandVolumeDefaultDevice.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Default Device Advanced", CommandVolumeDefaultDeviceAdvanced.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Default Device Toggle", CommandVolumeDefaultDeviceToggle.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Default Device Toggle Advanced", CommandVolumeDefaultDeviceToggleAdvanced.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Device Mute", CommandVolumeDeviceMute.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Focus Mute", CommandVolumeFocusMute.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Process Mute", CommandVolumeProcessMute.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Obs Mute Source", CommandObsMuteSource.class.getName(), CommandCategory.obs, Kinds.button), + new CommandType("Obs Set Scene", CommandObsSetScene.class.getName(), CommandCategory.obs, Kinds.button), + new CommandType("VoiceMeeter Advanced", CommandVoiceMeeterAdvancedButton.class.getName(), CommandCategory.voicemeeter, Kinds.button), + new CommandType("VoiceMeeter Basic", CommandVoiceMeeterBasicButton.class.getName(), CommandCategory.voicemeeter, Kinds.button), + new CommandType("WaveLink Add Focus To Channel", CommandWaveLinkAddFocusToChannel.class.getName(), CommandCategory.wavelink, Kinds.button), + new CommandType("WaveLink Change Mute", CommandWaveLinkChangeMute.class.getName(), CommandCategory.wavelink, Kinds.button), + new CommandType("WaveLink Channel Effect", CommandWaveLinkChannelEffect.class.getName(), CommandCategory.wavelink, Kinds.button), + new CommandType("WaveLink Main Output", CommandWaveLinkMainOutput.class.getName(), CommandCategory.wavelink, Kinds.button) + ); + + @GET + @Path("/available") + public Collection listAvailableCommands() { + return StreamEx.of(commandTypes).filter(this::enabled).toList(); + } + + private boolean enabled(CommandType commandType) { + return switch (commandType.category()) { + case standard -> true; + case obs -> saveService.get().isObsEnabled(); + case voicemeeter -> saveService.get().isVoicemeeterEnabled(); + case wavelink -> saveService.get().getWaveLink().enabled(); + }; + } +} diff --git a/src/main/java/com/getpcpanel/rest/DeviceResource.java b/src/main/java/com/getpcpanel/rest/DeviceResource.java new file mode 100644 index 00000000..873e0fb2 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/DeviceResource.java @@ -0,0 +1,283 @@ +package com.getpcpanel.rest; + +import java.util.List; +import java.util.Optional; + +import com.getpcpanel.commands.Commands; +import com.getpcpanel.device.Device; +import com.getpcpanel.hid.DeviceHolder; +import com.getpcpanel.profile.DeviceSave; +import com.getpcpanel.profile.Profile; +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.profile.dto.KnobSetting; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.rest.EventBroadcaster.AssignmentChangedEvent; +import com.getpcpanel.rest.EventBroadcaster.AssignmentChangedEvent.Kinds; +import com.getpcpanel.rest.EventBroadcaster.DeviceRenamedEvent; +import com.getpcpanel.rest.EventBroadcaster.LightingChangedEvent; +import com.getpcpanel.rest.EventBroadcaster.ProfileSwitchedEvent; +import com.getpcpanel.rest.EventBroadcaster.KnobSettingChangedEvent; +import com.getpcpanel.rest.model.dto.ControlAssignmentsUpdateDto; +import com.getpcpanel.rest.model.dto.DeviceDto; +import com.getpcpanel.rest.model.dto.ProfileDto; +import com.getpcpanel.rest.model.dto.ProfileSnapshotDto; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import one.util.streamex.StreamEx; + +@Path("/api/devices") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class DeviceResource { + @Inject DeviceHolder deviceHolder; + @Inject SaveService saveService; + @Inject Event eventBus; + + @GET + public List listDevices() { + var save = saveService.get(); + return StreamEx.of(deviceHolder.all()) + .map(d -> DeviceDto.from(d, save.getDeviceSave(d.getSerialNumber()))) + .toList(); + } + + @GET + @Path("/{serial}") + public DeviceDto getDevice(@PathParam("serial") String serial) { + var device = deviceHolder.getDevice(serial).orElseThrow(NotFoundException::new); + return DeviceDto.from(device, saveService.get().getDeviceSave(serial)); + } + + @PUT + @Path("/{serial}/name") + public Response renameDevice(@PathParam("serial") String serial, String name) { + var device = deviceHolder.getDevice(serial).orElseThrow(NotFoundException::new); + device.setDisplayName(name); + saveService.save(); + eventBus.fire(new DeviceRenamedEvent(serial, name)); + return Response.ok().build(); + } + + // ── Profiles ────────────────────────────────────────────────────────────── + + @GET + @Path("/{serial}/profiles") + public List listProfiles(@PathParam("serial") String serial) { + var deviceSave = getDeviceSave(serial); + return StreamEx.of(deviceSave.getProfiles()).map(ProfileDto::from).toList(); + } + + @POST + @Path("/{serial}/profiles") + public Response createProfile(@PathParam("serial") String serial, String name) { + var deviceSave = getDeviceSave(serial); + var device = deviceHolder.getDevice(serial).orElseThrow(NotFoundException::new); + var profile = new Profile(name, device.deviceType()); + deviceSave.getProfiles().add(profile); + saveService.save(); + return Response.ok(ProfileDto.from(profile)).build(); + } + + @DELETE + @Path("/{serial}/profiles/{name}") + public Response deleteProfile(@PathParam("serial") String serial, @PathParam("name") String name) { + var deviceSave = getDeviceSave(serial); + deviceSave.getProfiles().removeIf(p -> p.getName().equals(name)); + saveService.save(); + return Response.noContent().build(); + } + + @PUT + @Path("/{serial}/profiles/current") + public Response switchProfile(@PathParam("serial") String serial, String name) { + var deviceSave = getDeviceSave(serial); + var profile = deviceSave.setCurrentProfile(name).orElseThrow(() -> new NotFoundException("Profile not found: " + name)); + saveService.save(); + eventBus.fire(new ProfileSwitchedEvent(serial, name, ProfileSnapshotDto.from(profile))); + return Response.ok().build(); + } + + // ── Button/Dial assignments ──────────────────────────────────────────────── + + @GET + @Path("/{serial}/profiles/{profile}/buttons/{index}") + public Commands getButton(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index) { + return getProfile(serial, profileName).getButtonData(index); + } + + @PUT + @Path("/{serial}/profiles/{profile}/buttons/{index}") + public Response setButton(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index, + Commands commands) { + getProfile(serial, profileName).setButtonData(index, commands); + saveService.save(); + eventBus.fire(new AssignmentChangedEvent(serial, Kinds.button, index, commands)); + return Response.ok().build(); + } + + @GET + @Path("/{serial}/profiles/{profile}/dblbuttons/{index}") + public Commands getDblButton(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index) { + return Optional.ofNullable(getProfile(serial, profileName).getDblButtonData(index)) + .orElse(Commands.EMPTY); + } + + @PUT + @Path("/{serial}/profiles/{profile}/dblbuttons/{index}") + public Response setDblButton(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index, + Commands commands) { + getProfile(serial, profileName).setDblButtonData(index, commands); + saveService.save(); + eventBus.fire(new AssignmentChangedEvent(serial, Kinds.dblbutton, index, commands)); + return Response.ok().build(); + } + + @GET + @Path("/{serial}/profiles/{profile}/dials/{index}") + public Commands getDial(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index) { + return Optional.ofNullable(getProfile(serial, profileName).getDialData(index)) + .orElse(Commands.EMPTY); + } + + @PUT + @Path("/{serial}/profiles/{profile}/dials/{index}") + public Response setDial(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index, + Commands commands) { + getProfile(serial, profileName).setDialData(index, commands); + saveService.save(); + eventBus.fire(new AssignmentChangedEvent(serial, Kinds.dial, index, commands)); + return Response.ok().build(); + } + + @GET + @Path("/{serial}/profiles/{profile}/knobsettings/{index}") + public KnobSetting getKnobSettings(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index) { + return getProfile(serial, profileName).getKnobSettings(index); + } + + @PUT + @Path("/{serial}/profiles/{profile}/knobsettings/{index}") + public Response setKnobSettings(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index, + KnobSetting settings) { + var knob = getProfile(serial, profileName).getKnobSettings(index); + knob.setMinTrim(settings.getMinTrim()); + knob.setMaxTrim(settings.getMaxTrim()); + knob.setLogarithmic(settings.isLogarithmic()); + knob.setOverlayIcon(settings.getOverlayIcon()); + knob.setButtonDebounce(settings.getButtonDebounce()); + saveService.save(); + eventBus.fire(new KnobSettingChangedEvent(serial, index, knob)); + return Response.ok().build(); + } + + // ── Lighting ────────────────────────────────────────────────────────────── + + @GET + @Path("/{serial}/lighting") + public LightingConfig getLighting(@PathParam("serial") String serial) { + return deviceHolder.getDevice(serial) + .map(Device::getSavedLightingConfig) + .orElseThrow(NotFoundException::new); + } + + @PUT + @Path("/{serial}/lighting") + public Response setLighting(@PathParam("serial") String serial, LightingConfig config) { + var device = deviceHolder.getDevice(serial).orElseThrow(NotFoundException::new); + device.setSavedLighting(config); + saveService.save(); + eventBus.fire(new LightingChangedEvent(serial, config)); + return Response.ok().build(); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private DeviceSave getDeviceSave(String serial) { + var save = saveService.get(); + var deviceSave = save.getDevices().get(serial); + if (deviceSave == null) { + throw new NotFoundException("Device not found: " + serial); + } + return deviceSave; + } + + private Profile getProfile(String serial, String profileName) { + return getDeviceSave(serial).getProfile(profileName) + .orElseThrow(() -> new NotFoundException("Profile not found: " + profileName)); + } + + @PUT + @Path("/{serial}/profiles/{profile}/controls/{index}") + public Response setControlAssignments(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index, + ControlAssignmentsUpdateDto update) { + var profile = getProfile(serial, profileName); + var changed = false; + + if (update.analog() != null) { + profile.setDialData(index, update.analog()); + eventBus.fire(new AssignmentChangedEvent(serial, Kinds.dial, index, update.analog())); + changed = true; + } + + if (update.button() != null) { + profile.setButtonData(index, update.button()); + eventBus.fire(new AssignmentChangedEvent(serial, Kinds.button, index, update.button())); + changed = true; + } + + if (update.dblButton() != null) { + profile.setDblButtonData(index, update.dblButton()); + eventBus.fire(new AssignmentChangedEvent(serial, Kinds.dblbutton, index, update.dblButton())); + changed = true; + } + + if (update.knobSetting() != null) { + var knob = profile.getKnobSettings(index); + knob.setMinTrim(update.knobSetting().getMinTrim()); + knob.setMaxTrim(update.knobSetting().getMaxTrim()); + knob.setLogarithmic(update.knobSetting().isLogarithmic()); + knob.setOverlayIcon(update.knobSetting().getOverlayIcon()); + knob.setButtonDebounce(update.knobSetting().getButtonDebounce()); + eventBus.fire(new KnobSettingChangedEvent(serial, index, knob)); + changed = true; + } + + if (changed) { + saveService.save(); + } + + return Response.ok().build(); + } +} diff --git a/src/main/java/com/getpcpanel/rest/EventBroadcaster.java b/src/main/java/com/getpcpanel/rest/EventBroadcaster.java new file mode 100644 index 00000000..71ebb408 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/EventBroadcaster.java @@ -0,0 +1,134 @@ +package com.getpcpanel.rest; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.getpcpanel.commands.Commands; +import com.getpcpanel.hid.DeviceCommunicationHandler.ButtonPressEvent; +import com.getpcpanel.hid.DeviceCommunicationHandler.KnobRotateEvent; +import com.getpcpanel.hid.DeviceHolder; +import com.getpcpanel.hid.DeviceHolder.DeviceFullyConnectedEvent; +import com.getpcpanel.hid.DeviceScanner.DeviceDisconnectedEvent; +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.profile.dto.KnobSetting; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.rest.ProVisualColorsService.ProVisualColors; +import com.getpcpanel.rest.model.dto.DeviceSnapshotDto; +import com.getpcpanel.rest.model.dto.ProfileSnapshotDto; +import com.getpcpanel.rest.model.ws.WsAssignmentChangedEvent; +import com.getpcpanel.rest.model.ws.WsButtonEvent; +import com.getpcpanel.rest.model.ws.WsControlSettingChangedEvent; +import com.getpcpanel.rest.model.ws.WsDeviceConnectedEvent; +import com.getpcpanel.rest.model.ws.WsDeviceDisconnectedEvent; +import com.getpcpanel.rest.model.ws.WsDeviceRenamedEvent; +import com.getpcpanel.rest.model.ws.WsKnobEvent; +import com.getpcpanel.rest.model.ws.WsLightingChangedEvent; +import com.getpcpanel.rest.model.ws.WsProfileSwitchedEvent; +import com.getpcpanel.rest.model.ws.WsVisualColorsChangedEvent; +import com.getpcpanel.util.AppShutdownState; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +@ApplicationScoped +public class EventBroadcaster { + @Inject ObjectMapper objectMapper; + @Inject SaveService saveService; + @Inject DeviceHolder deviceHolder; + @Inject ProVisualColorsService proVisualColorsService; + + private boolean shouldSkipBroadcast() { + return AppShutdownState.isShuttingDown(); + } + + private void broadcast(Object event) { + if (shouldSkipBroadcast()) + return; + EventWebSocket.broadcast(event, objectMapper); + } + + // ── Existing operational events ──────────────────────────────────────────── + + public void onDeviceConnected(@Observes DeviceFullyConnectedEvent event) { + var serial = event.device().getSerialNumber(); + var save = saveService.get().getDeviceSave(serial); + if (save == null) { + log.debug("Skipping device_connected broadcast for {} because no device save exists", serial); + return; + } + + var snapshot = DeviceSnapshotDto.from(event.device(), save, proVisualColorsService); + broadcast(new WsDeviceConnectedEvent(snapshot)); + } + + public void onDeviceDisconnected(@Observes DeviceDisconnectedEvent event) { + broadcast(new WsDeviceDisconnectedEvent(event.serialNum())); + } + + public void onKnobRotate(@Observes KnobRotateEvent event) { + broadcast(new WsKnobEvent(event.serialNum(), event.knob(), event.value())); + } + + public void onButtonPress(@Observes ButtonPressEvent event) { + broadcast(new WsButtonEvent(event.serialNum(), event.button(), event.pressed())); + } + + // ── Mutation patch events ────────────────────────────────────────────────── + + public void onDeviceRenamed(@Observes DeviceRenamedEvent event) { + broadcast(new WsDeviceRenamedEvent(event.serial(), event.displayName())); + } + + public void onProfileSwitched(@Observes ProfileSwitchedEvent event) { + var colors = colorsFor(event.serial()); + broadcast(new WsProfileSwitchedEvent(event.serial(), event.profileName(), event.profileSnapshot(), colors.dialColors(), colors.sliderLabelColors(), colors.sliderColors(), colors.logoColor())); + } + + public void onLightingChanged(@Observes LightingChangedEvent event) { + var colors = colorsFor(event.serial()); + broadcast(new WsLightingChangedEvent(event.serial(), event.lightingConfig(), colors.dialColors(), colors.sliderLabelColors(), colors.sliderColors(), colors.logoColor())); + } + + public void onVisualColorsChanged(@Observes VisualColorsChangedEvent event) { + var colors = colorsFor(event.serial()); + broadcast(new WsVisualColorsChangedEvent(event.serial(), colors.dialColors(), colors.sliderLabelColors(), colors.sliderColors(), colors.logoColor())); + } + + public void onAssignmentChanged(@Observes AssignmentChangedEvent event) { + broadcast(new WsAssignmentChangedEvent(event.serial(), event.kind(), event.index(), event.commands())); + } + + public void onSettingChanged(@Observes KnobSettingChangedEvent event) { + broadcast(new WsControlSettingChangedEvent(event.serial(), event.index(), event.settings())); + } + + // ── CDI mutation events (fired by DeviceResource) ───────────────────────── + + public record DeviceRenamedEvent(String serial, String displayName) { + } + + public record ProfileSwitchedEvent(String serial, String profileName, ProfileSnapshotDto profileSnapshot) { + } + + public record LightingChangedEvent(String serial, LightingConfig lightingConfig) { + } + + public record VisualColorsChangedEvent(String serial) { + } + + public record KnobSettingChangedEvent(String serial, int index, KnobSetting settings) { + } + + public record AssignmentChangedEvent(String serial, Kinds kind, int index, Commands commands) { + public enum Kinds { + dial, button, dblbutton + } + } + + private ProVisualColors colorsFor(String serial) { + return deviceHolder.getDevice(serial) + .map(proVisualColorsService::resolve) + .orElse(ProVisualColors.empty()); + } +} diff --git a/src/main/java/com/getpcpanel/rest/EventWebSocket.java b/src/main/java/com/getpcpanel/rest/EventWebSocket.java new file mode 100644 index 00000000..8fdb5c94 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/EventWebSocket.java @@ -0,0 +1,86 @@ +package com.getpcpanel.rest; + +import java.util.concurrent.CopyOnWriteArraySet; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.getpcpanel.hid.DeviceHolder; +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.rest.model.dto.DeviceSnapshotDto; +import com.getpcpanel.rest.model.ws.WsDeviceConnectedEvent; +import com.getpcpanel.util.AppShutdownState; + +import io.quarkus.websockets.next.OnClose; +import io.quarkus.websockets.next.OnOpen; +import io.quarkus.websockets.next.WebSocket; +import io.quarkus.websockets.next.WebSocketConnection; +import jakarta.inject.Inject; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +@WebSocket(path = "/ws/events") +public class EventWebSocket { + private static final CopyOnWriteArraySet connections = new CopyOnWriteArraySet<>(); + + @Inject ObjectMapper objectMapper; + @Inject DeviceHolder deviceHolder; + @Inject SaveService saveService; + @Inject ProVisualColorsService proVisualColorsService; + + @OnOpen + public void onOpen(WebSocketConnection connection) { + if (AppShutdownState.isShuttingDown()) { + log.debug("Ignoring websocket connection {} because shutdown is in progress", connection.id()); + return; + } + connections.add(connection); + log.debug("WebSocket client connected: {} (total connections: {})", connection.id(), connections.size()); + sendInitialSnapshots(connection); + } + + @OnClose + public void onClose(WebSocketConnection connection) { + connections.remove(connection); + log.debug("WebSocket client disconnected: {} (remaining connections: {})", connection.id(), connections.size()); + } + + private void sendInitialSnapshots(WebSocketConnection connection) { + var save = saveService.get(); + deviceHolder.all().forEach(device -> { + try { + var deviceSave = save.getDeviceSave(device.getSerialNumber()); + if (deviceSave == null) { + log.debug("Skipping initial device_connected for {} because no device save exists", device.getSerialNumber()); + return; + } + + var snapshot = DeviceSnapshotDto.from(device, deviceSave, proVisualColorsService); + var connectedEvent = new WsDeviceConnectedEvent(snapshot); + var json = objectMapper.writeValueAsString(connectedEvent); + connection.sendTextAndAwait(json); + } catch (Exception e) { + log.warn("Failed to send initial device_connected for {} to new WS connection {}", device.getSerialNumber(), connection.id(), e); + } + }); + } + + public static void broadcast(Object event, ObjectMapper mapper) { + if (AppShutdownState.isShuttingDown()) { + connections.clear(); + return; + } + try { + var json = mapper.writeValueAsString(event); + log.debug("Broadcasting event to {} WebSocket clients: {}", connections.size(), json); + connections.forEach(c -> { + try { + c.sendTextAndAwait(json); + } catch (Exception e) { + log.debug("Failed to send event to WS client {}", c.id(), e); + } + }); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize event", e); + } + } +} diff --git a/src/main/java/com/getpcpanel/rest/IconResource.java b/src/main/java/com/getpcpanel/rest/IconResource.java new file mode 100644 index 00000000..b3276f01 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/IconResource.java @@ -0,0 +1,67 @@ +package com.getpcpanel.rest; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; + +import javax.imageio.ImageIO; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Response; + +import com.getpcpanel.iconextract.IIconService; + +import lombok.extern.log4j.Log4j2; + +@Log4j2 +@Path("/api/icons") +@ApplicationScoped +public class IconResource { + @Inject IIconService iconService; + + @GET + @Produces("image/png") + public Response getIcon(@QueryParam("path") String filePath, + @QueryParam("size") @DefaultValue("32") int size) { + if (filePath == null || filePath.isBlank()) { + throw new NotFoundException(); + } + // Resolve canonical path to prevent path traversal sequences (e.g. "../") + File file; + try { + file = new File(filePath).getCanonicalFile(); + } catch (IOException e) { + throw new NotFoundException(); + } + // Restrict to files with known safe extensions to prevent arbitrary file access + var name = file.getName().toLowerCase(); + var allowed = name.endsWith(".exe") || name.endsWith(".lnk") || name.endsWith(".ico") + || name.endsWith(".png") || name.endsWith(".jpg") || name.endsWith(".jpeg") + || name.endsWith(".bmp") || name.endsWith(".gif"); + if (!allowed) { + throw new NotFoundException(); + } + if (!file.isFile()) { + throw new NotFoundException(); + } + var img = iconService.getIconForFile(size, size, file); + if (img == null) { + throw new NotFoundException(); + } + try { + var baos = new ByteArrayOutputStream(); + ImageIO.write(img, "png", baos); + return Response.ok(baos.toByteArray()).type("image/png").build(); + } catch (Exception e) { + log.error("Failed to encode icon for {}", filePath, e); + return Response.serverError().build(); + } + } +} diff --git a/src/main/java/com/getpcpanel/rest/ObsResource.java b/src/main/java/com/getpcpanel/rest/ObsResource.java new file mode 100644 index 00000000..17fd7221 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/ObsResource.java @@ -0,0 +1,37 @@ +package com.getpcpanel.rest; + +import java.util.List; + +import com.getpcpanel.obs.OBS; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("/api/obs") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +public class ObsResource { + @Inject OBS obs; + + @GET + @Path("/scenes") + public List listScenes() { + if (!obs.isConnected()) { + return List.of(); + } + return obs.getScenes(); + } + + @GET + @Path("/sources") + public List listSources() { + if (!obs.isConnected()) { + return List.of(); + } + return obs.getSourcesWithAudio(); + } +} diff --git a/src/main/java/com/getpcpanel/rest/OverlayResource.java b/src/main/java/com/getpcpanel/rest/OverlayResource.java new file mode 100644 index 00000000..aa2948b4 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/OverlayResource.java @@ -0,0 +1,37 @@ +package com.getpcpanel.rest; + +import com.getpcpanel.overlay.Overlay; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +@Path("/api/overlay") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +public class OverlayResource { + @Inject Overlay overlay; + + @GET + public Response testOverlay() { + System.out.println("Overlay!"); + overlay.show(0); + return Response.ok().build(); + } + + @POST + public Response showOverlay(OverlayDto params) { + overlay.show(params.value()); + return Response.ok().build(); + } + + @RegisterForReflection + public record OverlayDto(int value, String icon) { + } +} diff --git a/src/main/java/com/getpcpanel/rest/ProVisualColorsService.java b/src/main/java/com/getpcpanel/rest/ProVisualColorsService.java new file mode 100644 index 00000000..e4cea3a0 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/ProVisualColorsService.java @@ -0,0 +1,302 @@ +package com.getpcpanel.rest; + +import java.awt.Color; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import com.getpcpanel.device.Device; +import com.getpcpanel.device.DeviceType; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.profile.dto.SingleKnobLightingConfig; +import com.getpcpanel.profile.dto.SingleLogoLightingConfig; +import com.getpcpanel.profile.dto.SingleSliderLabelLightingConfig; +import com.getpcpanel.profile.dto.SingleSliderLightingConfig; +import com.getpcpanel.util.Util; +import com.getpcpanel.util.coloroverride.OverrideColorService; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +@ApplicationScoped +public class ProVisualColorsService { + private static final String BLACK = "#000000"; + private static final int PRO_DIAL_COUNT = 5; + private static final int PRO_SLIDER_COUNT = 4; + private static final int PRO_SLIDER_SEGMENT_COUNT = 5; + + @Inject + OverrideColorService overrideColorService; + + public ProVisualColors resolve(Device device) { + if (device == null || device.deviceType() != DeviceType.PCPANEL_PRO) { + return ProVisualColors.empty(); + } + + var config = device.lightingConfig(); + if (config == null || config.lightingMode() == null) { + return ProVisualColors.defaultForPro(); + } + + return switch (config.lightingMode()) { + case ALL_COLOR -> monochrome(colorOrDefault(config.allColor())); + case ALL_WAVE -> fromWave(config); + case ALL_BREATH -> monochrome(colorFromHue(config.breathHue(), config.breathBrightness())); + case ALL_RAINBOW -> fromRainbow(config); + case CUSTOM -> fromCustom(device.getSerialNumber(), config); + default -> ProVisualColors.defaultForPro(); + }; + } + + private ProVisualColors fromRainbow(LightingConfig config) { + var baseHue = unitByte(config.rainbowPhaseShift()); + var reverse = config.rainbowReverse() == 1; + var vertical = config.rainbowVertical() == 1; + var brightness = unitByte(config.rainbowBrightness()); + + var dialColors = new ArrayList(PRO_DIAL_COUNT); + for (var i = 0; i < PRO_DIAL_COUNT; i++) { + dialColors.add(rainbowColor(baseHue, reverse, i, PRO_DIAL_COUNT, brightness)); + } + + var sliderLabelColors = new ArrayList(PRO_SLIDER_COUNT); + for (var i = 0; i < PRO_SLIDER_COUNT; i++) { + sliderLabelColors.add(rainbowColor(baseHue, reverse, i + PRO_DIAL_COUNT, PRO_DIAL_COUNT + PRO_SLIDER_COUNT, brightness)); + } + + var sliderColors = new ArrayList>(PRO_SLIDER_COUNT); + for (var s = 0; s < PRO_SLIDER_COUNT; s++) { + var segmentColors = new ArrayList(PRO_SLIDER_SEGMENT_COUNT); + for (var seg = 0; seg < PRO_SLIDER_SEGMENT_COUNT; seg++) { + var idx = vertical ? seg : (s * PRO_SLIDER_SEGMENT_COUNT + seg); + var total = vertical ? PRO_SLIDER_SEGMENT_COUNT : (PRO_SLIDER_COUNT * PRO_SLIDER_SEGMENT_COUNT); + segmentColors.add(rainbowColor(baseHue, reverse, idx, total, brightness)); + } + sliderColors.add(List.copyOf(segmentColors)); + } + + var logoColor = rainbowColor(baseHue, reverse, PRO_DIAL_COUNT + PRO_SLIDER_COUNT, PRO_DIAL_COUNT + PRO_SLIDER_COUNT + 1, brightness); + return new ProVisualColors(List.copyOf(dialColors), List.copyOf(sliderLabelColors), List.copyOf(sliderColors), logoColor); + } + + private ProVisualColors fromWave(LightingConfig config) { + var centerHue = unitByte(config.waveHue()); + var brightness = unitByte(config.waveBrightness()); + var reverse = config.waveReverse() == 1; + var bounce = config.waveBounce() == 1; + + var dialColors = new ArrayList(PRO_DIAL_COUNT); + for (var i = 0; i < PRO_DIAL_COUNT; i++) { + dialColors.add(waveColor(centerHue, brightness, i, PRO_DIAL_COUNT, reverse, bounce)); + } + + var sliderLabelColors = new ArrayList(PRO_SLIDER_COUNT); + for (var i = 0; i < PRO_SLIDER_COUNT; i++) { + sliderLabelColors.add(waveColor(centerHue, brightness, i, PRO_SLIDER_COUNT, reverse, bounce)); + } + + var sliderColors = new ArrayList>(PRO_SLIDER_COUNT); + for (var s = 0; s < PRO_SLIDER_COUNT; s++) { + var segmentColors = new ArrayList(PRO_SLIDER_SEGMENT_COUNT); + for (var seg = 0; seg < PRO_SLIDER_SEGMENT_COUNT; seg++) { + segmentColors.add(waveColor(centerHue, brightness, seg, PRO_SLIDER_SEGMENT_COUNT, reverse, bounce)); + } + sliderColors.add(List.copyOf(segmentColors)); + } + + var logoColor = colorFromHue(config.waveHue(), config.waveBrightness()); + return new ProVisualColors(List.copyOf(dialColors), List.copyOf(sliderLabelColors), List.copyOf(sliderColors), logoColor); + } + + private ProVisualColors monochrome(String color) { + var c = colorOrDefault(color); + var dials = nCopies(PRO_DIAL_COUNT, c); + var labels = nCopies(PRO_SLIDER_COUNT, c); + var sliders = new ArrayList>(PRO_SLIDER_COUNT); + for (var i = 0; i < PRO_SLIDER_COUNT; i++) { + sliders.add(nCopies(PRO_SLIDER_SEGMENT_COUNT, c)); + } + return new ProVisualColors(dials, labels, List.copyOf(sliders), c); + } + + private ProVisualColors fromCustom(String serial, LightingConfig config) { + var dialColors = new ArrayList(PRO_DIAL_COUNT); + var knobConfigs = config.knobConfigs(); + for (var i = 0; i < PRO_DIAL_COUNT; i++) { + var knob = i < knobConfigs.length ? knobConfigs[i] : new SingleKnobLightingConfig(); + knob = overrideColorService.getDialOverride(serial, i).orElse(knob); + dialColors.add(resolveDialColor(knob)); + } + + var sliderLabelColors = new ArrayList(PRO_SLIDER_COUNT); + var labelConfigs = config.sliderLabelConfigs(); + for (var i = 0; i < PRO_SLIDER_COUNT; i++) { + var label = i < labelConfigs.length ? labelConfigs[i] : new SingleSliderLabelLightingConfig(); + label = overrideColorService.getSliderLabelOverride(serial, i).orElse(label); + sliderLabelColors.add(resolveSliderLabelColor(label)); + } + + var sliderColors = new ArrayList>(PRO_SLIDER_COUNT); + var sliderConfigs = config.sliderConfigs(); + for (var i = 0; i < PRO_SLIDER_COUNT; i++) { + var slider = i < sliderConfigs.length ? sliderConfigs[i] : new SingleSliderLightingConfig(); + slider = overrideColorService.getSliderOverride(serial, i).orElse(slider); + sliderColors.add(resolveSliderColors(slider)); + } + + var logo = overrideColorService.getLogoOverride(serial).orElse(config.logoConfig()); + var logoColor = resolveLogoColor(logo); + + return new ProVisualColors(List.copyOf(dialColors), List.copyOf(sliderLabelColors), List.copyOf(sliderColors), logoColor); + } + + private String resolveDialColor(SingleKnobLightingConfig config) { + if (config == null || config.getMode() == null) { + return BLACK; + } + return switch (config.getMode()) { + case NONE -> BLACK; + case STATIC, VOLUME_GRADIENT -> firstColor(config.getColor1(), config.getColor2()); + }; + } + + private String resolveSliderLabelColor(SingleSliderLabelLightingConfig config) { + if (config == null || config.getMode() == null) { + return BLACK; + } + return switch (config.getMode()) { + case NONE -> BLACK; + case STATIC -> colorOrDefault(config.getColor()); + }; + } + + private List resolveSliderColors(SingleSliderLightingConfig config) { + if (config == null || config.getMode() == null) { + return nCopies(PRO_SLIDER_SEGMENT_COUNT, BLACK); + } + + return switch (config.getMode()) { + case NONE -> nCopies(PRO_SLIDER_SEGMENT_COUNT, BLACK); + case STATIC -> nCopies(PRO_SLIDER_SEGMENT_COUNT, colorOrDefault(config.getColor1())); + case STATIC_GRADIENT, VOLUME_GRADIENT -> gradient(config.getColor1(), config.getColor2(), PRO_SLIDER_SEGMENT_COUNT); + }; + } + + String resolveLogoColor(SingleLogoLightingConfig config) { + if (config == null || config.getMode() == null) { + return BLACK; + } + + return switch (config.getMode()) { + case NONE -> BLACK; + case STATIC -> colorOrDefault(config.getColor()); + case RAINBOW -> "$RAINBOW!"; + case BREATH -> "$BREATH"; + }; + } + + private List gradient(String startColor, String endColor, int steps) { + var start = Util.parseColorComponents(colorOrDefault(startColor)); + var end = Util.parseColorComponents(colorOrDefault(endColor)); + + if (start == null || end == null) { + return nCopies(steps, BLACK); + } + + var result = new ArrayList(steps); + for (var i = 0; i < steps; i++) { + var ratio = steps == 1 ? 0f : (float) i / (steps - 1); + var r = Math.round(start[0] + (end[0] - start[0]) * ratio); + var g = Math.round(start[1] + (end[1] - start[1]) * ratio); + var b = Math.round(start[2] + (end[2] - start[2]) * ratio); + result.add(toHex(r, g, b)); + } + return List.copyOf(result); + } + + private String colorFromHue(byte hue, byte brightness) { + return colorFromHsb(unitByte(hue), 1f, unitByte(brightness)); + } + + private String rainbowColor(float baseHue, boolean reverse, int index, int total, float brightness) { + var span = 0.7f; + var shift = total <= 1 ? 0f : (span * index / (total - 1)); + var hue = reverse ? baseHue - shift : baseHue + shift; + return colorFromHsb(normalizeHue(hue), 1f, brightness); + } + + private String waveColor(float centerHue, float brightness, int index, int total, boolean reverse, boolean bounce) { + var progress = total <= 1 ? 0f : (float) index / (total - 1); + if (reverse) { + progress = 1f - progress; + } + var spread = 0.12f; + var offset = bounce + ? (Math.abs(progress - 0.5f) * 2f * spread) + : ((progress - 0.5f) * 2f * spread); + return colorFromHsb(normalizeHue(centerHue + offset), 1f, brightness); + } + + private String colorFromHsb(float hue, float saturation, float brightness) { + var rgb = Color.HSBtoRGB(hue, saturation, brightness); + var r = (rgb >> 16) & 0xFF; + var g = (rgb >> 8) & 0xFF; + var b = rgb & 0xFF; + return toHex(r, g, b); + } + + private String toHex(int r, int g, int b) { + var hex = Util.formatHexString(r, g, b); + return hex == null ? BLACK : hex; + } + + private float unitByte(byte value) { + return (value & 0xFF) / 255f; + } + + private float normalizeHue(float hue) { + var normalized = hue % 1f; + return normalized < 0 ? normalized + 1f : normalized; + } + + private String firstColor(String color1, String color2) { + var c1 = colorOrDefault(color1); + if (!BLACK.equals(c1)) { + return c1; + } + return colorOrDefault(color2); + } + + private String colorOrDefault(String color) { + var parsed = Util.parseColorComponents(color); + if (parsed == null) { + return BLACK; + } + return color.startsWith("#") ? color : "#" + color; + } + + private static List nCopies(int count, String color) { + return Collections.nCopies(count, color); + } + + public record ProVisualColors( + List dialColors, + List sliderLabelColors, + List> sliderColors, + String logoColor + ) { + public static ProVisualColors empty() { + return new ProVisualColors(List.of(), List.of(), List.of(), BLACK); + } + + public static ProVisualColors defaultForPro() { + var blackDials = nCopies(PRO_DIAL_COUNT, BLACK); + var blackLabels = nCopies(PRO_SLIDER_COUNT, BLACK); + var blackSliders = new ArrayList>(PRO_SLIDER_COUNT); + for (var i = 0; i < PRO_SLIDER_COUNT; i++) { + blackSliders.add(nCopies(PRO_SLIDER_SEGMENT_COUNT, BLACK)); + } + return new ProVisualColors(blackDials, blackLabels, List.copyOf(blackSliders), BLACK); + } + } +} diff --git a/src/main/java/com/getpcpanel/rest/ProcessResource.java b/src/main/java/com/getpcpanel/rest/ProcessResource.java new file mode 100644 index 00000000..2abfbbd8 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/ProcessResource.java @@ -0,0 +1,55 @@ +package com.getpcpanel.rest; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Base64; +import java.util.List; + +import javax.imageio.ImageIO; + +import com.getpcpanel.cpp.ISndCtrl; +import com.getpcpanel.iconextract.IIconService; +import com.getpcpanel.rest.model.dto.ProcessDto; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +@Path("/api/processes") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +public class ProcessResource { + @Inject ISndCtrl sndCtrl; + @Inject IIconService iconService; + + @GET + public List listProcesses() { + return sndCtrl.getRunningApplications().stream() + .map(app -> new ProcessDto( + app.pid(), + app.file().getAbsolutePath(), + app.name(), + encodeIcon(iconService.getIconForFile(32, 32, app.file())))) + .toList(); + } + + static String encodeIcon(BufferedImage img) { + if (img == null) { + return null; + } + try { + var baos = new ByteArrayOutputStream(); + ImageIO.write(img, "png", baos); + return "data:image/png;base64," + Base64.getEncoder().encodeToString(baos.toByteArray()); + } catch (IOException e) { + log.debug("Failed to encode process icon", e); + return null; + } + } +} diff --git a/src/main/java/com/getpcpanel/rest/SettingsResource.java b/src/main/java/com/getpcpanel/rest/SettingsResource.java new file mode 100644 index 00000000..25a5bfc0 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/SettingsResource.java @@ -0,0 +1,65 @@ +package com.getpcpanel.rest; + +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.profile.dto.MqttSettings; +import com.getpcpanel.profile.dto.WaveLinkSettings; +import com.getpcpanel.rest.model.dto.SettingsDto; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +@Path("/api/settings") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class SettingsResource { + @Inject SaveService saveService; + + @GET + public SettingsDto getSettings() { + return SettingsDto.from(saveService.get()); + } + + @PUT + public Response updateSettings(SettingsDto dto) { + var save = saveService.get(); + dto.applyTo(save); + saveService.save(); + return Response.ok().build(); + } + + @GET + @Path("/mqtt") + public MqttSettings getMqttSettings() { + return saveService.get().getMqtt(); + } + + @PUT + @Path("/mqtt") + public Response updateMqttSettings(MqttSettings settings) { + saveService.get().setMqtt(settings); + saveService.save(); + return Response.ok().build(); + } + + @GET + @Path("/wavelink") + public WaveLinkSettings getWaveLinkSettings() { + return saveService.get().getWaveLink(); + } + + @PUT + @Path("/wavelink") + public Response updateWaveLinkSettings(WaveLinkSettings settings) { + saveService.get().setWaveLink(settings); + saveService.save(); + return Response.ok().build(); + } +} diff --git a/src/main/java/com/getpcpanel/rest/VoiceMeeterResource.java b/src/main/java/com/getpcpanel/rest/VoiceMeeterResource.java new file mode 100644 index 00000000..5e4792b2 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/VoiceMeeterResource.java @@ -0,0 +1,39 @@ +package com.getpcpanel.rest; + +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("/api/voicemeeter") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +public class VoiceMeeterResource { + + public static class VoiceMeeterParam { + public String name; + public List params; + + public VoiceMeeterParam(String name, List params) { + this.name = name; + this.params = params; + } + } + + @GET + @Path("/basic") + public List getBasicParams() { + // Return empty list for now - these are typically obtained from user configuration + return List.of(); + } + + @GET + @Path("/advanced") + public List getAdvancedParams() { + // Return empty list for now - these are typically obtained from user configuration + return List.of(); + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/CommandType.java b/src/main/java/com/getpcpanel/rest/model/dto/CommandType.java new file mode 100644 index 00000000..4b0217d6 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/CommandType.java @@ -0,0 +1,15 @@ +package com.getpcpanel.rest.model.dto; + +import com.getpcpanel.rest.EventBroadcaster.AssignmentChangedEvent.Kinds; + +public record CommandType( + String name, + String command, + CommandCategory category, + Kinds kind +) { + + public enum CommandCategory { + standard, voicemeeter, obs, wavelink + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/ControlAssignmentsUpdateDto.java b/src/main/java/com/getpcpanel/rest/model/dto/ControlAssignmentsUpdateDto.java new file mode 100644 index 00000000..9c2378b0 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/ControlAssignmentsUpdateDto.java @@ -0,0 +1,14 @@ +package com.getpcpanel.rest.model.dto; + +import com.getpcpanel.commands.Commands; +import com.getpcpanel.profile.dto.KnobSetting; + +import jakarta.annotation.Nullable; + +public record ControlAssignmentsUpdateDto( + @Nullable Commands analog, + @Nullable Commands button, + @Nullable Commands dblButton, + @Nullable KnobSetting knobSetting +) { +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/DeviceDto.java b/src/main/java/com/getpcpanel/rest/model/dto/DeviceDto.java new file mode 100644 index 00000000..0e26655e --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/DeviceDto.java @@ -0,0 +1,34 @@ +package com.getpcpanel.rest.model.dto; + +import java.util.List; + +import com.getpcpanel.device.Device; +import com.getpcpanel.device.DeviceType; +import com.getpcpanel.profile.DeviceSave; + +import one.util.streamex.StreamEx; + +public record DeviceDto( + String serial, + String displayName, + DeviceType deviceType, + int analogCount, + int buttonCount, + boolean hasLogoLed, + String currentProfile, + List profiles +) { + public static DeviceDto from(Device device, DeviceSave deviceSave) { + var type = device.deviceType(); + return new DeviceDto( + device.getSerialNumber(), + device.getDisplayName(), + type, + type.getAnalogCount(), + type.getButtonCount(), + type.isHasLogoLed(), + deviceSave.getCurrentProfileName(), + StreamEx.of(deviceSave.getProfiles()).map(p -> p.getName()).toList() + ); + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/DeviceSnapshotDto.java b/src/main/java/com/getpcpanel/rest/model/dto/DeviceSnapshotDto.java new file mode 100644 index 00000000..99364d4d --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/DeviceSnapshotDto.java @@ -0,0 +1,77 @@ +package com.getpcpanel.rest.model.dto; + +import java.util.List; +import java.util.stream.IntStream; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.getpcpanel.device.Device; +import com.getpcpanel.profile.DeviceSave; +import com.getpcpanel.profile.Profile; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.rest.ProVisualColorsService; +import com.getpcpanel.rest.model.ws.WsEvent; + +import one.util.streamex.StreamEx; + +/** + * Full device state snapshot sent over WebSocket on connection. + * Combines DeviceDto fields with lighting config, the active profile's + * assignments, and the current analog knob values — so the frontend + * never needs separate HTTP calls just to display device state. + */ +@JsonTypeName("device_snapshot") +public record DeviceSnapshotDto( + // ── core device fields (same as DeviceDto) ────────────────────────── + String serial, + String displayName, + String deviceType, + int analogCount, + int buttonCount, + boolean hasLogoLed, + String currentProfile, + List profiles, + // ── extra snapshot fields ──────────────────────────────────────────── + LightingConfig lightingConfig, + ProfileSnapshotDto currentProfileSnapshot, + List analogValues, + List dialColors, + List sliderLabelColors, + List> sliderColors, + String logoColor +) implements WsEvent { + /** + * WsEvent type discriminator understood by the frontend. + */ + public String type() { + return "device_snapshot"; + } + + public static DeviceSnapshotDto from(Device device, DeviceSave deviceSave, ProVisualColorsService proVisualColorsService) { + var dt = device.deviceType(); + var profile = device.currentProfile(); + var analogCount = dt.getAnalogCount(); + var visualColors = proVisualColorsService.resolve(device); + + var knobValues = IntStream.range(0, analogCount) + .mapToObj(device::getKnobRotation) + .toList(); + + return new DeviceSnapshotDto( + device.getSerialNumber(), + device.getDisplayName(), + dt.name(), + analogCount, + dt.getButtonCount(), + dt.isHasLogoLed(), + deviceSave.getCurrentProfileName(), + StreamEx.of(deviceSave.getProfiles()).map(Profile::getName).toList(), + device.getSavedLightingConfig(), + ProfileSnapshotDto.from(profile), + knobValues, + visualColors.dialColors(), + visualColors.sliderLabelColors(), + visualColors.sliderColors(), + visualColors.logoColor() + ); + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/ProcessDto.java b/src/main/java/com/getpcpanel/rest/model/dto/ProcessDto.java new file mode 100644 index 00000000..b7f31d90 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/ProcessDto.java @@ -0,0 +1,6 @@ +package com.getpcpanel.rest.model.dto; + +import javax.annotation.Nullable; + +public record ProcessDto(int pid, String path, String name, @Nullable String icon) { +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/ProfileDto.java b/src/main/java/com/getpcpanel/rest/model/dto/ProfileDto.java new file mode 100644 index 00000000..d902cfc8 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/ProfileDto.java @@ -0,0 +1,9 @@ +package com.getpcpanel.rest.model.dto; + +import com.getpcpanel.profile.Profile; + +public record ProfileDto(String name, boolean isMainProfile) { + public static ProfileDto from(Profile profile) { + return new ProfileDto(profile.getName(), profile.isMainProfile()); + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/ProfileSnapshotDto.java b/src/main/java/com/getpcpanel/rest/model/dto/ProfileSnapshotDto.java new file mode 100644 index 00000000..a6e8a8ae --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/ProfileSnapshotDto.java @@ -0,0 +1,32 @@ +package com.getpcpanel.rest.model.dto; + +import java.util.Map; + +import com.getpcpanel.commands.Commands; +import com.getpcpanel.profile.Profile; +import com.getpcpanel.profile.dto.KnobSetting; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +/** + * Snapshot of the currently active profile — all assignment data the frontend + * needs to render the device page without any additional HTTP calls. + */ +@RegisterForReflection +public record ProfileSnapshotDto( + String name, + Map dialData, + Map buttonData, + Map dblButtonData, + Map knobSettings +) { + public static ProfileSnapshotDto from(Profile profile) { + return new ProfileSnapshotDto( + profile.getName(), + profile.getDialData(), + profile.getButtonData(), + profile.getDblButtonData(), + profile.getKnobSettings() + ); + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/SettingsDto.java b/src/main/java/com/getpcpanel/rest/model/dto/SettingsDto.java new file mode 100644 index 00000000..2bf947bc --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/SettingsDto.java @@ -0,0 +1,125 @@ +package com.getpcpanel.rest.model.dto; + +import java.util.List; + +import javax.annotation.Nullable; + +import com.getpcpanel.profile.Save; +import com.getpcpanel.profile.dto.MqttSettings; +import com.getpcpanel.profile.dto.OSCConnectionInfo; +import com.getpcpanel.profile.dto.OverlayPosition; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class SettingsDto { + // General + private boolean mainUIIcons; + private boolean startupVersionCheck; + private boolean forceVolume; + private Long dblClickInterval; + private boolean preventClickWhenDblClick; + @Nullable private Integer preventSliderTwitchDelay; + @Nullable private Integer sliderRollingAverage; + @Nullable private Integer sendOnlyIfDelta; + private boolean workaroundsOnlySliders; + + // OBS + private boolean obsEnabled; + private String obsAddress; + private String obsPort; + private String obsPassword; + + // VoiceMeeter + private boolean voicemeeterEnabled; + private String voicemeeterPath; + + // OSC + private Integer oscListenPort; + private List oscConnections; + + // Overlay + private boolean overlayEnabled; + private boolean overlayUseLog; + private boolean overlayShowNumber; + private String overlayBackgroundColor; + private String overlayTextColor; + private String overlayBarColor; + private String overlayBarBackgroundColor; + private int overlayWindowCornerRounding; + @Nullable private Integer overlayBarHeight; + @Nullable private Integer overlayBarCornerRounding; + @Nullable private OverlayPosition overlayPosition; + @Nullable private Integer overlayPadding; + private MqttSettings mqtt; + + public static SettingsDto from(Save save) { + var dto = new SettingsDto(); + dto.mainUIIcons = save.isMainUIIcons(); + dto.startupVersionCheck = save.isStartupVersionCheck(); + dto.forceVolume = save.isForceVolume(); + dto.dblClickInterval = save.getDblClickInterval(); + dto.preventClickWhenDblClick = save.isPreventClickWhenDblClick(); + dto.preventSliderTwitchDelay = save.getPreventSliderTwitchDelay(); + dto.sliderRollingAverage = save.getSliderRollingAverage(); + dto.sendOnlyIfDelta = save.getSendOnlyIfDelta(); + dto.workaroundsOnlySliders = save.isWorkaroundsOnlySliders(); + dto.obsEnabled = save.isObsEnabled(); + dto.obsAddress = save.getObsAddress(); + dto.obsPort = save.getObsPort(); + dto.obsPassword = save.getObsPassword(); + dto.voicemeeterEnabled = save.isVoicemeeterEnabled(); + dto.voicemeeterPath = save.getVoicemeeterPath(); + dto.oscListenPort = save.getOscListenPort(); + dto.oscConnections = save.getOscConnections(); + dto.overlayEnabled = save.isOverlayEnabled(); + dto.overlayUseLog = save.isOverlayUseLog(); + dto.overlayShowNumber = save.isOverlayShowNumber(); + dto.overlayBackgroundColor = save.getOverlayBackgroundColor(); + dto.overlayTextColor = save.getOverlayTextColor(); + dto.overlayBarColor = save.getOverlayBarColor(); + dto.overlayBarBackgroundColor = save.getOverlayBarBackgroundColor(); + dto.overlayWindowCornerRounding = save.getOverlayWindowCornerRounding(); + dto.overlayBarHeight = save.getOverlayBarHeight(); + dto.overlayBarCornerRounding = save.getOverlayBarCornerRounding(); + dto.overlayPosition = save.getOverlayPosition(); + dto.overlayPadding = save.getOverlayPadding(); + dto.mqtt = save.getMqtt(); + return dto; + } + + public void applyTo(Save save) { + save.setMainUIIcons(mainUIIcons); + save.setStartupVersionCheck(startupVersionCheck); + save.setForceVolume(forceVolume); + save.setDblClickInterval(dblClickInterval); + save.setPreventClickWhenDblClick(preventClickWhenDblClick); + save.setPreventSliderTwitchDelay(preventSliderTwitchDelay); + save.setSliderRollingAverage(sliderRollingAverage); + save.setSendOnlyIfDelta(sendOnlyIfDelta); + save.setWorkaroundsOnlySliders(workaroundsOnlySliders); + save.setObsEnabled(obsEnabled); + save.setObsAddress(obsAddress); + save.setObsPort(obsPort); + save.setObsPassword(obsPassword); + save.setVoicemeeterEnabled(voicemeeterEnabled); + save.setVoicemeeterPath(voicemeeterPath); + save.setOscListenPort(oscListenPort); + save.setOscConnections(oscConnections); + save.setOverlayEnabled(overlayEnabled); + save.setOverlayUseLog(overlayUseLog); + save.setOverlayShowNumber(overlayShowNumber); + save.setOverlayBackgroundColor(overlayBackgroundColor); + save.setOverlayTextColor(overlayTextColor); + save.setOverlayBarColor(overlayBarColor); + save.setOverlayBarBackgroundColor(overlayBarBackgroundColor); + save.setOverlayWindowCornerRounding(overlayWindowCornerRounding); + save.setOverlayBarHeight(overlayBarHeight); + save.setOverlayBarCornerRounding(overlayBarCornerRounding); + save.setOverlayPosition(overlayPosition); + save.setOverlayPadding(overlayPadding); + save.setMqtt(mqtt); + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsAssignmentChangedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsAssignmentChangedEvent.java new file mode 100644 index 00000000..a5d246ed --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsAssignmentChangedEvent.java @@ -0,0 +1,9 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.getpcpanel.commands.Commands; +import com.getpcpanel.rest.EventBroadcaster.AssignmentChangedEvent.Kinds; + +@JsonTypeName("assignment_changed") +public record WsAssignmentChangedEvent(String serial, Kinds kind, int index, Commands commands) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsButtonEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsButtonEvent.java new file mode 100644 index 00000000..d1aa0328 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsButtonEvent.java @@ -0,0 +1,7 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; + +@JsonTypeName("button_press") +public record WsButtonEvent(String serial, int button, boolean pressed) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsControlSettingChangedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsControlSettingChangedEvent.java new file mode 100644 index 00000000..66b37b08 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsControlSettingChangedEvent.java @@ -0,0 +1,8 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.getpcpanel.profile.dto.KnobSetting; + +@JsonTypeName("control_setting_changed") +public record WsControlSettingChangedEvent(String serial, int index, KnobSetting settings) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceConnectedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceConnectedEvent.java new file mode 100644 index 00000000..be407264 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceConnectedEvent.java @@ -0,0 +1,10 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.getpcpanel.rest.model.dto.DeviceSnapshotDto; + +@JsonTypeName("device_connected") +public record WsDeviceConnectedEvent( + DeviceSnapshotDto deviceSnapshot +) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceDisconnectedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceDisconnectedEvent.java new file mode 100644 index 00000000..c5fa06fd --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceDisconnectedEvent.java @@ -0,0 +1,7 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; + +@JsonTypeName("device_disconnected") +public record WsDeviceDisconnectedEvent(String serial) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceRenamedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceRenamedEvent.java new file mode 100644 index 00000000..73f2ebc4 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceRenamedEvent.java @@ -0,0 +1,7 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; + +@JsonTypeName("device_renamed") +public record WsDeviceRenamedEvent(String serial, String displayName) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsEvent.java new file mode 100644 index 00000000..ad235d06 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsEvent.java @@ -0,0 +1,25 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.getpcpanel.rest.model.dto.DeviceSnapshotDto; + +@JsonTypeInfo(use = Id.NAME, include = As.PROPERTY, property = "type") +@JsonSubTypes({ + @Type(value = WsAssignmentChangedEvent.class, name = "assignment_changed"), + @Type(value = WsButtonEvent.class, name = "button_press"), + @Type(value = WsDeviceConnectedEvent.class, name = "device_connected"), + @Type(value = WsDeviceDisconnectedEvent.class, name = "device_disconnected"), + @Type(value = WsDeviceRenamedEvent.class, name = "device_renamed"), + @Type(value = WsKnobEvent.class, name = "knob_rotate"), + @Type(value = WsLightingChangedEvent.class, name = "lighting_changed"), + @Type(value = WsProfileSwitchedEvent.class, name = "profile_switched"), + @Type(value = WsVisualColorsChangedEvent.class, name = "visual_colors_changed"), + @Type(value = DeviceSnapshotDto.class, name = "device_snapshot"), + @Type(value = WsControlSettingChangedEvent.class, name = "control_setting_changed") +}) +public interface WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsKnobEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsKnobEvent.java new file mode 100644 index 00000000..745a8088 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsKnobEvent.java @@ -0,0 +1,7 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; + +@JsonTypeName("knob_rotate") +public record WsKnobEvent(String serial, int knob, int value) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsLightingChangedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsLightingChangedEvent.java new file mode 100644 index 00000000..6bdbb137 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsLightingChangedEvent.java @@ -0,0 +1,17 @@ +package com.getpcpanel.rest.model.ws; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.getpcpanel.profile.dto.LightingConfig; + +@JsonTypeName("lighting_changed") +public record WsLightingChangedEvent( + String serial, + LightingConfig lightingConfig, + List dialColors, + List sliderLabelColors, + List> sliderColors, + String logoColor +) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsProfileSwitchedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsProfileSwitchedEvent.java new file mode 100644 index 00000000..4c30bb4a --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsProfileSwitchedEvent.java @@ -0,0 +1,18 @@ +package com.getpcpanel.rest.model.ws; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.getpcpanel.rest.model.dto.ProfileSnapshotDto; + +@JsonTypeName("profile_switched") +public record WsProfileSwitchedEvent( + String serial, + String profileName, + ProfileSnapshotDto profileSnapshot, + List dialColors, + List sliderLabelColors, + List
Handles: hello/identify handshake, optional SHA-256 password auth, + * request/response correlation, InputMuteStateChanged events. + */ +@Log4j2 +public class ObsWebSocketClient implements WebSocket.Listener { + + // OBS WebSocket 5 opcodes + private static final int OP_HELLO = 0; + private static final int OP_IDENTIFY = 1; + private static final int OP_IDENTIFIED = 2; + private static final int OP_EVENT = 5; + private static final int OP_REQUEST = 6; + private static final int OP_REQUEST_RESPONSE = 7; + + // EventSubscriptions bit: Inputs (for InputMuteStateChanged) + private static final int EVENT_SUB_INPUTS = 1 << 3; + + private final ObjectMapper mapper; + private final String password; + private final Consumer onConnected; + private final Consumer onMuteChange; + + private WebSocket webSocket; + private final ConcurrentHashMap> pending = new ConcurrentHashMap<>(); + private final StringBuilder textBuffer = new StringBuilder(); + private volatile boolean connected = false; + + public ObsWebSocketClient(ObjectMapper mapper, String password, + Consumer onConnected, Consumer onMuteChange) { + this.mapper = mapper; + this.password = password; + this.onConnected = onConnected; + this.onMuteChange = onMuteChange; + } + + public void connect(String host, int port, long timeoutMs) throws Exception { + var uri = URI.create("ws://" + host + ":" + port); + webSocket = HttpClient.newHttpClient() + .newWebSocketBuilder() + .buildAsync(uri, this) + .get(timeoutMs, TimeUnit.MILLISECONDS); + } + + public void disconnect() { + connected = false; + if (webSocket != null) { + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "bye"); + } + } + + public boolean isConnected() { + return connected; + } + + // --- WebSocket.Listener --- + + @Override + public CompletionStage> onText(WebSocket ws, CharSequence data, boolean last) { + textBuffer.append(data); + ws.request(1); + if (!last) { + return null; + } + var text = textBuffer.toString(); + textBuffer.setLength(0); + try { + handleMessage(mapper.readTree(text)); + } catch (Exception e) { + log.warn("OBS: failed to handle message", e); + } + return null; + } + + @Override + public CompletionStage> onClose(WebSocket ws, int statusCode, String reason) { + if (connected) { + connected = false; + onConnected.accept(false); + } + return null; + } + + @Override + public void onError(WebSocket ws, Throwable error) { + log.warn("OBS WebSocket error: {}", error.getMessage()); + if (connected) { + connected = false; + onConnected.accept(false); + } + pending.values().forEach(f -> f.completeExceptionally(error)); + pending.clear(); + } + + // --- Protocol handling --- + + private void handleMessage(JsonNode msg) throws Exception { + var op = msg.path("op").asInt(-1); + var d = msg.path("d"); + switch (op) { + case OP_HELLO -> identify(d); + case OP_IDENTIFIED -> { + connected = true; + log.info("OBS: connected and authenticated"); + onConnected.accept(true); + } + case OP_EVENT -> handleEvent(d); + case OP_REQUEST_RESPONSE -> { + var id = d.path("requestId").asText(null); + var future = id != null ? pending.remove(id) : null; + if (future != null) { + future.complete(d.path("responseData")); + } + } + default -> log.trace("OBS: unhandled opcode {}", op); + } + } + + private void identify(JsonNode hello) throws Exception { + var msg = mapper.createObjectNode(); + msg.put("op", OP_IDENTIFY); + var data = msg.putObject("d"); + data.put("rpcVersion", 1); + data.put("eventSubscriptions", EVENT_SUB_INPUTS); + var authNode = hello.path("authentication"); + if (!authNode.isMissingNode() && !authNode.isNull() && password != null && !password.isBlank()) { + data.put("authentication", computeAuth(password, + authNode.path("salt").asText(), + authNode.path("challenge").asText())); + } + send(msg); + } + + private void handleEvent(JsonNode d) { + var type = d.path("eventType").asText(); + if ("InputMuteStateChanged".equals(type)) { + var data = d.path("eventData"); + onMuteChange.accept(new OBSMuteEvent( + data.path("inputName").asText(), + data.path("inputMuted").asBoolean())); + } + } + + private static String computeAuth(String password, String salt, String challenge) throws Exception { + var md = MessageDigest.getInstance("SHA-256"); + var secret = Base64.getEncoder().encodeToString( + md.digest((password + salt).getBytes(StandardCharsets.UTF_8))); + md.reset(); + return Base64.getEncoder().encodeToString( + md.digest((secret + challenge).getBytes(StandardCharsets.UTF_8))); + } + + private void send(Object obj) { + try { + webSocket.sendText(mapper.writeValueAsString(obj), true); + } catch (Exception e) { + log.warn("OBS: failed to send message", e); + } + } + + private JsonNode request(String type, ObjectNode fields) throws Exception { + var id = UUID.randomUUID().toString(); + var msg = mapper.createObjectNode(); + msg.put("op", OP_REQUEST); + var d = msg.putObject("d"); + d.put("requestType", type); + d.put("requestId", id); + if (fields != null) { + d.set("requestData", fields); + } + var future = new CompletableFuture(); + pending.put(id, future); + send(msg); + return future.get(5, TimeUnit.SECONDS); + } + + // --- High-level OBS operations --- + + public List getSourcesWithAudio() { + try { + var resp = request("GetInputList", null); + var list = new ArrayList(); + resp.path("inputs").forEach(n -> list.add(n.path("inputName").asText())); + return list; + } catch (Exception e) { + log.warn("OBS: GetInputList failed: {}", e.getMessage()); + return List.of(); + } + } + + public Map getSourcesWithMuteState() { + var map = new LinkedHashMap(); + for (var source : getSourcesWithAudio()) { + try { + var fields = mapper.createObjectNode().put("inputName", source); + var resp = request("GetInputMute", fields); + map.put(source, resp.path("inputMuted").asBoolean()); + } catch (Exception e) { + log.trace("OBS: GetInputMute failed for {}: {}", source, e.getMessage()); + } + } + return map; + } + + public List getScenes() { + try { + var resp = request("GetSceneList", null); + var list = new ArrayList(); + resp.path("scenes").forEach(n -> list.add(n.path("sceneName").asText())); + return list; + } catch (Exception e) { + log.warn("OBS: GetSceneList failed: {}", e.getMessage()); + return List.of(); + } + } + + /** vol is 0–100; converted to OBS volume multiplier 0.0–1.0. */ + public void setSourceVolume(String sourceName, int vol) { + try { + var fields = mapper.createObjectNode() + .put("inputName", sourceName) + .put("inputVolumeMultiplier", vol / 100.0); + request("SetInputVolume", fields); + } catch (Exception e) { + log.warn("OBS: SetInputVolume failed: {}", e.getMessage()); + } + } + + public void toggleSourceMute(String sourceName) { + try { + var fields = mapper.createObjectNode().put("inputName", sourceName); + request("ToggleInputMute", fields); + } catch (Exception e) { + log.warn("OBS: ToggleInputMute failed: {}", e.getMessage()); + } + } + + public void setSourceMute(String sourceName, boolean mute) { + try { + var fields = mapper.createObjectNode() + .put("inputName", sourceName) + .put("inputMuted", mute); + request("SetInputMute", fields); + } catch (Exception e) { + log.warn("OBS: SetInputMute failed: {}", e.getMessage()); + } + } + + public void setCurrentScene(String sceneName) { + try { + var fields = mapper.createObjectNode().put("sceneName", sceneName); + request("SetCurrentProgramScene", fields); + } catch (Exception e) { + log.warn("OBS: SetCurrentProgramScene failed: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/com/getpcpanel/osc/OSCService.java b/src/main/java/com/getpcpanel/osc/OSCService.java index 0b01a9c6..77e498c0 100644 --- a/src/main/java/com/getpcpanel/osc/OSCService.java +++ b/src/main/java/com/getpcpanel/osc/OSCService.java @@ -10,14 +10,11 @@ import javax.annotation.Nonnull; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; -import org.springframework.util.CollectionUtils; - -import com.getpcpanel.hid.DeviceCommunicationHandler; -import com.getpcpanel.profile.OSCBinding; -import com.getpcpanel.profile.OSCConnectionInfo; +import com.getpcpanel.hid.DeviceCommunicationHandler.ButtonPressEvent; +import com.getpcpanel.hid.DeviceCommunicationHandler.KnobRotateEvent; import com.getpcpanel.profile.SaveService; +import com.getpcpanel.profile.dto.OSCBinding; +import com.getpcpanel.profile.dto.OSCConnectionInfo; import com.getpcpanel.util.Util; import com.illposed.osc.OSCBadDataEvent; import com.illposed.osc.OSCBundle; @@ -29,16 +26,18 @@ import com.illposed.osc.transport.OSCPortOut; import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; import lombok.Getter; -import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import one.util.streamex.StreamEx; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public class OSCService { - private final SaveService saveService; + @Inject + SaveService saveService; private OSCPortIn portIn; private List ports = List.of(); private Integer prevListenPort; @@ -46,7 +45,6 @@ public class OSCService { @Getter private final Set addresses = new HashSet<>(); @PostConstruct - @EventListener(SaveService.SaveEvent.class) public void saveChanged() { log.trace("Save changed, restarting OSC"); initSend(); @@ -105,14 +103,13 @@ private void stopPortIn() { } } - @EventListener - public void dialAction(DeviceCommunicationHandler.KnobRotateEvent dial) { - if (dial.initial() || CollectionUtils.isEmpty(ports)) { + public void dialAction(@Observes KnobRotateEvent dial) { + if (dial.initial() || ports == null || ports.isEmpty()) { return; } saveService.getProfile(dial.serialNum()).ifPresent(profile -> { - var knobLength = profile.getLightingConfig().getKnobConfigs().length; + var knobLength = profile.lightingConfig().knobConfigs().length; var idx = dial.knob() < knobLength ? dial.knob() * 2 : dial.knob() + knobLength; var target = profile.getOscBinding().get(idx); @@ -122,9 +119,8 @@ public void dialAction(DeviceCommunicationHandler.KnobRotateEvent dial) { }); } - @EventListener - public void dialAction(DeviceCommunicationHandler.ButtonPressEvent button) { - if (CollectionUtils.isEmpty(ports)) { + public void dialAction(@Observes ButtonPressEvent button) { + if (ports == null || ports.isEmpty()) { return; } var idx = button.button() * 2 + 1; @@ -152,7 +148,8 @@ private float determineValue(@Nonnull OSCBinding target, float val) { return Util.map(val, 0, 1, target.min(), target.max()); } - private static @Nonnull OSCMessage buildMessage(OSCBinding target, String defaultTarget, float val) { + @Nonnull + private static OSCMessage buildMessage(OSCBinding target, String defaultTarget, float val) { var targetString = target == null ? defaultTarget : target.address(); try { return new OSCMessage(targetString, List.of(val)); diff --git a/src/main/java/com/getpcpanel/overlay/Overlay.java b/src/main/java/com/getpcpanel/overlay/Overlay.java new file mode 100644 index 00000000..aa6a72bc --- /dev/null +++ b/src/main/java/com/getpcpanel/overlay/Overlay.java @@ -0,0 +1,122 @@ +package com.getpcpanel.overlay; + +import java.awt.Image; +import java.awt.Toolkit; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import javax.swing.SwingUtilities; + +import com.getpcpanel.commands.Commands; +import com.getpcpanel.commands.IconService; +import com.getpcpanel.commands.PCPanelControlEvent; +import com.getpcpanel.commands.command.ButtonAction; +import com.getpcpanel.commands.command.DialAction; +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.profile.SaveService.SaveEvent; +import com.getpcpanel.profile.dto.OverlayPosition; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import lombok.RequiredArgsConstructor; +import one.util.streamex.StreamEx; + +@ApplicationScoped +@RequiredArgsConstructor +public class Overlay { + private final SaveService save; + private final IconService iconService; + private VolumeOverlay overlay = new VolumeOverlay(); + + // @PostConstruct + // public void init() { + // SwingUtilities.invokeLater(() -> { + // overlay; + // }); + // } + + public void updateSaveValues(@Observes SaveEvent event) { + updateStyle(null); + determinePosition(); + } + + private void determinePosition() { + var window = Toolkit.getDefaultToolkit().getScreenSize(); + var x = window.width; + var y = window.height; + var width = overlay.getWidth(); + var height = overlay.getHeight(); + + var position = save == null ? OverlayPosition.topLeft : save.get().getOverlayPosition(); + var padding = save == null ? 0 : save.get().getOverlayPadding(); + var newY = switch (position) { + case topLeft, topMiddle, topRight -> padding; + case middleLeft, middleMiddle, middleRight -> y / 2 - height / 2; + case bottomLeft, bottomMiddle, bottomRight -> y - overlay.getHeight() - padding; + }; + var newX = switch (position) { + case topLeft, middleLeft, bottomLeft -> padding; + case topMiddle, middleMiddle, bottomMiddle -> x / 2 - width / 2; + case topRight, middleRight, bottomRight -> x - width - padding; + }; + setXY(newX, newY); + } + + private void setXY(int x, int y) { + var b = overlay.getBounds(); + b.x = x; + b.y = y; + overlay.setBounds(b); + } + + public void show(float value) { + showDebounced(value, () -> CommandAndIcon.DEFAULT, x -> true); + } + + public void updateStyle(@Nullable @Observes SaveEvent event) { + SwingUtilities.invokeLater(() -> overlay.setStyles(save.get())); + } + + public void handleControl(@Observes PCPanelControlEvent event) { + if (event.initial()) { + return; + } + var vol = event.vol(); + var value = vol == null ? -1 : save.get().isOverlayUseLog() ? vol.getValue(null, 0, 1) : vol.value() / 255f; + showDebounced(value, () -> determineIconImage(event), command -> true); + } + + private void showDebounced(float value, Supplier pre, Predicate pred) { + if (!save.get().isOverlayEnabled()) { + return; + } + SwingUtilities.invokeLater(() -> { + var cai = pre.get(); + if (hasOverlay(cai.command) && pred.test(cai.command)) { + overlay.show(value, cai.icon); + } + }); + } + + private boolean hasOverlay(Commands commands) { + return Commands.hasCommands(commands) && + StreamEx.of(commands.getCommands()).anyMatch(command -> command instanceof DialAction da && da.hasOverlay() + || command instanceof ButtonAction ba && ba.hasOverlay()); + } + + @Nonnull + private CommandAndIcon determineIconImage(PCPanelControlEvent event) { + return save.getProfile(event.serialNum()).map(profile -> { + var data = event.cmd(); + var setting = event.vol() == null ? null : profile.getKnobSettings(event.knob()); + return new CommandAndIcon(data, iconService.getImageFrom(data, setting)); + }).orElse(CommandAndIcon.DEFAULT); + } + + private record CommandAndIcon(Commands command, Image icon) { + static final CommandAndIcon DEFAULT = new CommandAndIcon(Commands.EMPTY, null); + } +} diff --git a/src/main/java/com/getpcpanel/overlay/VolumeOverlay.java b/src/main/java/com/getpcpanel/overlay/VolumeOverlay.java new file mode 100644 index 00000000..8e9fe608 --- /dev/null +++ b/src/main/java/com/getpcpanel/overlay/VolumeOverlay.java @@ -0,0 +1,265 @@ +package com.getpcpanel.overlay; + +import java.awt.Color; +import java.awt.Font; +import java.awt.GradientPaint; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.RenderingHints; +import java.awt.Toolkit; +import java.awt.geom.Ellipse2D; +import java.awt.geom.RoundRectangle2D; +import java.util.regex.Pattern; + +import javax.swing.JPanel; +import javax.swing.JWindow; +import javax.swing.Timer; +import javax.swing.UIManager; + +import com.getpcpanel.profile.Save; + +public class VolumeOverlay extends JWindow { + // Install the cross-platform (Metal) Look and Feel before any Swing + // component is constructed. This static block runs when VolumeOverlay is + // first loaded – before the implicit JWindow() super-constructor call – + // so JPanel / JRootPane can find their ComponentUI delegates. + static { + try { + UIManager.setLookAndFeel(UIManager.getCrossPlatformLookAndFeelClassName()); + } catch (Exception ignored) { + // If Metal L&F is unavailable in native image, swallow and continue. + } + } + + private static final int WIDTH = 340; + private static final int DEFAULT_HEIGHT = 56; + private static final int DEFAULT_CORNER_RADIUS = 28; + private static final int CONTENT_PADDING = 10; + private static final int ICON_SIZE = 36; + private static final int DEFAULT_BAR_HEIGHT = 10; + private static final int DEFAULT_BAR_CORNER_RADIUS = DEFAULT_BAR_HEIGHT; + private static final int VALUE_LABEL_WIDTH = 36; + private static final int VALUE_GAP = 8; + private static final int DISMISS_MS = 2000; // auto-hide after 2 s + private static final Pattern RGB_PATTERN = Pattern.compile("rgba?\\(([^)]+)\\)", Pattern.CASE_INSENSITIVE); + private static final Pattern COLOR_COMPONENT_SEPARATOR = Pattern.compile("\\s*,\\s*"); + + private static final Color DEFAULT_BG_COLOR = new Color(80, 80, 90, 210); + private static final Color DEFAULT_BAR_COLOR = new Color(0, 200, 230, 255); + private static final Color DEFAULT_BAR_TRACK_COLOR = new Color(255, 255, 255, 50); + private static final Color DEFAULT_TEXT_COLOR = new Color(230, 230, 230, 255); + + private int value; + private final Timer dismissTimer; + private Image icon; + private boolean showNumber = true; + private int windowCornerRadius = DEFAULT_CORNER_RADIUS; + private int barHeight = DEFAULT_BAR_HEIGHT; + private int barCornerRadius = DEFAULT_BAR_CORNER_RADIUS; + private Color backgroundColor = DEFAULT_BG_COLOR; + private Color barColor = DEFAULT_BAR_COLOR; + private Color barTrackColor = DEFAULT_BAR_TRACK_COLOR; + private Color textColor = DEFAULT_TEXT_COLOR; + + VolumeOverlay() { + setAlwaysOnTop(true); + setSize(WIDTH, DEFAULT_HEIGHT); + setBackground(new Color(0, 0, 0, 0)); + + JPanel panel = new OverlayPanel(); + panel.setOpaque(false); + setContentPane(panel); + + var screen = Toolkit.getDefaultToolkit().getScreenSize(); + setLocation((screen.width - WIDTH) / 2, 48); + + dismissTimer = new Timer(DISMISS_MS, _ -> setVisible(false)); + dismissTimer.setRepeats(false); + } + + public void show(float v, Image icon) { + this.icon = icon; + update(Math.round(v * 100f)); + } + + public void setStyles(Save save) { + showNumber = save.isOverlayShowNumber(); + backgroundColor = parseColor(save.getOverlayBackgroundColor(), DEFAULT_BG_COLOR); + textColor = parseColor(save.getOverlayTextColor(), DEFAULT_TEXT_COLOR); + barColor = parseColor(save.getOverlayBarColor(), DEFAULT_BAR_COLOR); + barTrackColor = parseColor(save.getOverlayBarBackgroundColor(), DEFAULT_BAR_TRACK_COLOR); + windowCornerRadius = Math.max(0, save.getOverlayWindowCornerRounding()); + barHeight = Math.max(2, save.getOverlayBarHeight()); + barCornerRadius = Math.max(0, save.getOverlayBarCornerRounding()); + + var computedHeight = Math.max(DEFAULT_HEIGHT, CONTENT_PADDING * 2 + Math.max(ICON_SIZE, barHeight)); + setSize(WIDTH, computedHeight); + revalidate(); + repaint(); + } + + private void update(int v) { + value = Math.clamp(v, 0, 100); + repaint(); + setVisible(true); + dismissTimer.restart(); + } + + private class OverlayPanel extends JPanel { + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + var g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_LCD_HRGB); + + var w = getWidth(); + var h = getHeight(); + var windowArc = Math.min(windowCornerRadius, Math.min(w, h)); + var barArc = Math.min(barCornerRadius, barHeight); + + // ── Background pill ────────────────────────────────────────── + g2.setColor(backgroundColor); + g2.fill(new RoundRectangle2D.Float(0, 0, w, h, windowArc, windowArc)); + + // Subtle top highlight (glass shimmer) + var gloss = new GradientPaint( + 0, 0, withAlpha(Color.WHITE, Math.clamp(backgroundColor.getAlpha() / 4, 18, 60)), + 0, h / 2f, withAlpha(Color.WHITE, 0)); + g2.setPaint(gloss); + g2.fill(new RoundRectangle2D.Float(0, 0, w, h / 2f, windowArc, windowArc)); + + // ── Icon area ──────────────────────────────────────────────── + var iconX = CONTENT_PADDING + 2; + var iconY = (h - ICON_SIZE) / 2; + if (icon != null) { + g2.drawImage(icon, iconX, iconY, ICON_SIZE, ICON_SIZE, null); + } + + // ── Layout constants ───────────────────────────────────────── + var afterIcon = iconX + ICON_SIZE + CONTENT_PADDING; + var valueWidth = showNumber ? VALUE_LABEL_WIDTH : 0; + var barEndX = w - CONTENT_PADDING - valueWidth - (showNumber ? VALUE_GAP : 0); + var barY = (h - barHeight) / 2; + var barWidth = barEndX - afterIcon; + + // ── Progress bar track ─────────────────────────────────────── + g2.setColor(barTrackColor); + g2.fill(new RoundRectangle2D.Float(afterIcon, barY, barWidth, barHeight, barArc, barArc)); + + // ── Progress bar fill ──────────────────────────────────────── + var fillWidth = Math.round(barWidth * (value / 100f)); + if (fillWidth > 0) { + var fillGrad = new GradientPaint( + afterIcon, 0, scaleColor(barColor, 1.15f), + afterIcon + fillWidth, 0, scaleColor(barColor, 0.82f)); + g2.setPaint(fillGrad); + g2.fill(new RoundRectangle2D.Float(afterIcon, barY, fillWidth, barHeight, barArc, barArc)); + + // Bright leading cap + if (fillWidth >= barHeight) { + g2.setColor(withAlpha(scaleColor(barColor, 1.35f), Math.clamp(barColor.getAlpha(), 120, 220))); + var capX = afterIcon + fillWidth - barHeight; + g2.fill(new Ellipse2D.Float(capX, barY, barHeight, barHeight)); + } + } + + // ── Value label ────────────────────────────────────────────── + if (showNumber) { + g2.setColor(textColor); + g2.setFont(new Font("SF Pro Display", Font.BOLD, 16)); + // Fallback font chain + if (!g2.getFont().getFamily().equals("SF Pro Display")) { + g2.setFont(new Font("Segoe UI", Font.BOLD, 16)); + } + var label = String.valueOf(value); + var fm = g2.getFontMetrics(); + var labelX = w - CONTENT_PADDING - valueWidth + (valueWidth - fm.stringWidth(label)) / 2; + var labelY = (h + fm.getAscent() - fm.getDescent()) / 2; + g2.drawString(label, labelX, labelY); + } + + g2.dispose(); + } + } + + private static Color parseColor(String value, Color fallback) { + if (value == null || value.isBlank()) { + return fallback; + } + + try { + var trimmed = value.trim(); + if (trimmed.startsWith("#")) { + return parseHexColor(trimmed); + } + + var matcher = RGB_PATTERN.matcher(trimmed); + if (matcher.matches()) { + var parts = COLOR_COMPONENT_SEPARATOR.split(matcher.group(1)); + if (parts.length == 3 || parts.length == 4) { + var red = clampChannel(Integer.parseInt(parts[0])); + var green = clampChannel(Integer.parseInt(parts[1])); + var blue = clampChannel(Integer.parseInt(parts[2])); + var alpha = parts.length == 4 ? parseAlpha(parts[3]) : 255; + return new Color(red, green, blue, alpha); + } + } + } catch (RuntimeException ignored) { + // Fall back to default styling for invalid persisted values. + } + + return fallback; + } + + private static Color parseHexColor(String value) { + var hex = value.substring(1); + return switch (hex.length()) { + case 3 -> new Color( + Integer.parseInt(hex.substring(0, 1).repeat(2), 16), + Integer.parseInt(hex.substring(1, 2).repeat(2), 16), + Integer.parseInt(hex.substring(2, 3).repeat(2), 16)); + case 4 -> new Color( + Integer.parseInt(hex.substring(0, 1).repeat(2), 16), + Integer.parseInt(hex.substring(1, 2).repeat(2), 16), + Integer.parseInt(hex.substring(2, 3).repeat(2), 16), + Integer.parseInt(hex.substring(3, 4).repeat(2), 16)); + case 6 -> new Color( + Integer.parseInt(hex.substring(0, 2), 16), + Integer.parseInt(hex.substring(2, 4), 16), + Integer.parseInt(hex.substring(4, 6), 16)); + case 8 -> new Color( + Integer.parseInt(hex.substring(0, 2), 16), + Integer.parseInt(hex.substring(2, 4), 16), + Integer.parseInt(hex.substring(4, 6), 16), + Integer.parseInt(hex.substring(6, 8), 16)); + default -> throw new IllegalArgumentException("Unsupported color format: " + value); + }; + } + + private static int parseAlpha(String value) { + var alpha = Double.parseDouble(value); + return clampChannel(roundToInt(alpha <= 1 ? alpha * 255 : alpha)); + } + + private static int roundToInt(double value) { + return Long.valueOf(Math.round(value)).intValue(); + } + + private static int clampChannel(int value) { + return Math.clamp(value, 0, 255); + } + + private static Color withAlpha(Color color, int alpha) { + return new Color(color.getRed(), color.getGreen(), color.getBlue(), clampChannel(alpha)); + } + + private static Color scaleColor(Color color, float factor) { + return new Color( + clampChannel(Math.round(color.getRed() * factor)), + clampChannel(Math.round(color.getGreen() * factor)), + clampChannel(Math.round(color.getBlue() * factor)), + color.getAlpha()); + } +} diff --git a/src/main/java/com/getpcpanel/platform/LinuxBuild.java b/src/main/java/com/getpcpanel/platform/LinuxBuild.java new file mode 100644 index 00000000..52690ac5 --- /dev/null +++ b/src/main/java/com/getpcpanel/platform/LinuxBuild.java @@ -0,0 +1,19 @@ +package com.getpcpanel.platform; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import io.quarkus.arc.properties.IfBuildProperty; +import jakarta.enterprise.inject.Stereotype; + +@Stereotype +@IfBuildProperty(name = "pcpanel.build.os", stringValue = "linux") +@Retention(RUNTIME) +@Target(TYPE) +public @interface LinuxBuild { +} + + diff --git a/src/main/java/com/getpcpanel/platform/WindowsBuild.java b/src/main/java/com/getpcpanel/platform/WindowsBuild.java new file mode 100644 index 00000000..13319075 --- /dev/null +++ b/src/main/java/com/getpcpanel/platform/WindowsBuild.java @@ -0,0 +1,18 @@ +package com.getpcpanel.platform; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import io.quarkus.arc.properties.IfBuildProperty; +import jakarta.enterprise.inject.Stereotype; + +@Stereotype +@IfBuildProperty(name = "pcpanel.build.os", stringValue = "windows") +@Retention(RUNTIME) +@Target(TYPE) +public @interface WindowsBuild { +} + diff --git a/src/main/java/com/getpcpanel/profile/DeviceSave.java b/src/main/java/com/getpcpanel/profile/DeviceSave.java index 10411cf4..9dc517e8 100644 --- a/src/main/java/com/getpcpanel/profile/DeviceSave.java +++ b/src/main/java/com/getpcpanel/profile/DeviceSave.java @@ -46,14 +46,14 @@ public Optional getProfile(@Nullable String name) { if (name == null) { return Optional.empty(); } - return StreamEx.of(getProfiles()).findFirst(p -> p.getName().equals(name)); + return StreamEx.of(profiles).findFirst(p -> p.getName().equals(name)); } @JsonIgnore private Optional getCurrentProfile() { var p = getProfile(currentProfileName); if (!profiles.isEmpty() && p.isEmpty()) { - return Optional.of(getProfiles().get(0)); + return Optional.of(profiles.get(0)); } return p; } diff --git a/src/main/java/com/getpcpanel/profile/KnobSettingMapDeserializer.java b/src/main/java/com/getpcpanel/profile/KnobSettingMapDeserializer.java index 207c60b1..3f214743 100644 --- a/src/main/java/com/getpcpanel/profile/KnobSettingMapDeserializer.java +++ b/src/main/java/com/getpcpanel/profile/KnobSettingMapDeserializer.java @@ -9,6 +9,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; +import com.getpcpanel.profile.dto.KnobSetting; public class KnobSettingMapDeserializer extends JsonDeserializer> { @Override diff --git a/src/main/java/com/getpcpanel/profile/LightingChangedToDefaultEvent.java b/src/main/java/com/getpcpanel/profile/LightingChangedToDefaultEvent.java new file mode 100644 index 00000000..e356d4eb --- /dev/null +++ b/src/main/java/com/getpcpanel/profile/LightingChangedToDefaultEvent.java @@ -0,0 +1,8 @@ +package com.getpcpanel.profile; + +/** + * Fired when lighting is changed back to its default/profile setting. + * Moved from com.getpcpanel.ui to profile package as part of Quarkus migration. + */ +public record LightingChangedToDefaultEvent(String serialNum) { +} diff --git a/src/main/java/com/getpcpanel/profile/Profile.java b/src/main/java/com/getpcpanel/profile/Profile.java index 42196830..8176e6c8 100644 --- a/src/main/java/com/getpcpanel/profile/Profile.java +++ b/src/main/java/com/getpcpanel/profile/Profile.java @@ -9,6 +9,9 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.getpcpanel.commands.Commands; import com.getpcpanel.device.DeviceType; +import com.getpcpanel.profile.dto.KnobSetting; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.profile.dto.OSCBinding; import lombok.Data; @@ -34,7 +37,7 @@ public Profile(String name, DeviceType dt) { protected Profile() { } - public LightingConfig getLightingConfig() { + public LightingConfig lightingConfig() { return lightingConfig.deepCopy(); } diff --git a/src/main/java/com/getpcpanel/profile/ProfileWindowFocusService.java b/src/main/java/com/getpcpanel/profile/ProfileWindowFocusService.java index ffb4064e..fc95107c 100644 --- a/src/main/java/com/getpcpanel/profile/ProfileWindowFocusService.java +++ b/src/main/java/com/getpcpanel/profile/ProfileWindowFocusService.java @@ -1,21 +1,21 @@ package com.getpcpanel.profile; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; +import jakarta.inject.Inject; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.context.ApplicationScoped; import com.getpcpanel.cpp.windows.WindowFocusChangedEvent; import com.getpcpanel.hid.DeviceHolder; import lombok.RequiredArgsConstructor; -@Service -@RequiredArgsConstructor +@ApplicationScoped public class ProfileWindowFocusService { - private final DeviceHolder devices; + @Inject + DeviceHolder devices; private String previousApplication = ""; - @EventListener - public void onFocusChanged(WindowFocusChangedEvent event) { + public void onFocusChanged(@Observes WindowFocusChangedEvent event) { devices.values().forEach(d -> d.focusChanged(previousApplication, event.application())); previousApplication = event.application(); } diff --git a/src/main/java/com/getpcpanel/profile/Save.java b/src/main/java/com/getpcpanel/profile/Save.java index df9bb4a1..b8791689 100644 --- a/src/main/java/com/getpcpanel/profile/Save.java +++ b/src/main/java/com/getpcpanel/profile/Save.java @@ -9,7 +9,10 @@ import javax.annotation.Nullable; import com.getpcpanel.device.DeviceType; -import com.getpcpanel.ui.OverlayPosition; +import com.getpcpanel.profile.dto.MqttSettings; +import com.getpcpanel.profile.dto.OSCConnectionInfo; +import com.getpcpanel.profile.dto.OverlayPosition; +import com.getpcpanel.profile.dto.WaveLinkSettings; import lombok.Data; import lombok.extern.log4j.Log4j2; @@ -53,7 +56,7 @@ public class Save { private String overlayTextColor = DEFAULT_OVERLAY_TEXT_COLOR; private String overlayBarColor = DEFAULT_OVERLAY_BAR_COLOR; private String overlayBarBackgroundColor = DEFAULT_OVERLAY_BAR_BACKGROUND_COLOR; - @Nullable private Integer overlayWindowCornerRounding = 0; + private int overlayWindowCornerRounding; @Nullable private Integer overlayBarHeight = DEFAULT_OVERLAY_BAR_HEIGHT; @Nullable private Integer overlayBarCornerRounding = 0; @Nullable private OverlayPosition overlayPosition = DEFAULT_OVERLAY_POSITION; @@ -85,10 +88,6 @@ public void setSendOnlyIfDelta(Integer sendOnlyIfDelta) { this.sendOnlyIfDelta = sendOnlyIfDelta == null || sendOnlyIfDelta == 0 ? null : sendOnlyIfDelta; } - public int getOverlayWindowCornerRounding() { - return overlayWindowCornerRounding == null ? 0 : overlayWindowCornerRounding; - } - public int getOverlayBarCornerRounding() { return overlayBarCornerRounding == null ? 0 : overlayBarCornerRounding; } diff --git a/src/main/java/com/getpcpanel/profile/SaveService.java b/src/main/java/com/getpcpanel/profile/SaveService.java index 773029b5..a77309bf 100644 --- a/src/main/java/com/getpcpanel/profile/SaveService.java +++ b/src/main/java/com/getpcpanel/profile/SaveService.java @@ -11,35 +11,35 @@ import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.annotation.Lazy; -import org.springframework.stereotype.Service; import com.getpcpanel.Json; import com.getpcpanel.hid.DeviceHolder; import com.getpcpanel.util.Debouncer; import com.getpcpanel.util.FileUtil; +import io.quarkus.runtime.StartupEvent; import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import lombok.Setter; +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; import lombok.extern.log4j.Log4j2; import one.util.streamex.StreamEx; @Log4j2 -@Service -@RequiredArgsConstructor +@ApplicationScoped public class SaveService { private static final String saveFileName = "profiles.json"; - private final ApplicationEventPublisher eventPublisher; - private final FileUtil fileUtil; - private final Json json; - private final Debouncer debouncer; - @Autowired @Lazy @Setter private DeviceHolder devices; + @Inject Event eventBus; + @Inject FileUtil fileUtil; + @Inject Json json; + @Inject Debouncer debouncer; + @Inject DeviceHolder devices; @SuppressWarnings("StaticNonFinalField") private static String oldVersionEncountered; private Save save; + private boolean isNew = false; public Save get() { return save; @@ -54,27 +54,45 @@ public void load() { if (!saveFile.exists()) { log.info("No save file found, creating new one"); save = new Save(); - eventPublisher.publishEvent(new SaveEvent(save, true)); + isNew = true; return; } try { save = json.read(FileUtils.readFileToString(saveFile, Charset.defaultCharset()), Save.class); handleOldVersionEncountered(); - StreamEx.ofValues(save.getDevices()).forEach(d -> StreamEx.of(d.getProfiles()).findFirst(Profile::isMainProfile).ifPresent(p -> d.setCurrentProfile(p.getName()))); - eventPublisher.publishEvent(new SaveEvent(save, false)); + StreamEx.ofValues(save.getDevices()).forEach(d -> StreamEx.of(d.getProfiles()).findFirst(p -> p.isMainProfile()).ifPresent(p -> d.setCurrentProfile(p.getName()))); } catch (Exception e) { log.error("Unable to read file", e); save = new Save(); + isNew = true; } } + /** + * Fire the initial SaveEvent after all beans are fully initialized. + * Using @Priority(1) to ensure this runs before DeviceScanner.onStart() (default priority ~2000). + */ + @Priority(1) + public void onStart(@Observes StartupEvent ev) { + eventBus.fire(new SaveEvent(save, isNew)); + } + private void handleOldVersionEncountered() { if (StringUtils.isBlank(oldVersionEncountered)) { return; } backup(); - save(); + writeToFile(); // write file only, SaveEvent will be fired from onStart() + } + + private void writeToFile() { + var saveFile = fileUtil.getFile(saveFileName); + try { + FileUtils.writeStringToFile(saveFile, json.writePretty(save), Charset.defaultCharset()); + } catch (IOException e) { + log.error("Unable to save file", e); + } } private void backup() { @@ -108,14 +126,8 @@ private void tryMigrate(File saveFile) { } public void save() { - var saveFile = fileUtil.getFile(saveFileName); - try { - FileUtils.writeStringToFile(saveFile, json.writePretty(save), Charset.defaultCharset()); - } catch (IOException e) { - log.error("Unable to save file", e); - } - - eventPublisher.publishEvent(new SaveEvent(save, false)); + writeToFile(); + eventBus.fire(new SaveEvent(save, false)); } public void debouncedSave() { @@ -123,10 +135,9 @@ public void debouncedSave() { } public Optional getProfile(String serialNum) { - return devices.getDevice(serialNum).map(device -> get().getDeviceSave(serialNum).ensureCurrentProfile(device.getDeviceType())); + return devices.getDevice(serialNum).map(device -> get().getDeviceSave(serialNum).ensureCurrentProfile(device.deviceType())); } public record SaveEvent(Save save, boolean isNew) { } } - diff --git a/src/main/java/com/getpcpanel/profile/KnobSetting.java b/src/main/java/com/getpcpanel/profile/dto/KnobSetting.java similarity index 85% rename from src/main/java/com/getpcpanel/profile/KnobSetting.java rename to src/main/java/com/getpcpanel/profile/dto/KnobSetting.java index 03706ff6..052b76cb 100644 --- a/src/main/java/com/getpcpanel/profile/KnobSetting.java +++ b/src/main/java/com/getpcpanel/profile/dto/KnobSetting.java @@ -1,4 +1,4 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; import lombok.Data; diff --git a/src/main/java/com/getpcpanel/profile/LightingConfig.java b/src/main/java/com/getpcpanel/profile/dto/LightingConfig.java similarity index 77% rename from src/main/java/com/getpcpanel/profile/LightingConfig.java rename to src/main/java/com/getpcpanel/profile/dto/LightingConfig.java index eb0a8f86..4d1dd77f 100644 --- a/src/main/java/com/getpcpanel/profile/LightingConfig.java +++ b/src/main/java/com/getpcpanel/profile/dto/LightingConfig.java @@ -1,11 +1,11 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; import java.util.Arrays; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; import com.getpcpanel.device.DeviceType; -import com.getpcpanel.util.Util; -import javafx.scene.paint.Color; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -13,6 +13,7 @@ @AllArgsConstructor @Builder(toBuilder = true) +@JsonAutoDetect(fieldVisibility = Visibility.ANY) public class LightingConfig { private LightingMode lightingMode; private String[] individualColors = {}; @@ -89,31 +90,6 @@ public static LightingConfig defaultLightingConfig(DeviceType dt) { throw new IllegalArgumentException("unknown deviceType"); } - public static LightingConfig createSingleColor(Color[] color, boolean[] volumeTracking) { - var lc = new LightingConfig(); - lc.lightingMode = LightingMode.SINGLE_COLOR; - lc.individualColors = new String[color.length]; - for (var i = 0; i < color.length; i++) - lc.individualColors[i] = Util.formatHexString(color[i]); - lc.volumeBrightnessTrackingEnabled = volumeTracking; - return lc; - } - - public static LightingConfig createAllColor(Color color, boolean[] volumeTracking) { - var lc = new LightingConfig(); - lc.lightingMode = LightingMode.ALL_COLOR; - lc.allColor = Util.formatHexString(color); - lc.volumeBrightnessTrackingEnabled = volumeTracking; - return lc; - } - - public static LightingConfig createAllColor(Color color) { - var lc = new LightingConfig(); - lc.lightingMode = LightingMode.ALL_COLOR; - lc.allColor = Util.formatHexString(color); - return lc; - } - public static LightingConfig createRainbowAnimation(byte phaseShift, byte brightness, byte speed, boolean reverse) { var lc = new LightingConfig(); lc.lightingMode = LightingMode.ALL_RAINBOW; @@ -155,7 +131,14 @@ public static LightingConfig createBreathAnimation(byte hue, byte brightness, by return lc; } - public LightingMode getLightingMode() { + public static LightingConfig createAllColor(String color) { + var lc = new LightingConfig(); + lc.lightingMode = LightingMode.ALL_COLOR; + lc.allColor = color; + return lc; + } + + public LightingMode lightingMode() { return lightingMode; } @@ -163,37 +146,37 @@ public void setLightingMode(LightingMode lightingMode) { this.lightingMode = lightingMode; } - public String[] getIndividualColors() { + public String[] individualColors() { return individualColors; } - public String getAllColor() { + public String allColor() { return allColor; } - public boolean[] getVolumeBrightnessTrackingEnabled() { + public boolean[] volumeBrightnessTrackingEnabled() { if (volumeBrightnessTrackingEnabled == null) volumeBrightnessTrackingEnabled = new boolean[0]; return volumeBrightnessTrackingEnabled; } - public byte getRainbowPhaseShift() { + public byte rainbowPhaseShift() { return rainbowPhaseShift; } - public byte getRainbowBrightness() { + public byte rainbowBrightness() { return rainbowBrightness; } - public byte getRainbowSpeed() { + public byte rainbowSpeed() { return rainbowSpeed; } - public byte getRainbowReverse() { + public byte rainbowReverse() { return rainbowReverse; } - public byte getRainbowVertical() { + public byte rainbowVertical() { return rainbowVertical; } @@ -209,52 +192,54 @@ public void setRainbowSpeed(byte rainbowSpeed) { this.rainbowSpeed = rainbowSpeed; } - public byte getWaveHue() { + public byte waveHue() { return waveHue; } - public byte getWaveBrightness() { + public byte waveBrightness() { return waveBrightness; } - public byte getWaveSpeed() { + public byte waveSpeed() { return waveSpeed; } - public byte getWaveReverse() { + public byte waveReverse() { return waveReverse; } - public byte getWaveBounce() { + public byte waveBounce() { return waveBounce; } - public byte getBreathHue() { + public byte breathHue() { return breathHue; } - public byte getBreathBrightness() { + public byte breathBrightness() { return breathBrightness; } - public byte getBreathSpeed() { + public byte breathSpeed() { return breathSpeed; } - public SingleKnobLightingConfig[] getKnobConfigs() { + public SingleKnobLightingConfig[] knobConfigs() { return knobConfigs; } - public SingleSliderLabelLightingConfig[] getSliderLabelConfigs() { + public SingleSliderLabelLightingConfig[] sliderLabelConfigs() { return sliderLabelConfigs; } - public SingleSliderLightingConfig[] getSliderConfigs() { + public SingleSliderLightingConfig[] sliderConfigs() { return sliderConfigs; } - public SingleLogoLightingConfig getLogoConfig() { + public SingleLogoLightingConfig logoConfig() { + if (logoConfig == null) { + logoConfig = new SingleLogoLightingConfig(); + } return logoConfig; } } - diff --git a/src/main/java/com/getpcpanel/profile/MqttSettings.java b/src/main/java/com/getpcpanel/profile/dto/MqttSettings.java similarity index 96% rename from src/main/java/com/getpcpanel/profile/MqttSettings.java rename to src/main/java/com/getpcpanel/profile/dto/MqttSettings.java index ca89202c..9284dd70 100644 --- a/src/main/java/com/getpcpanel/profile/MqttSettings.java +++ b/src/main/java/com/getpcpanel/profile/dto/MqttSettings.java @@ -1,4 +1,4 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; public record MqttSettings(boolean enabled, String host, Integer port, String username, String password, boolean secure, String baseTopic, diff --git a/src/main/java/com/getpcpanel/profile/OSCBinding.java b/src/main/java/com/getpcpanel/profile/dto/OSCBinding.java similarity index 81% rename from src/main/java/com/getpcpanel/profile/OSCBinding.java rename to src/main/java/com/getpcpanel/profile/dto/OSCBinding.java index 8ed7249e..6b0c4b69 100644 --- a/src/main/java/com/getpcpanel/profile/OSCBinding.java +++ b/src/main/java/com/getpcpanel/profile/dto/OSCBinding.java @@ -1,4 +1,4 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; public record OSCBinding(String address, float min, float max, boolean toggle) { public static final OSCBinding EMPTY = new OSCBinding("", 0, 1, false); diff --git a/src/main/java/com/getpcpanel/profile/OSCConnectionInfo.java b/src/main/java/com/getpcpanel/profile/dto/OSCConnectionInfo.java similarity index 62% rename from src/main/java/com/getpcpanel/profile/OSCConnectionInfo.java rename to src/main/java/com/getpcpanel/profile/dto/OSCConnectionInfo.java index 059301ce..d522db31 100644 --- a/src/main/java/com/getpcpanel/profile/OSCConnectionInfo.java +++ b/src/main/java/com/getpcpanel/profile/dto/OSCConnectionInfo.java @@ -1,4 +1,4 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; public record OSCConnectionInfo(String host, int port) { } diff --git a/src/main/java/com/getpcpanel/profile/dto/OverlayPosition.java b/src/main/java/com/getpcpanel/profile/dto/OverlayPosition.java new file mode 100644 index 00000000..8170d8b5 --- /dev/null +++ b/src/main/java/com/getpcpanel/profile/dto/OverlayPosition.java @@ -0,0 +1,9 @@ +package com.getpcpanel.profile.dto; + +/** + * Position options for the on-screen overlay. + * Moved from com.getpcpanel.ui to profile package as part of Quarkus migration. + */ +public enum OverlayPosition { + topLeft, topMiddle, topRight, middleLeft, middleMiddle, middleRight, bottomLeft, bottomMiddle, bottomRight +} diff --git a/src/main/java/com/getpcpanel/profile/SingleKnobLightingConfig.java b/src/main/java/com/getpcpanel/profile/dto/SingleKnobLightingConfig.java similarity index 50% rename from src/main/java/com/getpcpanel/profile/SingleKnobLightingConfig.java rename to src/main/java/com/getpcpanel/profile/dto/SingleKnobLightingConfig.java index 1234b62b..133605aa 100644 --- a/src/main/java/com/getpcpanel/profile/SingleKnobLightingConfig.java +++ b/src/main/java/com/getpcpanel/profile/dto/SingleKnobLightingConfig.java @@ -1,11 +1,7 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; import javax.annotation.Nullable; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.getpcpanel.util.Util; - -import javafx.scene.paint.Color; import lombok.Data; @Data @@ -24,25 +20,6 @@ public enum SINGLE_KNOB_MODE { NONE, STATIC, VOLUME_GRADIENT } - @JsonIgnore - public void setColor1FromColor(Color color1) { - this.color1 = Util.formatHexString(color1); - } - - @JsonIgnore - public void setColor2FromColor(Color color2) { - this.color2 = Util.formatHexString(color2); - } - - @JsonIgnore - public void setMuteOverrideColorFromColor(Color color) { - if (color == null) { - muteOverrideColor = null; - } else { - muteOverrideColor = Util.formatHexString(color); - } - } - public void set(SingleKnobLightingConfig c) { color1 = c.color1; color2 = c.color2; @@ -50,4 +27,3 @@ public void set(SingleKnobLightingConfig c) { mode = c.mode; } } - diff --git a/src/main/java/com/getpcpanel/profile/SingleLogoLightingConfig.java b/src/main/java/com/getpcpanel/profile/dto/SingleLogoLightingConfig.java similarity index 68% rename from src/main/java/com/getpcpanel/profile/SingleLogoLightingConfig.java rename to src/main/java/com/getpcpanel/profile/dto/SingleLogoLightingConfig.java index 105bc463..ea6935a3 100644 --- a/src/main/java/com/getpcpanel/profile/SingleLogoLightingConfig.java +++ b/src/main/java/com/getpcpanel/profile/dto/SingleLogoLightingConfig.java @@ -1,8 +1,5 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; -import com.getpcpanel.util.Util; - -import javafx.scene.paint.Color; import lombok.Data; @Data @@ -21,11 +18,6 @@ public enum SINGLE_LOGO_MODE { NONE, STATIC, RAINBOW, BREATH } - public SingleLogoLightingConfig setColor(Color color) { - this.color = Util.formatHexString(color); - return this; - } - /** * Used by Jackson */ @@ -34,4 +26,3 @@ public SingleLogoLightingConfig setColor(String color) { return this; } } - diff --git a/src/main/java/com/getpcpanel/profile/SingleSliderLabelLightingConfig.java b/src/main/java/com/getpcpanel/profile/dto/SingleSliderLabelLightingConfig.java similarity index 55% rename from src/main/java/com/getpcpanel/profile/SingleSliderLabelLightingConfig.java rename to src/main/java/com/getpcpanel/profile/dto/SingleSliderLabelLightingConfig.java index f3ad63cc..4f934176 100644 --- a/src/main/java/com/getpcpanel/profile/SingleSliderLabelLightingConfig.java +++ b/src/main/java/com/getpcpanel/profile/dto/SingleSliderLabelLightingConfig.java @@ -1,9 +1,5 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.getpcpanel.util.Util; - -import javafx.scene.paint.Color; import lombok.Data; @Data @@ -22,19 +18,8 @@ public enum SINGLE_SLIDER_LABEL_MODE { NONE, STATIC } - @JsonIgnore - public void setColorFromColor(Color color) { - this.color = Util.formatHexString(color); - } - - @JsonIgnore - public void setMuteOverrideColorFromColor(Color color) { - muteOverrideColor = Util.formatHexString(color); - } - public void set(SingleSliderLabelLightingConfig c) { color = c.color; mode = c.mode; } } - diff --git a/src/main/java/com/getpcpanel/profile/SingleSliderLightingConfig.java b/src/main/java/com/getpcpanel/profile/dto/SingleSliderLightingConfig.java similarity index 51% rename from src/main/java/com/getpcpanel/profile/SingleSliderLightingConfig.java rename to src/main/java/com/getpcpanel/profile/dto/SingleSliderLightingConfig.java index d3f76174..98047c7c 100644 --- a/src/main/java/com/getpcpanel/profile/SingleSliderLightingConfig.java +++ b/src/main/java/com/getpcpanel/profile/dto/SingleSliderLightingConfig.java @@ -1,9 +1,5 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.getpcpanel.util.Util; - -import javafx.scene.paint.Color; import lombok.Data; @Data @@ -22,25 +18,9 @@ public enum SINGLE_SLIDER_MODE { NONE, STATIC, STATIC_GRADIENT, VOLUME_GRADIENT } - @JsonIgnore - public void setColor1FromColor(Color color1) { - this.color1 = Util.formatHexString(color1); - } - - @JsonIgnore - public void setColor2FromColor(Color color2) { - this.color2 = Util.formatHexString(color2); - } - - @JsonIgnore - public void setMuteOverrideColorFromColor(Color color) { - muteOverrideColor = Util.formatHexString(color); - } - public void set(SingleSliderLightingConfig c) { color1 = c.color1; color2 = c.color2; mode = c.mode; } } - diff --git a/src/main/java/com/getpcpanel/profile/WaveLinkSettings.java b/src/main/java/com/getpcpanel/profile/dto/WaveLinkSettings.java similarity index 78% rename from src/main/java/com/getpcpanel/profile/WaveLinkSettings.java rename to src/main/java/com/getpcpanel/profile/dto/WaveLinkSettings.java index cae6a94e..cf5a3965 100644 --- a/src/main/java/com/getpcpanel/profile/WaveLinkSettings.java +++ b/src/main/java/com/getpcpanel/profile/dto/WaveLinkSettings.java @@ -1,4 +1,4 @@ -package com.getpcpanel.profile; +package com.getpcpanel.profile.dto; public record WaveLinkSettings(boolean enabled) { public static final WaveLinkSettings DEFAULT = new WaveLinkSettings(false); diff --git a/src/main/java/com/getpcpanel/rest/AudioResource.java b/src/main/java/com/getpcpanel/rest/AudioResource.java new file mode 100644 index 00000000..5317e3bb --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/AudioResource.java @@ -0,0 +1,56 @@ +package com.getpcpanel.rest; + +import java.util.Collection; +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import com.getpcpanel.cpp.AudioDevice; +import com.getpcpanel.cpp.AudioSession; +import com.getpcpanel.cpp.ISndCtrl; + +import one.util.streamex.StreamEx; + +@Path("/api/audio") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class AudioResource { + @Inject ISndCtrl sndCtrl; + + @GET + @Path("/devices") + public Collection listAudioDevices() { + return sndCtrl.devices(); + } + + @GET + @Path("/devices/output") + public List listOutputDevices() { + return StreamEx.of(sndCtrl.devices()).filter(AudioDevice::isOutput).toList(); + } + + @GET + @Path("/devices/input") + public List listInputDevices() { + return StreamEx.of(sndCtrl.devices()).filter(AudioDevice::isInput).toList(); + } + + @GET + @Path("/sessions") + public Collection listAudioSessions() { + return sndCtrl.getAllSessions(); + } + + @GET + @Path("/applications") + public List listRunningApplications() { + return sndCtrl.getRunningApplications(); + } +} diff --git a/src/main/java/com/getpcpanel/rest/CommandsResource.java b/src/main/java/com/getpcpanel/rest/CommandsResource.java new file mode 100644 index 00000000..e35e1cdc --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/CommandsResource.java @@ -0,0 +1,98 @@ +package com.getpcpanel.rest; + +import java.util.Collection; +import java.util.List; + +import com.getpcpanel.commands.command.CommandBrightness; +import com.getpcpanel.commands.command.CommandEndProgram; +import com.getpcpanel.commands.command.CommandKeystroke; +import com.getpcpanel.commands.command.CommandMedia; +import com.getpcpanel.commands.command.CommandObsMuteSource; +import com.getpcpanel.commands.command.CommandObsSetScene; +import com.getpcpanel.commands.command.CommandObsSetSourceVolume; +import com.getpcpanel.commands.command.CommandRun; +import com.getpcpanel.commands.command.CommandShortcut; +import com.getpcpanel.commands.command.CommandVoiceMeeterAdvanced; +import com.getpcpanel.commands.command.CommandVoiceMeeterAdvancedButton; +import com.getpcpanel.commands.command.CommandVoiceMeeterBasic; +import com.getpcpanel.commands.command.CommandVoiceMeeterBasicButton; +import com.getpcpanel.commands.command.CommandVolumeApplicationDeviceToggle; +import com.getpcpanel.commands.command.CommandVolumeDefaultDevice; +import com.getpcpanel.commands.command.CommandVolumeDefaultDeviceAdvanced; +import com.getpcpanel.commands.command.CommandVolumeDefaultDeviceToggle; +import com.getpcpanel.commands.command.CommandVolumeDefaultDeviceToggleAdvanced; +import com.getpcpanel.commands.command.CommandVolumeDevice; +import com.getpcpanel.commands.command.CommandVolumeDeviceMute; +import com.getpcpanel.commands.command.CommandVolumeFocus; +import com.getpcpanel.commands.command.CommandVolumeFocusMute; +import com.getpcpanel.commands.command.CommandVolumeProcess; +import com.getpcpanel.commands.command.CommandVolumeProcessMute; +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.rest.EventBroadcaster.AssignmentChangedEvent.Kinds; +import com.getpcpanel.rest.model.dto.CommandType; +import com.getpcpanel.rest.model.dto.CommandType.CommandCategory; +import com.getpcpanel.wavelink.command.CommandWaveLinkAddFocusToChannel; +import com.getpcpanel.wavelink.command.CommandWaveLinkChangeLevel; +import com.getpcpanel.wavelink.command.CommandWaveLinkChangeMute; +import com.getpcpanel.wavelink.command.CommandWaveLinkChannelEffect; +import com.getpcpanel.wavelink.command.CommandWaveLinkMainOutput; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import one.util.streamex.StreamEx; + +@Path("/api/commands") +@ApplicationScoped +public class CommandsResource { + @Inject SaveService saveService; + + private static final List commandTypes = List.of( + new CommandType("Brightness", CommandBrightness.class.getName(), CommandCategory.standard, Kinds.dial), + new CommandType("Process volume", CommandVolumeProcess.class.getName(), CommandCategory.standard, Kinds.dial), + new CommandType("Focus volume", CommandVolumeFocus.class.getName(), CommandCategory.standard, Kinds.dial), + new CommandType("Device volume", CommandVolumeDevice.class.getName(), CommandCategory.standard, Kinds.dial), + new CommandType("Obs Source Volume", CommandObsSetSourceVolume.class.getName(), CommandCategory.obs, Kinds.dial), + new CommandType("VoiceMeeter Advanced", CommandVoiceMeeterAdvanced.class.getName(), CommandCategory.voicemeeter, Kinds.dial), + new CommandType("VoiceMeeter Basic", CommandVoiceMeeterBasic.class.getName(), CommandCategory.voicemeeter, Kinds.dial), + new CommandType("WaveLink Change Level", CommandWaveLinkChangeLevel.class.getName(), CommandCategory.wavelink, Kinds.dial), + + new CommandType("End Program", CommandEndProgram.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Keystroke", CommandKeystroke.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Run", CommandRun.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Shortcut", CommandShortcut.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Media", CommandMedia.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Toggle application device", CommandVolumeApplicationDeviceToggle.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Default Device", CommandVolumeDefaultDevice.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Default Device Advanced", CommandVolumeDefaultDeviceAdvanced.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Default Device Toggle", CommandVolumeDefaultDeviceToggle.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Default Device Toggle Advanced", CommandVolumeDefaultDeviceToggleAdvanced.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Device Mute", CommandVolumeDeviceMute.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Focus Mute", CommandVolumeFocusMute.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Process Mute", CommandVolumeProcessMute.class.getName(), CommandCategory.standard, Kinds.button), + new CommandType("Obs Mute Source", CommandObsMuteSource.class.getName(), CommandCategory.obs, Kinds.button), + new CommandType("Obs Set Scene", CommandObsSetScene.class.getName(), CommandCategory.obs, Kinds.button), + new CommandType("VoiceMeeter Advanced", CommandVoiceMeeterAdvancedButton.class.getName(), CommandCategory.voicemeeter, Kinds.button), + new CommandType("VoiceMeeter Basic", CommandVoiceMeeterBasicButton.class.getName(), CommandCategory.voicemeeter, Kinds.button), + new CommandType("WaveLink Add Focus To Channel", CommandWaveLinkAddFocusToChannel.class.getName(), CommandCategory.wavelink, Kinds.button), + new CommandType("WaveLink Change Mute", CommandWaveLinkChangeMute.class.getName(), CommandCategory.wavelink, Kinds.button), + new CommandType("WaveLink Channel Effect", CommandWaveLinkChannelEffect.class.getName(), CommandCategory.wavelink, Kinds.button), + new CommandType("WaveLink Main Output", CommandWaveLinkMainOutput.class.getName(), CommandCategory.wavelink, Kinds.button) + ); + + @GET + @Path("/available") + public Collection listAvailableCommands() { + return StreamEx.of(commandTypes).filter(this::enabled).toList(); + } + + private boolean enabled(CommandType commandType) { + return switch (commandType.category()) { + case standard -> true; + case obs -> saveService.get().isObsEnabled(); + case voicemeeter -> saveService.get().isVoicemeeterEnabled(); + case wavelink -> saveService.get().getWaveLink().enabled(); + }; + } +} diff --git a/src/main/java/com/getpcpanel/rest/DeviceResource.java b/src/main/java/com/getpcpanel/rest/DeviceResource.java new file mode 100644 index 00000000..873e0fb2 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/DeviceResource.java @@ -0,0 +1,283 @@ +package com.getpcpanel.rest; + +import java.util.List; +import java.util.Optional; + +import com.getpcpanel.commands.Commands; +import com.getpcpanel.device.Device; +import com.getpcpanel.hid.DeviceHolder; +import com.getpcpanel.profile.DeviceSave; +import com.getpcpanel.profile.Profile; +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.profile.dto.KnobSetting; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.rest.EventBroadcaster.AssignmentChangedEvent; +import com.getpcpanel.rest.EventBroadcaster.AssignmentChangedEvent.Kinds; +import com.getpcpanel.rest.EventBroadcaster.DeviceRenamedEvent; +import com.getpcpanel.rest.EventBroadcaster.LightingChangedEvent; +import com.getpcpanel.rest.EventBroadcaster.ProfileSwitchedEvent; +import com.getpcpanel.rest.EventBroadcaster.KnobSettingChangedEvent; +import com.getpcpanel.rest.model.dto.ControlAssignmentsUpdateDto; +import com.getpcpanel.rest.model.dto.DeviceDto; +import com.getpcpanel.rest.model.dto.ProfileDto; +import com.getpcpanel.rest.model.dto.ProfileSnapshotDto; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import one.util.streamex.StreamEx; + +@Path("/api/devices") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class DeviceResource { + @Inject DeviceHolder deviceHolder; + @Inject SaveService saveService; + @Inject Event eventBus; + + @GET + public List listDevices() { + var save = saveService.get(); + return StreamEx.of(deviceHolder.all()) + .map(d -> DeviceDto.from(d, save.getDeviceSave(d.getSerialNumber()))) + .toList(); + } + + @GET + @Path("/{serial}") + public DeviceDto getDevice(@PathParam("serial") String serial) { + var device = deviceHolder.getDevice(serial).orElseThrow(NotFoundException::new); + return DeviceDto.from(device, saveService.get().getDeviceSave(serial)); + } + + @PUT + @Path("/{serial}/name") + public Response renameDevice(@PathParam("serial") String serial, String name) { + var device = deviceHolder.getDevice(serial).orElseThrow(NotFoundException::new); + device.setDisplayName(name); + saveService.save(); + eventBus.fire(new DeviceRenamedEvent(serial, name)); + return Response.ok().build(); + } + + // ── Profiles ────────────────────────────────────────────────────────────── + + @GET + @Path("/{serial}/profiles") + public List listProfiles(@PathParam("serial") String serial) { + var deviceSave = getDeviceSave(serial); + return StreamEx.of(deviceSave.getProfiles()).map(ProfileDto::from).toList(); + } + + @POST + @Path("/{serial}/profiles") + public Response createProfile(@PathParam("serial") String serial, String name) { + var deviceSave = getDeviceSave(serial); + var device = deviceHolder.getDevice(serial).orElseThrow(NotFoundException::new); + var profile = new Profile(name, device.deviceType()); + deviceSave.getProfiles().add(profile); + saveService.save(); + return Response.ok(ProfileDto.from(profile)).build(); + } + + @DELETE + @Path("/{serial}/profiles/{name}") + public Response deleteProfile(@PathParam("serial") String serial, @PathParam("name") String name) { + var deviceSave = getDeviceSave(serial); + deviceSave.getProfiles().removeIf(p -> p.getName().equals(name)); + saveService.save(); + return Response.noContent().build(); + } + + @PUT + @Path("/{serial}/profiles/current") + public Response switchProfile(@PathParam("serial") String serial, String name) { + var deviceSave = getDeviceSave(serial); + var profile = deviceSave.setCurrentProfile(name).orElseThrow(() -> new NotFoundException("Profile not found: " + name)); + saveService.save(); + eventBus.fire(new ProfileSwitchedEvent(serial, name, ProfileSnapshotDto.from(profile))); + return Response.ok().build(); + } + + // ── Button/Dial assignments ──────────────────────────────────────────────── + + @GET + @Path("/{serial}/profiles/{profile}/buttons/{index}") + public Commands getButton(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index) { + return getProfile(serial, profileName).getButtonData(index); + } + + @PUT + @Path("/{serial}/profiles/{profile}/buttons/{index}") + public Response setButton(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index, + Commands commands) { + getProfile(serial, profileName).setButtonData(index, commands); + saveService.save(); + eventBus.fire(new AssignmentChangedEvent(serial, Kinds.button, index, commands)); + return Response.ok().build(); + } + + @GET + @Path("/{serial}/profiles/{profile}/dblbuttons/{index}") + public Commands getDblButton(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index) { + return Optional.ofNullable(getProfile(serial, profileName).getDblButtonData(index)) + .orElse(Commands.EMPTY); + } + + @PUT + @Path("/{serial}/profiles/{profile}/dblbuttons/{index}") + public Response setDblButton(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index, + Commands commands) { + getProfile(serial, profileName).setDblButtonData(index, commands); + saveService.save(); + eventBus.fire(new AssignmentChangedEvent(serial, Kinds.dblbutton, index, commands)); + return Response.ok().build(); + } + + @GET + @Path("/{serial}/profiles/{profile}/dials/{index}") + public Commands getDial(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index) { + return Optional.ofNullable(getProfile(serial, profileName).getDialData(index)) + .orElse(Commands.EMPTY); + } + + @PUT + @Path("/{serial}/profiles/{profile}/dials/{index}") + public Response setDial(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index, + Commands commands) { + getProfile(serial, profileName).setDialData(index, commands); + saveService.save(); + eventBus.fire(new AssignmentChangedEvent(serial, Kinds.dial, index, commands)); + return Response.ok().build(); + } + + @GET + @Path("/{serial}/profiles/{profile}/knobsettings/{index}") + public KnobSetting getKnobSettings(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index) { + return getProfile(serial, profileName).getKnobSettings(index); + } + + @PUT + @Path("/{serial}/profiles/{profile}/knobsettings/{index}") + public Response setKnobSettings(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index, + KnobSetting settings) { + var knob = getProfile(serial, profileName).getKnobSettings(index); + knob.setMinTrim(settings.getMinTrim()); + knob.setMaxTrim(settings.getMaxTrim()); + knob.setLogarithmic(settings.isLogarithmic()); + knob.setOverlayIcon(settings.getOverlayIcon()); + knob.setButtonDebounce(settings.getButtonDebounce()); + saveService.save(); + eventBus.fire(new KnobSettingChangedEvent(serial, index, knob)); + return Response.ok().build(); + } + + // ── Lighting ────────────────────────────────────────────────────────────── + + @GET + @Path("/{serial}/lighting") + public LightingConfig getLighting(@PathParam("serial") String serial) { + return deviceHolder.getDevice(serial) + .map(Device::getSavedLightingConfig) + .orElseThrow(NotFoundException::new); + } + + @PUT + @Path("/{serial}/lighting") + public Response setLighting(@PathParam("serial") String serial, LightingConfig config) { + var device = deviceHolder.getDevice(serial).orElseThrow(NotFoundException::new); + device.setSavedLighting(config); + saveService.save(); + eventBus.fire(new LightingChangedEvent(serial, config)); + return Response.ok().build(); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private DeviceSave getDeviceSave(String serial) { + var save = saveService.get(); + var deviceSave = save.getDevices().get(serial); + if (deviceSave == null) { + throw new NotFoundException("Device not found: " + serial); + } + return deviceSave; + } + + private Profile getProfile(String serial, String profileName) { + return getDeviceSave(serial).getProfile(profileName) + .orElseThrow(() -> new NotFoundException("Profile not found: " + profileName)); + } + + @PUT + @Path("/{serial}/profiles/{profile}/controls/{index}") + public Response setControlAssignments(@PathParam("serial") String serial, + @PathParam("profile") String profileName, + @PathParam("index") int index, + ControlAssignmentsUpdateDto update) { + var profile = getProfile(serial, profileName); + var changed = false; + + if (update.analog() != null) { + profile.setDialData(index, update.analog()); + eventBus.fire(new AssignmentChangedEvent(serial, Kinds.dial, index, update.analog())); + changed = true; + } + + if (update.button() != null) { + profile.setButtonData(index, update.button()); + eventBus.fire(new AssignmentChangedEvent(serial, Kinds.button, index, update.button())); + changed = true; + } + + if (update.dblButton() != null) { + profile.setDblButtonData(index, update.dblButton()); + eventBus.fire(new AssignmentChangedEvent(serial, Kinds.dblbutton, index, update.dblButton())); + changed = true; + } + + if (update.knobSetting() != null) { + var knob = profile.getKnobSettings(index); + knob.setMinTrim(update.knobSetting().getMinTrim()); + knob.setMaxTrim(update.knobSetting().getMaxTrim()); + knob.setLogarithmic(update.knobSetting().isLogarithmic()); + knob.setOverlayIcon(update.knobSetting().getOverlayIcon()); + knob.setButtonDebounce(update.knobSetting().getButtonDebounce()); + eventBus.fire(new KnobSettingChangedEvent(serial, index, knob)); + changed = true; + } + + if (changed) { + saveService.save(); + } + + return Response.ok().build(); + } +} diff --git a/src/main/java/com/getpcpanel/rest/EventBroadcaster.java b/src/main/java/com/getpcpanel/rest/EventBroadcaster.java new file mode 100644 index 00000000..71ebb408 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/EventBroadcaster.java @@ -0,0 +1,134 @@ +package com.getpcpanel.rest; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.getpcpanel.commands.Commands; +import com.getpcpanel.hid.DeviceCommunicationHandler.ButtonPressEvent; +import com.getpcpanel.hid.DeviceCommunicationHandler.KnobRotateEvent; +import com.getpcpanel.hid.DeviceHolder; +import com.getpcpanel.hid.DeviceHolder.DeviceFullyConnectedEvent; +import com.getpcpanel.hid.DeviceScanner.DeviceDisconnectedEvent; +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.profile.dto.KnobSetting; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.rest.ProVisualColorsService.ProVisualColors; +import com.getpcpanel.rest.model.dto.DeviceSnapshotDto; +import com.getpcpanel.rest.model.dto.ProfileSnapshotDto; +import com.getpcpanel.rest.model.ws.WsAssignmentChangedEvent; +import com.getpcpanel.rest.model.ws.WsButtonEvent; +import com.getpcpanel.rest.model.ws.WsControlSettingChangedEvent; +import com.getpcpanel.rest.model.ws.WsDeviceConnectedEvent; +import com.getpcpanel.rest.model.ws.WsDeviceDisconnectedEvent; +import com.getpcpanel.rest.model.ws.WsDeviceRenamedEvent; +import com.getpcpanel.rest.model.ws.WsKnobEvent; +import com.getpcpanel.rest.model.ws.WsLightingChangedEvent; +import com.getpcpanel.rest.model.ws.WsProfileSwitchedEvent; +import com.getpcpanel.rest.model.ws.WsVisualColorsChangedEvent; +import com.getpcpanel.util.AppShutdownState; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +@ApplicationScoped +public class EventBroadcaster { + @Inject ObjectMapper objectMapper; + @Inject SaveService saveService; + @Inject DeviceHolder deviceHolder; + @Inject ProVisualColorsService proVisualColorsService; + + private boolean shouldSkipBroadcast() { + return AppShutdownState.isShuttingDown(); + } + + private void broadcast(Object event) { + if (shouldSkipBroadcast()) + return; + EventWebSocket.broadcast(event, objectMapper); + } + + // ── Existing operational events ──────────────────────────────────────────── + + public void onDeviceConnected(@Observes DeviceFullyConnectedEvent event) { + var serial = event.device().getSerialNumber(); + var save = saveService.get().getDeviceSave(serial); + if (save == null) { + log.debug("Skipping device_connected broadcast for {} because no device save exists", serial); + return; + } + + var snapshot = DeviceSnapshotDto.from(event.device(), save, proVisualColorsService); + broadcast(new WsDeviceConnectedEvent(snapshot)); + } + + public void onDeviceDisconnected(@Observes DeviceDisconnectedEvent event) { + broadcast(new WsDeviceDisconnectedEvent(event.serialNum())); + } + + public void onKnobRotate(@Observes KnobRotateEvent event) { + broadcast(new WsKnobEvent(event.serialNum(), event.knob(), event.value())); + } + + public void onButtonPress(@Observes ButtonPressEvent event) { + broadcast(new WsButtonEvent(event.serialNum(), event.button(), event.pressed())); + } + + // ── Mutation patch events ────────────────────────────────────────────────── + + public void onDeviceRenamed(@Observes DeviceRenamedEvent event) { + broadcast(new WsDeviceRenamedEvent(event.serial(), event.displayName())); + } + + public void onProfileSwitched(@Observes ProfileSwitchedEvent event) { + var colors = colorsFor(event.serial()); + broadcast(new WsProfileSwitchedEvent(event.serial(), event.profileName(), event.profileSnapshot(), colors.dialColors(), colors.sliderLabelColors(), colors.sliderColors(), colors.logoColor())); + } + + public void onLightingChanged(@Observes LightingChangedEvent event) { + var colors = colorsFor(event.serial()); + broadcast(new WsLightingChangedEvent(event.serial(), event.lightingConfig(), colors.dialColors(), colors.sliderLabelColors(), colors.sliderColors(), colors.logoColor())); + } + + public void onVisualColorsChanged(@Observes VisualColorsChangedEvent event) { + var colors = colorsFor(event.serial()); + broadcast(new WsVisualColorsChangedEvent(event.serial(), colors.dialColors(), colors.sliderLabelColors(), colors.sliderColors(), colors.logoColor())); + } + + public void onAssignmentChanged(@Observes AssignmentChangedEvent event) { + broadcast(new WsAssignmentChangedEvent(event.serial(), event.kind(), event.index(), event.commands())); + } + + public void onSettingChanged(@Observes KnobSettingChangedEvent event) { + broadcast(new WsControlSettingChangedEvent(event.serial(), event.index(), event.settings())); + } + + // ── CDI mutation events (fired by DeviceResource) ───────────────────────── + + public record DeviceRenamedEvent(String serial, String displayName) { + } + + public record ProfileSwitchedEvent(String serial, String profileName, ProfileSnapshotDto profileSnapshot) { + } + + public record LightingChangedEvent(String serial, LightingConfig lightingConfig) { + } + + public record VisualColorsChangedEvent(String serial) { + } + + public record KnobSettingChangedEvent(String serial, int index, KnobSetting settings) { + } + + public record AssignmentChangedEvent(String serial, Kinds kind, int index, Commands commands) { + public enum Kinds { + dial, button, dblbutton + } + } + + private ProVisualColors colorsFor(String serial) { + return deviceHolder.getDevice(serial) + .map(proVisualColorsService::resolve) + .orElse(ProVisualColors.empty()); + } +} diff --git a/src/main/java/com/getpcpanel/rest/EventWebSocket.java b/src/main/java/com/getpcpanel/rest/EventWebSocket.java new file mode 100644 index 00000000..8fdb5c94 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/EventWebSocket.java @@ -0,0 +1,86 @@ +package com.getpcpanel.rest; + +import java.util.concurrent.CopyOnWriteArraySet; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.getpcpanel.hid.DeviceHolder; +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.rest.model.dto.DeviceSnapshotDto; +import com.getpcpanel.rest.model.ws.WsDeviceConnectedEvent; +import com.getpcpanel.util.AppShutdownState; + +import io.quarkus.websockets.next.OnClose; +import io.quarkus.websockets.next.OnOpen; +import io.quarkus.websockets.next.WebSocket; +import io.quarkus.websockets.next.WebSocketConnection; +import jakarta.inject.Inject; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +@WebSocket(path = "/ws/events") +public class EventWebSocket { + private static final CopyOnWriteArraySet connections = new CopyOnWriteArraySet<>(); + + @Inject ObjectMapper objectMapper; + @Inject DeviceHolder deviceHolder; + @Inject SaveService saveService; + @Inject ProVisualColorsService proVisualColorsService; + + @OnOpen + public void onOpen(WebSocketConnection connection) { + if (AppShutdownState.isShuttingDown()) { + log.debug("Ignoring websocket connection {} because shutdown is in progress", connection.id()); + return; + } + connections.add(connection); + log.debug("WebSocket client connected: {} (total connections: {})", connection.id(), connections.size()); + sendInitialSnapshots(connection); + } + + @OnClose + public void onClose(WebSocketConnection connection) { + connections.remove(connection); + log.debug("WebSocket client disconnected: {} (remaining connections: {})", connection.id(), connections.size()); + } + + private void sendInitialSnapshots(WebSocketConnection connection) { + var save = saveService.get(); + deviceHolder.all().forEach(device -> { + try { + var deviceSave = save.getDeviceSave(device.getSerialNumber()); + if (deviceSave == null) { + log.debug("Skipping initial device_connected for {} because no device save exists", device.getSerialNumber()); + return; + } + + var snapshot = DeviceSnapshotDto.from(device, deviceSave, proVisualColorsService); + var connectedEvent = new WsDeviceConnectedEvent(snapshot); + var json = objectMapper.writeValueAsString(connectedEvent); + connection.sendTextAndAwait(json); + } catch (Exception e) { + log.warn("Failed to send initial device_connected for {} to new WS connection {}", device.getSerialNumber(), connection.id(), e); + } + }); + } + + public static void broadcast(Object event, ObjectMapper mapper) { + if (AppShutdownState.isShuttingDown()) { + connections.clear(); + return; + } + try { + var json = mapper.writeValueAsString(event); + log.debug("Broadcasting event to {} WebSocket clients: {}", connections.size(), json); + connections.forEach(c -> { + try { + c.sendTextAndAwait(json); + } catch (Exception e) { + log.debug("Failed to send event to WS client {}", c.id(), e); + } + }); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize event", e); + } + } +} diff --git a/src/main/java/com/getpcpanel/rest/IconResource.java b/src/main/java/com/getpcpanel/rest/IconResource.java new file mode 100644 index 00000000..b3276f01 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/IconResource.java @@ -0,0 +1,67 @@ +package com.getpcpanel.rest; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; + +import javax.imageio.ImageIO; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Response; + +import com.getpcpanel.iconextract.IIconService; + +import lombok.extern.log4j.Log4j2; + +@Log4j2 +@Path("/api/icons") +@ApplicationScoped +public class IconResource { + @Inject IIconService iconService; + + @GET + @Produces("image/png") + public Response getIcon(@QueryParam("path") String filePath, + @QueryParam("size") @DefaultValue("32") int size) { + if (filePath == null || filePath.isBlank()) { + throw new NotFoundException(); + } + // Resolve canonical path to prevent path traversal sequences (e.g. "../") + File file; + try { + file = new File(filePath).getCanonicalFile(); + } catch (IOException e) { + throw new NotFoundException(); + } + // Restrict to files with known safe extensions to prevent arbitrary file access + var name = file.getName().toLowerCase(); + var allowed = name.endsWith(".exe") || name.endsWith(".lnk") || name.endsWith(".ico") + || name.endsWith(".png") || name.endsWith(".jpg") || name.endsWith(".jpeg") + || name.endsWith(".bmp") || name.endsWith(".gif"); + if (!allowed) { + throw new NotFoundException(); + } + if (!file.isFile()) { + throw new NotFoundException(); + } + var img = iconService.getIconForFile(size, size, file); + if (img == null) { + throw new NotFoundException(); + } + try { + var baos = new ByteArrayOutputStream(); + ImageIO.write(img, "png", baos); + return Response.ok(baos.toByteArray()).type("image/png").build(); + } catch (Exception e) { + log.error("Failed to encode icon for {}", filePath, e); + return Response.serverError().build(); + } + } +} diff --git a/src/main/java/com/getpcpanel/rest/ObsResource.java b/src/main/java/com/getpcpanel/rest/ObsResource.java new file mode 100644 index 00000000..17fd7221 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/ObsResource.java @@ -0,0 +1,37 @@ +package com.getpcpanel.rest; + +import java.util.List; + +import com.getpcpanel.obs.OBS; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("/api/obs") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +public class ObsResource { + @Inject OBS obs; + + @GET + @Path("/scenes") + public List listScenes() { + if (!obs.isConnected()) { + return List.of(); + } + return obs.getScenes(); + } + + @GET + @Path("/sources") + public List listSources() { + if (!obs.isConnected()) { + return List.of(); + } + return obs.getSourcesWithAudio(); + } +} diff --git a/src/main/java/com/getpcpanel/rest/OverlayResource.java b/src/main/java/com/getpcpanel/rest/OverlayResource.java new file mode 100644 index 00000000..aa2948b4 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/OverlayResource.java @@ -0,0 +1,37 @@ +package com.getpcpanel.rest; + +import com.getpcpanel.overlay.Overlay; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +@Path("/api/overlay") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +public class OverlayResource { + @Inject Overlay overlay; + + @GET + public Response testOverlay() { + System.out.println("Overlay!"); + overlay.show(0); + return Response.ok().build(); + } + + @POST + public Response showOverlay(OverlayDto params) { + overlay.show(params.value()); + return Response.ok().build(); + } + + @RegisterForReflection + public record OverlayDto(int value, String icon) { + } +} diff --git a/src/main/java/com/getpcpanel/rest/ProVisualColorsService.java b/src/main/java/com/getpcpanel/rest/ProVisualColorsService.java new file mode 100644 index 00000000..e4cea3a0 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/ProVisualColorsService.java @@ -0,0 +1,302 @@ +package com.getpcpanel.rest; + +import java.awt.Color; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import com.getpcpanel.device.Device; +import com.getpcpanel.device.DeviceType; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.profile.dto.SingleKnobLightingConfig; +import com.getpcpanel.profile.dto.SingleLogoLightingConfig; +import com.getpcpanel.profile.dto.SingleSliderLabelLightingConfig; +import com.getpcpanel.profile.dto.SingleSliderLightingConfig; +import com.getpcpanel.util.Util; +import com.getpcpanel.util.coloroverride.OverrideColorService; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +@ApplicationScoped +public class ProVisualColorsService { + private static final String BLACK = "#000000"; + private static final int PRO_DIAL_COUNT = 5; + private static final int PRO_SLIDER_COUNT = 4; + private static final int PRO_SLIDER_SEGMENT_COUNT = 5; + + @Inject + OverrideColorService overrideColorService; + + public ProVisualColors resolve(Device device) { + if (device == null || device.deviceType() != DeviceType.PCPANEL_PRO) { + return ProVisualColors.empty(); + } + + var config = device.lightingConfig(); + if (config == null || config.lightingMode() == null) { + return ProVisualColors.defaultForPro(); + } + + return switch (config.lightingMode()) { + case ALL_COLOR -> monochrome(colorOrDefault(config.allColor())); + case ALL_WAVE -> fromWave(config); + case ALL_BREATH -> monochrome(colorFromHue(config.breathHue(), config.breathBrightness())); + case ALL_RAINBOW -> fromRainbow(config); + case CUSTOM -> fromCustom(device.getSerialNumber(), config); + default -> ProVisualColors.defaultForPro(); + }; + } + + private ProVisualColors fromRainbow(LightingConfig config) { + var baseHue = unitByte(config.rainbowPhaseShift()); + var reverse = config.rainbowReverse() == 1; + var vertical = config.rainbowVertical() == 1; + var brightness = unitByte(config.rainbowBrightness()); + + var dialColors = new ArrayList(PRO_DIAL_COUNT); + for (var i = 0; i < PRO_DIAL_COUNT; i++) { + dialColors.add(rainbowColor(baseHue, reverse, i, PRO_DIAL_COUNT, brightness)); + } + + var sliderLabelColors = new ArrayList(PRO_SLIDER_COUNT); + for (var i = 0; i < PRO_SLIDER_COUNT; i++) { + sliderLabelColors.add(rainbowColor(baseHue, reverse, i + PRO_DIAL_COUNT, PRO_DIAL_COUNT + PRO_SLIDER_COUNT, brightness)); + } + + var sliderColors = new ArrayList>(PRO_SLIDER_COUNT); + for (var s = 0; s < PRO_SLIDER_COUNT; s++) { + var segmentColors = new ArrayList(PRO_SLIDER_SEGMENT_COUNT); + for (var seg = 0; seg < PRO_SLIDER_SEGMENT_COUNT; seg++) { + var idx = vertical ? seg : (s * PRO_SLIDER_SEGMENT_COUNT + seg); + var total = vertical ? PRO_SLIDER_SEGMENT_COUNT : (PRO_SLIDER_COUNT * PRO_SLIDER_SEGMENT_COUNT); + segmentColors.add(rainbowColor(baseHue, reverse, idx, total, brightness)); + } + sliderColors.add(List.copyOf(segmentColors)); + } + + var logoColor = rainbowColor(baseHue, reverse, PRO_DIAL_COUNT + PRO_SLIDER_COUNT, PRO_DIAL_COUNT + PRO_SLIDER_COUNT + 1, brightness); + return new ProVisualColors(List.copyOf(dialColors), List.copyOf(sliderLabelColors), List.copyOf(sliderColors), logoColor); + } + + private ProVisualColors fromWave(LightingConfig config) { + var centerHue = unitByte(config.waveHue()); + var brightness = unitByte(config.waveBrightness()); + var reverse = config.waveReverse() == 1; + var bounce = config.waveBounce() == 1; + + var dialColors = new ArrayList(PRO_DIAL_COUNT); + for (var i = 0; i < PRO_DIAL_COUNT; i++) { + dialColors.add(waveColor(centerHue, brightness, i, PRO_DIAL_COUNT, reverse, bounce)); + } + + var sliderLabelColors = new ArrayList(PRO_SLIDER_COUNT); + for (var i = 0; i < PRO_SLIDER_COUNT; i++) { + sliderLabelColors.add(waveColor(centerHue, brightness, i, PRO_SLIDER_COUNT, reverse, bounce)); + } + + var sliderColors = new ArrayList>(PRO_SLIDER_COUNT); + for (var s = 0; s < PRO_SLIDER_COUNT; s++) { + var segmentColors = new ArrayList(PRO_SLIDER_SEGMENT_COUNT); + for (var seg = 0; seg < PRO_SLIDER_SEGMENT_COUNT; seg++) { + segmentColors.add(waveColor(centerHue, brightness, seg, PRO_SLIDER_SEGMENT_COUNT, reverse, bounce)); + } + sliderColors.add(List.copyOf(segmentColors)); + } + + var logoColor = colorFromHue(config.waveHue(), config.waveBrightness()); + return new ProVisualColors(List.copyOf(dialColors), List.copyOf(sliderLabelColors), List.copyOf(sliderColors), logoColor); + } + + private ProVisualColors monochrome(String color) { + var c = colorOrDefault(color); + var dials = nCopies(PRO_DIAL_COUNT, c); + var labels = nCopies(PRO_SLIDER_COUNT, c); + var sliders = new ArrayList>(PRO_SLIDER_COUNT); + for (var i = 0; i < PRO_SLIDER_COUNT; i++) { + sliders.add(nCopies(PRO_SLIDER_SEGMENT_COUNT, c)); + } + return new ProVisualColors(dials, labels, List.copyOf(sliders), c); + } + + private ProVisualColors fromCustom(String serial, LightingConfig config) { + var dialColors = new ArrayList(PRO_DIAL_COUNT); + var knobConfigs = config.knobConfigs(); + for (var i = 0; i < PRO_DIAL_COUNT; i++) { + var knob = i < knobConfigs.length ? knobConfigs[i] : new SingleKnobLightingConfig(); + knob = overrideColorService.getDialOverride(serial, i).orElse(knob); + dialColors.add(resolveDialColor(knob)); + } + + var sliderLabelColors = new ArrayList(PRO_SLIDER_COUNT); + var labelConfigs = config.sliderLabelConfigs(); + for (var i = 0; i < PRO_SLIDER_COUNT; i++) { + var label = i < labelConfigs.length ? labelConfigs[i] : new SingleSliderLabelLightingConfig(); + label = overrideColorService.getSliderLabelOverride(serial, i).orElse(label); + sliderLabelColors.add(resolveSliderLabelColor(label)); + } + + var sliderColors = new ArrayList>(PRO_SLIDER_COUNT); + var sliderConfigs = config.sliderConfigs(); + for (var i = 0; i < PRO_SLIDER_COUNT; i++) { + var slider = i < sliderConfigs.length ? sliderConfigs[i] : new SingleSliderLightingConfig(); + slider = overrideColorService.getSliderOverride(serial, i).orElse(slider); + sliderColors.add(resolveSliderColors(slider)); + } + + var logo = overrideColorService.getLogoOverride(serial).orElse(config.logoConfig()); + var logoColor = resolveLogoColor(logo); + + return new ProVisualColors(List.copyOf(dialColors), List.copyOf(sliderLabelColors), List.copyOf(sliderColors), logoColor); + } + + private String resolveDialColor(SingleKnobLightingConfig config) { + if (config == null || config.getMode() == null) { + return BLACK; + } + return switch (config.getMode()) { + case NONE -> BLACK; + case STATIC, VOLUME_GRADIENT -> firstColor(config.getColor1(), config.getColor2()); + }; + } + + private String resolveSliderLabelColor(SingleSliderLabelLightingConfig config) { + if (config == null || config.getMode() == null) { + return BLACK; + } + return switch (config.getMode()) { + case NONE -> BLACK; + case STATIC -> colorOrDefault(config.getColor()); + }; + } + + private List resolveSliderColors(SingleSliderLightingConfig config) { + if (config == null || config.getMode() == null) { + return nCopies(PRO_SLIDER_SEGMENT_COUNT, BLACK); + } + + return switch (config.getMode()) { + case NONE -> nCopies(PRO_SLIDER_SEGMENT_COUNT, BLACK); + case STATIC -> nCopies(PRO_SLIDER_SEGMENT_COUNT, colorOrDefault(config.getColor1())); + case STATIC_GRADIENT, VOLUME_GRADIENT -> gradient(config.getColor1(), config.getColor2(), PRO_SLIDER_SEGMENT_COUNT); + }; + } + + String resolveLogoColor(SingleLogoLightingConfig config) { + if (config == null || config.getMode() == null) { + return BLACK; + } + + return switch (config.getMode()) { + case NONE -> BLACK; + case STATIC -> colorOrDefault(config.getColor()); + case RAINBOW -> "$RAINBOW!"; + case BREATH -> "$BREATH"; + }; + } + + private List gradient(String startColor, String endColor, int steps) { + var start = Util.parseColorComponents(colorOrDefault(startColor)); + var end = Util.parseColorComponents(colorOrDefault(endColor)); + + if (start == null || end == null) { + return nCopies(steps, BLACK); + } + + var result = new ArrayList(steps); + for (var i = 0; i < steps; i++) { + var ratio = steps == 1 ? 0f : (float) i / (steps - 1); + var r = Math.round(start[0] + (end[0] - start[0]) * ratio); + var g = Math.round(start[1] + (end[1] - start[1]) * ratio); + var b = Math.round(start[2] + (end[2] - start[2]) * ratio); + result.add(toHex(r, g, b)); + } + return List.copyOf(result); + } + + private String colorFromHue(byte hue, byte brightness) { + return colorFromHsb(unitByte(hue), 1f, unitByte(brightness)); + } + + private String rainbowColor(float baseHue, boolean reverse, int index, int total, float brightness) { + var span = 0.7f; + var shift = total <= 1 ? 0f : (span * index / (total - 1)); + var hue = reverse ? baseHue - shift : baseHue + shift; + return colorFromHsb(normalizeHue(hue), 1f, brightness); + } + + private String waveColor(float centerHue, float brightness, int index, int total, boolean reverse, boolean bounce) { + var progress = total <= 1 ? 0f : (float) index / (total - 1); + if (reverse) { + progress = 1f - progress; + } + var spread = 0.12f; + var offset = bounce + ? (Math.abs(progress - 0.5f) * 2f * spread) + : ((progress - 0.5f) * 2f * spread); + return colorFromHsb(normalizeHue(centerHue + offset), 1f, brightness); + } + + private String colorFromHsb(float hue, float saturation, float brightness) { + var rgb = Color.HSBtoRGB(hue, saturation, brightness); + var r = (rgb >> 16) & 0xFF; + var g = (rgb >> 8) & 0xFF; + var b = rgb & 0xFF; + return toHex(r, g, b); + } + + private String toHex(int r, int g, int b) { + var hex = Util.formatHexString(r, g, b); + return hex == null ? BLACK : hex; + } + + private float unitByte(byte value) { + return (value & 0xFF) / 255f; + } + + private float normalizeHue(float hue) { + var normalized = hue % 1f; + return normalized < 0 ? normalized + 1f : normalized; + } + + private String firstColor(String color1, String color2) { + var c1 = colorOrDefault(color1); + if (!BLACK.equals(c1)) { + return c1; + } + return colorOrDefault(color2); + } + + private String colorOrDefault(String color) { + var parsed = Util.parseColorComponents(color); + if (parsed == null) { + return BLACK; + } + return color.startsWith("#") ? color : "#" + color; + } + + private static List nCopies(int count, String color) { + return Collections.nCopies(count, color); + } + + public record ProVisualColors( + List dialColors, + List sliderLabelColors, + List> sliderColors, + String logoColor + ) { + public static ProVisualColors empty() { + return new ProVisualColors(List.of(), List.of(), List.of(), BLACK); + } + + public static ProVisualColors defaultForPro() { + var blackDials = nCopies(PRO_DIAL_COUNT, BLACK); + var blackLabels = nCopies(PRO_SLIDER_COUNT, BLACK); + var blackSliders = new ArrayList>(PRO_SLIDER_COUNT); + for (var i = 0; i < PRO_SLIDER_COUNT; i++) { + blackSliders.add(nCopies(PRO_SLIDER_SEGMENT_COUNT, BLACK)); + } + return new ProVisualColors(blackDials, blackLabels, List.copyOf(blackSliders), BLACK); + } + } +} diff --git a/src/main/java/com/getpcpanel/rest/ProcessResource.java b/src/main/java/com/getpcpanel/rest/ProcessResource.java new file mode 100644 index 00000000..2abfbbd8 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/ProcessResource.java @@ -0,0 +1,55 @@ +package com.getpcpanel.rest; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Base64; +import java.util.List; + +import javax.imageio.ImageIO; + +import com.getpcpanel.cpp.ISndCtrl; +import com.getpcpanel.iconextract.IIconService; +import com.getpcpanel.rest.model.dto.ProcessDto; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import lombok.extern.log4j.Log4j2; + +@Log4j2 +@Path("/api/processes") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +public class ProcessResource { + @Inject ISndCtrl sndCtrl; + @Inject IIconService iconService; + + @GET + public List listProcesses() { + return sndCtrl.getRunningApplications().stream() + .map(app -> new ProcessDto( + app.pid(), + app.file().getAbsolutePath(), + app.name(), + encodeIcon(iconService.getIconForFile(32, 32, app.file())))) + .toList(); + } + + static String encodeIcon(BufferedImage img) { + if (img == null) { + return null; + } + try { + var baos = new ByteArrayOutputStream(); + ImageIO.write(img, "png", baos); + return "data:image/png;base64," + Base64.getEncoder().encodeToString(baos.toByteArray()); + } catch (IOException e) { + log.debug("Failed to encode process icon", e); + return null; + } + } +} diff --git a/src/main/java/com/getpcpanel/rest/SettingsResource.java b/src/main/java/com/getpcpanel/rest/SettingsResource.java new file mode 100644 index 00000000..25a5bfc0 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/SettingsResource.java @@ -0,0 +1,65 @@ +package com.getpcpanel.rest; + +import com.getpcpanel.profile.SaveService; +import com.getpcpanel.profile.dto.MqttSettings; +import com.getpcpanel.profile.dto.WaveLinkSettings; +import com.getpcpanel.rest.model.dto.SettingsDto; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +@Path("/api/settings") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class SettingsResource { + @Inject SaveService saveService; + + @GET + public SettingsDto getSettings() { + return SettingsDto.from(saveService.get()); + } + + @PUT + public Response updateSettings(SettingsDto dto) { + var save = saveService.get(); + dto.applyTo(save); + saveService.save(); + return Response.ok().build(); + } + + @GET + @Path("/mqtt") + public MqttSettings getMqttSettings() { + return saveService.get().getMqtt(); + } + + @PUT + @Path("/mqtt") + public Response updateMqttSettings(MqttSettings settings) { + saveService.get().setMqtt(settings); + saveService.save(); + return Response.ok().build(); + } + + @GET + @Path("/wavelink") + public WaveLinkSettings getWaveLinkSettings() { + return saveService.get().getWaveLink(); + } + + @PUT + @Path("/wavelink") + public Response updateWaveLinkSettings(WaveLinkSettings settings) { + saveService.get().setWaveLink(settings); + saveService.save(); + return Response.ok().build(); + } +} diff --git a/src/main/java/com/getpcpanel/rest/VoiceMeeterResource.java b/src/main/java/com/getpcpanel/rest/VoiceMeeterResource.java new file mode 100644 index 00000000..5e4792b2 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/VoiceMeeterResource.java @@ -0,0 +1,39 @@ +package com.getpcpanel.rest; + +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("/api/voicemeeter") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +public class VoiceMeeterResource { + + public static class VoiceMeeterParam { + public String name; + public List params; + + public VoiceMeeterParam(String name, List params) { + this.name = name; + this.params = params; + } + } + + @GET + @Path("/basic") + public List getBasicParams() { + // Return empty list for now - these are typically obtained from user configuration + return List.of(); + } + + @GET + @Path("/advanced") + public List getAdvancedParams() { + // Return empty list for now - these are typically obtained from user configuration + return List.of(); + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/CommandType.java b/src/main/java/com/getpcpanel/rest/model/dto/CommandType.java new file mode 100644 index 00000000..4b0217d6 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/CommandType.java @@ -0,0 +1,15 @@ +package com.getpcpanel.rest.model.dto; + +import com.getpcpanel.rest.EventBroadcaster.AssignmentChangedEvent.Kinds; + +public record CommandType( + String name, + String command, + CommandCategory category, + Kinds kind +) { + + public enum CommandCategory { + standard, voicemeeter, obs, wavelink + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/ControlAssignmentsUpdateDto.java b/src/main/java/com/getpcpanel/rest/model/dto/ControlAssignmentsUpdateDto.java new file mode 100644 index 00000000..9c2378b0 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/ControlAssignmentsUpdateDto.java @@ -0,0 +1,14 @@ +package com.getpcpanel.rest.model.dto; + +import com.getpcpanel.commands.Commands; +import com.getpcpanel.profile.dto.KnobSetting; + +import jakarta.annotation.Nullable; + +public record ControlAssignmentsUpdateDto( + @Nullable Commands analog, + @Nullable Commands button, + @Nullable Commands dblButton, + @Nullable KnobSetting knobSetting +) { +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/DeviceDto.java b/src/main/java/com/getpcpanel/rest/model/dto/DeviceDto.java new file mode 100644 index 00000000..0e26655e --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/DeviceDto.java @@ -0,0 +1,34 @@ +package com.getpcpanel.rest.model.dto; + +import java.util.List; + +import com.getpcpanel.device.Device; +import com.getpcpanel.device.DeviceType; +import com.getpcpanel.profile.DeviceSave; + +import one.util.streamex.StreamEx; + +public record DeviceDto( + String serial, + String displayName, + DeviceType deviceType, + int analogCount, + int buttonCount, + boolean hasLogoLed, + String currentProfile, + List profiles +) { + public static DeviceDto from(Device device, DeviceSave deviceSave) { + var type = device.deviceType(); + return new DeviceDto( + device.getSerialNumber(), + device.getDisplayName(), + type, + type.getAnalogCount(), + type.getButtonCount(), + type.isHasLogoLed(), + deviceSave.getCurrentProfileName(), + StreamEx.of(deviceSave.getProfiles()).map(p -> p.getName()).toList() + ); + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/DeviceSnapshotDto.java b/src/main/java/com/getpcpanel/rest/model/dto/DeviceSnapshotDto.java new file mode 100644 index 00000000..99364d4d --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/DeviceSnapshotDto.java @@ -0,0 +1,77 @@ +package com.getpcpanel.rest.model.dto; + +import java.util.List; +import java.util.stream.IntStream; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.getpcpanel.device.Device; +import com.getpcpanel.profile.DeviceSave; +import com.getpcpanel.profile.Profile; +import com.getpcpanel.profile.dto.LightingConfig; +import com.getpcpanel.rest.ProVisualColorsService; +import com.getpcpanel.rest.model.ws.WsEvent; + +import one.util.streamex.StreamEx; + +/** + * Full device state snapshot sent over WebSocket on connection. + * Combines DeviceDto fields with lighting config, the active profile's + * assignments, and the current analog knob values — so the frontend + * never needs separate HTTP calls just to display device state. + */ +@JsonTypeName("device_snapshot") +public record DeviceSnapshotDto( + // ── core device fields (same as DeviceDto) ────────────────────────── + String serial, + String displayName, + String deviceType, + int analogCount, + int buttonCount, + boolean hasLogoLed, + String currentProfile, + List profiles, + // ── extra snapshot fields ──────────────────────────────────────────── + LightingConfig lightingConfig, + ProfileSnapshotDto currentProfileSnapshot, + List analogValues, + List dialColors, + List sliderLabelColors, + List> sliderColors, + String logoColor +) implements WsEvent { + /** + * WsEvent type discriminator understood by the frontend. + */ + public String type() { + return "device_snapshot"; + } + + public static DeviceSnapshotDto from(Device device, DeviceSave deviceSave, ProVisualColorsService proVisualColorsService) { + var dt = device.deviceType(); + var profile = device.currentProfile(); + var analogCount = dt.getAnalogCount(); + var visualColors = proVisualColorsService.resolve(device); + + var knobValues = IntStream.range(0, analogCount) + .mapToObj(device::getKnobRotation) + .toList(); + + return new DeviceSnapshotDto( + device.getSerialNumber(), + device.getDisplayName(), + dt.name(), + analogCount, + dt.getButtonCount(), + dt.isHasLogoLed(), + deviceSave.getCurrentProfileName(), + StreamEx.of(deviceSave.getProfiles()).map(Profile::getName).toList(), + device.getSavedLightingConfig(), + ProfileSnapshotDto.from(profile), + knobValues, + visualColors.dialColors(), + visualColors.sliderLabelColors(), + visualColors.sliderColors(), + visualColors.logoColor() + ); + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/ProcessDto.java b/src/main/java/com/getpcpanel/rest/model/dto/ProcessDto.java new file mode 100644 index 00000000..b7f31d90 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/ProcessDto.java @@ -0,0 +1,6 @@ +package com.getpcpanel.rest.model.dto; + +import javax.annotation.Nullable; + +public record ProcessDto(int pid, String path, String name, @Nullable String icon) { +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/ProfileDto.java b/src/main/java/com/getpcpanel/rest/model/dto/ProfileDto.java new file mode 100644 index 00000000..d902cfc8 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/ProfileDto.java @@ -0,0 +1,9 @@ +package com.getpcpanel.rest.model.dto; + +import com.getpcpanel.profile.Profile; + +public record ProfileDto(String name, boolean isMainProfile) { + public static ProfileDto from(Profile profile) { + return new ProfileDto(profile.getName(), profile.isMainProfile()); + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/ProfileSnapshotDto.java b/src/main/java/com/getpcpanel/rest/model/dto/ProfileSnapshotDto.java new file mode 100644 index 00000000..a6e8a8ae --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/ProfileSnapshotDto.java @@ -0,0 +1,32 @@ +package com.getpcpanel.rest.model.dto; + +import java.util.Map; + +import com.getpcpanel.commands.Commands; +import com.getpcpanel.profile.Profile; +import com.getpcpanel.profile.dto.KnobSetting; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +/** + * Snapshot of the currently active profile — all assignment data the frontend + * needs to render the device page without any additional HTTP calls. + */ +@RegisterForReflection +public record ProfileSnapshotDto( + String name, + Map dialData, + Map buttonData, + Map dblButtonData, + Map knobSettings +) { + public static ProfileSnapshotDto from(Profile profile) { + return new ProfileSnapshotDto( + profile.getName(), + profile.getDialData(), + profile.getButtonData(), + profile.getDblButtonData(), + profile.getKnobSettings() + ); + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/dto/SettingsDto.java b/src/main/java/com/getpcpanel/rest/model/dto/SettingsDto.java new file mode 100644 index 00000000..2bf947bc --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/dto/SettingsDto.java @@ -0,0 +1,125 @@ +package com.getpcpanel.rest.model.dto; + +import java.util.List; + +import javax.annotation.Nullable; + +import com.getpcpanel.profile.Save; +import com.getpcpanel.profile.dto.MqttSettings; +import com.getpcpanel.profile.dto.OSCConnectionInfo; +import com.getpcpanel.profile.dto.OverlayPosition; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class SettingsDto { + // General + private boolean mainUIIcons; + private boolean startupVersionCheck; + private boolean forceVolume; + private Long dblClickInterval; + private boolean preventClickWhenDblClick; + @Nullable private Integer preventSliderTwitchDelay; + @Nullable private Integer sliderRollingAverage; + @Nullable private Integer sendOnlyIfDelta; + private boolean workaroundsOnlySliders; + + // OBS + private boolean obsEnabled; + private String obsAddress; + private String obsPort; + private String obsPassword; + + // VoiceMeeter + private boolean voicemeeterEnabled; + private String voicemeeterPath; + + // OSC + private Integer oscListenPort; + private List oscConnections; + + // Overlay + private boolean overlayEnabled; + private boolean overlayUseLog; + private boolean overlayShowNumber; + private String overlayBackgroundColor; + private String overlayTextColor; + private String overlayBarColor; + private String overlayBarBackgroundColor; + private int overlayWindowCornerRounding; + @Nullable private Integer overlayBarHeight; + @Nullable private Integer overlayBarCornerRounding; + @Nullable private OverlayPosition overlayPosition; + @Nullable private Integer overlayPadding; + private MqttSettings mqtt; + + public static SettingsDto from(Save save) { + var dto = new SettingsDto(); + dto.mainUIIcons = save.isMainUIIcons(); + dto.startupVersionCheck = save.isStartupVersionCheck(); + dto.forceVolume = save.isForceVolume(); + dto.dblClickInterval = save.getDblClickInterval(); + dto.preventClickWhenDblClick = save.isPreventClickWhenDblClick(); + dto.preventSliderTwitchDelay = save.getPreventSliderTwitchDelay(); + dto.sliderRollingAverage = save.getSliderRollingAverage(); + dto.sendOnlyIfDelta = save.getSendOnlyIfDelta(); + dto.workaroundsOnlySliders = save.isWorkaroundsOnlySliders(); + dto.obsEnabled = save.isObsEnabled(); + dto.obsAddress = save.getObsAddress(); + dto.obsPort = save.getObsPort(); + dto.obsPassword = save.getObsPassword(); + dto.voicemeeterEnabled = save.isVoicemeeterEnabled(); + dto.voicemeeterPath = save.getVoicemeeterPath(); + dto.oscListenPort = save.getOscListenPort(); + dto.oscConnections = save.getOscConnections(); + dto.overlayEnabled = save.isOverlayEnabled(); + dto.overlayUseLog = save.isOverlayUseLog(); + dto.overlayShowNumber = save.isOverlayShowNumber(); + dto.overlayBackgroundColor = save.getOverlayBackgroundColor(); + dto.overlayTextColor = save.getOverlayTextColor(); + dto.overlayBarColor = save.getOverlayBarColor(); + dto.overlayBarBackgroundColor = save.getOverlayBarBackgroundColor(); + dto.overlayWindowCornerRounding = save.getOverlayWindowCornerRounding(); + dto.overlayBarHeight = save.getOverlayBarHeight(); + dto.overlayBarCornerRounding = save.getOverlayBarCornerRounding(); + dto.overlayPosition = save.getOverlayPosition(); + dto.overlayPadding = save.getOverlayPadding(); + dto.mqtt = save.getMqtt(); + return dto; + } + + public void applyTo(Save save) { + save.setMainUIIcons(mainUIIcons); + save.setStartupVersionCheck(startupVersionCheck); + save.setForceVolume(forceVolume); + save.setDblClickInterval(dblClickInterval); + save.setPreventClickWhenDblClick(preventClickWhenDblClick); + save.setPreventSliderTwitchDelay(preventSliderTwitchDelay); + save.setSliderRollingAverage(sliderRollingAverage); + save.setSendOnlyIfDelta(sendOnlyIfDelta); + save.setWorkaroundsOnlySliders(workaroundsOnlySliders); + save.setObsEnabled(obsEnabled); + save.setObsAddress(obsAddress); + save.setObsPort(obsPort); + save.setObsPassword(obsPassword); + save.setVoicemeeterEnabled(voicemeeterEnabled); + save.setVoicemeeterPath(voicemeeterPath); + save.setOscListenPort(oscListenPort); + save.setOscConnections(oscConnections); + save.setOverlayEnabled(overlayEnabled); + save.setOverlayUseLog(overlayUseLog); + save.setOverlayShowNumber(overlayShowNumber); + save.setOverlayBackgroundColor(overlayBackgroundColor); + save.setOverlayTextColor(overlayTextColor); + save.setOverlayBarColor(overlayBarColor); + save.setOverlayBarBackgroundColor(overlayBarBackgroundColor); + save.setOverlayWindowCornerRounding(overlayWindowCornerRounding); + save.setOverlayBarHeight(overlayBarHeight); + save.setOverlayBarCornerRounding(overlayBarCornerRounding); + save.setOverlayPosition(overlayPosition); + save.setOverlayPadding(overlayPadding); + save.setMqtt(mqtt); + } +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsAssignmentChangedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsAssignmentChangedEvent.java new file mode 100644 index 00000000..a5d246ed --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsAssignmentChangedEvent.java @@ -0,0 +1,9 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.getpcpanel.commands.Commands; +import com.getpcpanel.rest.EventBroadcaster.AssignmentChangedEvent.Kinds; + +@JsonTypeName("assignment_changed") +public record WsAssignmentChangedEvent(String serial, Kinds kind, int index, Commands commands) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsButtonEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsButtonEvent.java new file mode 100644 index 00000000..d1aa0328 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsButtonEvent.java @@ -0,0 +1,7 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; + +@JsonTypeName("button_press") +public record WsButtonEvent(String serial, int button, boolean pressed) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsControlSettingChangedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsControlSettingChangedEvent.java new file mode 100644 index 00000000..66b37b08 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsControlSettingChangedEvent.java @@ -0,0 +1,8 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.getpcpanel.profile.dto.KnobSetting; + +@JsonTypeName("control_setting_changed") +public record WsControlSettingChangedEvent(String serial, int index, KnobSetting settings) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceConnectedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceConnectedEvent.java new file mode 100644 index 00000000..be407264 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceConnectedEvent.java @@ -0,0 +1,10 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.getpcpanel.rest.model.dto.DeviceSnapshotDto; + +@JsonTypeName("device_connected") +public record WsDeviceConnectedEvent( + DeviceSnapshotDto deviceSnapshot +) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceDisconnectedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceDisconnectedEvent.java new file mode 100644 index 00000000..c5fa06fd --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceDisconnectedEvent.java @@ -0,0 +1,7 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; + +@JsonTypeName("device_disconnected") +public record WsDeviceDisconnectedEvent(String serial) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceRenamedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceRenamedEvent.java new file mode 100644 index 00000000..73f2ebc4 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsDeviceRenamedEvent.java @@ -0,0 +1,7 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; + +@JsonTypeName("device_renamed") +public record WsDeviceRenamedEvent(String serial, String displayName) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsEvent.java new file mode 100644 index 00000000..ad235d06 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsEvent.java @@ -0,0 +1,25 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.getpcpanel.rest.model.dto.DeviceSnapshotDto; + +@JsonTypeInfo(use = Id.NAME, include = As.PROPERTY, property = "type") +@JsonSubTypes({ + @Type(value = WsAssignmentChangedEvent.class, name = "assignment_changed"), + @Type(value = WsButtonEvent.class, name = "button_press"), + @Type(value = WsDeviceConnectedEvent.class, name = "device_connected"), + @Type(value = WsDeviceDisconnectedEvent.class, name = "device_disconnected"), + @Type(value = WsDeviceRenamedEvent.class, name = "device_renamed"), + @Type(value = WsKnobEvent.class, name = "knob_rotate"), + @Type(value = WsLightingChangedEvent.class, name = "lighting_changed"), + @Type(value = WsProfileSwitchedEvent.class, name = "profile_switched"), + @Type(value = WsVisualColorsChangedEvent.class, name = "visual_colors_changed"), + @Type(value = DeviceSnapshotDto.class, name = "device_snapshot"), + @Type(value = WsControlSettingChangedEvent.class, name = "control_setting_changed") +}) +public interface WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsKnobEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsKnobEvent.java new file mode 100644 index 00000000..745a8088 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsKnobEvent.java @@ -0,0 +1,7 @@ +package com.getpcpanel.rest.model.ws; + +import com.fasterxml.jackson.annotation.JsonTypeName; + +@JsonTypeName("knob_rotate") +public record WsKnobEvent(String serial, int knob, int value) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsLightingChangedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsLightingChangedEvent.java new file mode 100644 index 00000000..6bdbb137 --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsLightingChangedEvent.java @@ -0,0 +1,17 @@ +package com.getpcpanel.rest.model.ws; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.getpcpanel.profile.dto.LightingConfig; + +@JsonTypeName("lighting_changed") +public record WsLightingChangedEvent( + String serial, + LightingConfig lightingConfig, + List dialColors, + List sliderLabelColors, + List> sliderColors, + String logoColor +) implements WsEvent { +} diff --git a/src/main/java/com/getpcpanel/rest/model/ws/WsProfileSwitchedEvent.java b/src/main/java/com/getpcpanel/rest/model/ws/WsProfileSwitchedEvent.java new file mode 100644 index 00000000..4c30bb4a --- /dev/null +++ b/src/main/java/com/getpcpanel/rest/model/ws/WsProfileSwitchedEvent.java @@ -0,0 +1,18 @@ +package com.getpcpanel.rest.model.ws; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.getpcpanel.rest.model.dto.ProfileSnapshotDto; + +@JsonTypeName("profile_switched") +public record WsProfileSwitchedEvent( + String serial, + String profileName, + ProfileSnapshotDto profileSnapshot, + List dialColors, + List sliderLabelColors, + List