Compare commits
1
Commits
main
..
5b072329d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b072329d3 |
-43
@@ -1,43 +0,0 @@
|
|||||||
target/
|
|
||||||
!.mvn/wrapper/maven-wrapper.jar
|
|
||||||
!**/src/main/**/target/
|
|
||||||
!**/src/test/**/target/
|
|
||||||
.kotlin
|
|
||||||
|
|
||||||
### IntelliJ IDEA ###
|
|
||||||
.idea/modules.xml
|
|
||||||
.idea/jarRepositories.xml
|
|
||||||
.idea/compiler.xml
|
|
||||||
.idea/libraries/
|
|
||||||
*.iws
|
|
||||||
*.iml
|
|
||||||
*.ipr
|
|
||||||
src/main/resources/scripts/.venv/
|
|
||||||
lib/
|
|
||||||
logs/
|
|
||||||
data/
|
|
||||||
|
|
||||||
### Eclipse ###
|
|
||||||
.apt_generated
|
|
||||||
.classpath
|
|
||||||
.factorypath
|
|
||||||
.project
|
|
||||||
.settings
|
|
||||||
.springBeans
|
|
||||||
.sts4-cache
|
|
||||||
|
|
||||||
### NetBeans ###
|
|
||||||
/nbproject/private/
|
|
||||||
/nbbuild/
|
|
||||||
/dist/
|
|
||||||
/nbdist/
|
|
||||||
/.nb-gradle/
|
|
||||||
build/
|
|
||||||
!**/src/main/**/build/
|
|
||||||
!**/src/test/**/build/
|
|
||||||
|
|
||||||
### VS Code ###
|
|
||||||
.vscode/
|
|
||||||
|
|
||||||
### Mac OS ###
|
|
||||||
.DS_Store
|
|
||||||
Generated
-10
@@ -1,10 +0,0 @@
|
|||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# Ignored default folder with query files
|
|
||||||
/queries/
|
|
||||||
# Datasource local storage ignored files
|
|
||||||
/dataSources/
|
|
||||||
/dataSources.local.xml
|
|
||||||
# Editor-based HTTP Client requests
|
|
||||||
/httpRequests/
|
|
||||||
Generated
-1
@@ -1 +0,0 @@
|
|||||||
Virtueller Gesundheitsassistent
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<component name="ArtifactManager">
|
|
||||||
<artifact type="jar" name="Virtueller_Gesundheitsassistent:jar">
|
|
||||||
<output-path>$PROJECT_DIR$/out/artifacts/Virtueller_Gesundheitsassistent_jar</output-path>
|
|
||||||
<root id="archive" name="Virtueller_Gesundheitsassistent.jar">
|
|
||||||
<element id="module-output" name="Virtueller_Gesundheitsassistent" />
|
|
||||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/me/friwi/jcefmaven/141.0.10/jcefmaven-141.0.10.jar" path-in-jar="/" />
|
|
||||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/com/formdev/flatlaf/2.6/flatlaf-2.6.jar" path-in-jar="/" />
|
|
||||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/commons-io/commons-io/2.16.1/commons-io-2.16.1.jar" path-in-jar="/" />
|
|
||||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/jakarta/websocket/jakarta.websocket-client-api/2.2.0/jakarta.websocket-client-api-2.2.0.jar" path-in-jar="/" />
|
|
||||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/me/friwi/jcef-api/jcef-2caef5a+cef-141.0.10+g1d65b0d+chromium-141.0.7390.123/jcef-api-jcef-2caef5a+cef-141.0.10+g1d65b0d+chromium-141.0.7390.123.jar" path-in-jar="/" />
|
|
||||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/org/eclipse/paho/org.eclipse.paho.client.mqttv3/1.2.5/org.eclipse.paho.client.mqttv3-1.2.5.jar" path-in-jar="/" />
|
|
||||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/org/xerial/sqlite-jdbc/3.51.2.0/sqlite-jdbc-3.51.2.0.jar" path-in-jar="/" />
|
|
||||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/org/apache/commons/commons-compress/1.27.1/commons-compress-1.27.1.jar" path-in-jar="/" />
|
|
||||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/org/apache/commons/commons-lang3/3.16.0/commons-lang3-3.16.0.jar" path-in-jar="/" />
|
|
||||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/com/google/errorprone/error_prone_annotations/2.27.0/error_prone_annotations-2.27.0.jar" path-in-jar="/" />
|
|
||||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/org/jfree/jfreechart/1.5.4/jfreechart-1.5.4.jar" path-in-jar="/" />
|
|
||||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/commons-codec/commons-codec/1.17.1/commons-codec-1.17.1.jar" path-in-jar="/" />
|
|
||||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/com/google/code/gson/gson/2.11.0/gson-2.11.0.jar" path-in-jar="/" />
|
|
||||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/me/friwi/jogl-all/v2.4.0/jogl-all-v2.4.0.jar" path-in-jar="/" />
|
|
||||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/me/friwi/gluegen-rt/v2.4.0/gluegen-rt-v2.4.0.jar" path-in-jar="/" />
|
|
||||||
</root>
|
|
||||||
</artifact>
|
|
||||||
</component>
|
|
||||||
Generated
-7
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="Encoding">
|
|
||||||
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
<component name="InspectionProjectProfileManager">
|
|
||||||
<settings>
|
|
||||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
|
||||||
<version value="1.0" />
|
|
||||||
</settings>
|
|
||||||
</component>
|
|
||||||
Generated
-25
@@ -1,25 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="Black">
|
|
||||||
<option name="sdkName" value="Python 3.12 (gesundheitsassistent)" />
|
|
||||||
</component>
|
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
|
||||||
<component name="MavenCompilerTasksManager">
|
|
||||||
<option name="afterCompileTasks">
|
|
||||||
<set>
|
|
||||||
<MavenCompilerTask>
|
|
||||||
<option name="goal" value="test" />
|
|
||||||
<option name="projectPath" value="$PROJECT_DIR$/pom.xml" />
|
|
||||||
</MavenCompilerTask>
|
|
||||||
</set>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
<component name="MavenProjectsManager">
|
|
||||||
<option name="originalFiles">
|
|
||||||
<list>
|
|
||||||
<option value="$PROJECT_DIR$/pom.xml" />
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
<component name="ProjectRootManager" version="2" project-jdk-name="ms-17" project-jdk-type="JavaSDK" />
|
|
||||||
</project>
|
|
||||||
Generated
-6
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
# Virtueller Gesundheitsassistent
|
|
||||||
|
|
||||||
A Java 17 desktop application that ingests binary health predictions over MQTT, stores them in SQLite, computes rolling risk levels, and visualizes the result in a dashboard with optional avatar/Unreal integration.
|
|
||||||
|
|
||||||
## Contents
|
|
||||||
|
|
||||||
- [Features](#features)
|
|
||||||
- [Architecture Overview](#architecture-overview)
|
|
||||||
- [Tech Stack](#tech-stack)
|
|
||||||
- [Getting Started](#getting-started)
|
|
||||||
- [Configuration](#configuration)
|
|
||||||
- [Testing](#testing)
|
|
||||||
- [Project Structure](#project-structure)
|
|
||||||
- [Documentation](#documentation)
|
|
||||||
- [Known Limitations](#known-limitations)
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Swing desktop UI with:
|
|
||||||
- `Avatar Streaming` tab (embedded JCEF browser)
|
|
||||||
- `Dashboard` tab (risk time-series chart + level bar)
|
|
||||||
- MQTT subscription to prediction events (`PREDICTION` by default)
|
|
||||||
- SQLite persistence (`data/health.db`)
|
|
||||||
- Threshold-based risk classification from the latest 20 values:
|
|
||||||
- `NONE` for ratio `< 0.5`
|
|
||||||
- `WARNING` for ratio `>= 0.5`
|
|
||||||
- `HIGH` for ratio `>= 0.8`
|
|
||||||
- `DISASTER` for ratio `>= 0.9`
|
|
||||||
- Animation state JSON export (`animation.output.path`)
|
|
||||||
- Optional startup/shutdown management for:
|
|
||||||
- Python MQTT simulator
|
|
||||||
- Unreal/Pixel Streaming startup script
|
|
||||||
- Asynchronous logger with optional file output and rotation
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
Runtime flow:
|
|
||||||
|
|
||||||
1. The app initializes UI and services.
|
|
||||||
2. MQTT messages are received on a configured topic.
|
|
||||||
3. Incoming JSON payloads are validated.
|
|
||||||
4. Accepted predictions are persisted in SQLite.
|
|
||||||
5. Risk ratio and level are re-evaluated.
|
|
||||||
6. App state notifies UI controllers.
|
|
||||||
7. Dashboard widgets and chart update.
|
|
||||||
8. Animation JSON is written for external consumers.
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- Java 17
|
|
||||||
- Maven
|
|
||||||
- Swing + FlatLaf + JFreeChart
|
|
||||||
- Eclipse Paho MQTT client
|
|
||||||
- SQLite JDBC
|
|
||||||
- JCEF (`jcefmaven`)
|
|
||||||
- JUnit 5 + Mockito
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- JDK 17
|
|
||||||
- Maven 3.9+
|
|
||||||
- MQTT broker running at `localhost:1883` (or adjust config)
|
|
||||||
- Windows for bundled PowerShell/BAT integration scripts
|
|
||||||
- Optional: Python for simulator support
|
|
||||||
- Optional: Unreal/Pixel Streaming environment
|
|
||||||
|
|
||||||
### 1) Configure a minimal local run
|
|
||||||
|
|
||||||
In `src/main/resources/config/application.properties`, disable optional external integrations if they are not available on your machine:
|
|
||||||
|
|
||||||
```properties
|
|
||||||
mqtt_sim.enabled=false
|
|
||||||
unreal.enabled=false
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2) Build and run
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
mvn clean compile
|
|
||||||
mvn org.codehaus.mojo:exec-maven-plugin:3.5.0:java -Dexec.mainClass=vassistent.App
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also run `vassistent.App` directly from an IDE.
|
|
||||||
|
|
||||||
### 3) Send test data
|
|
||||||
|
|
||||||
Publish to topic `PREDICTION` with payload format:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"valid": true,
|
|
||||||
"_id": 1,
|
|
||||||
"prediction": 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Validation rules:
|
|
||||||
|
|
||||||
- `valid` must be `true`
|
|
||||||
- `_id` must exist
|
|
||||||
- `prediction` must be `0` or `1`
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Main config: `src/main/resources/config/application.properties`
|
|
||||||
Logger config: `src/main/resources/config/logger.properties`
|
|
||||||
|
|
||||||
High-impact keys:
|
|
||||||
|
|
||||||
- `mqtt.broker.url`, `mqtt.client.id`, `mqtt.topic`
|
|
||||||
- `streaming.url`
|
|
||||||
- `animation.output.path`
|
|
||||||
- `mqtt_sim.enabled`, `python.path`, `mqtt_sim.script`
|
|
||||||
- `unreal.enabled`, `unreal.executable`, `unreal.signalling_server.script`
|
|
||||||
|
|
||||||
A full key-by-key reference is available in [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md).
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Run tests with:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
mvn test
|
|
||||||
```
|
|
||||||
|
|
||||||
The suite includes unit and integration tests for state changes, payload validation, threshold evaluation, persistence behavior, process management, MQTT behavior, and animation output.
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```text
|
|
||||||
src/main/java/vassistent
|
|
||||||
App.java
|
|
||||||
bootstrap/ # startup wiring + shutdown sequencing
|
|
||||||
controller/ # UI orchestration
|
|
||||||
model/ # app state + value objects
|
|
||||||
service/ # MQTT, DB, stats, evaluation, processes
|
|
||||||
ui/ # Swing views/components
|
|
||||||
util/ # config loading/validation + logging
|
|
||||||
|
|
||||||
src/main/resources
|
|
||||||
config/application.properties
|
|
||||||
config/logger.properties
|
|
||||||
scripts/mqtt_simulator.py
|
|
||||||
scripts/start_avatar.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- [Documentation Index](docs/INDEX.md)
|
|
||||||
- [Project Review](docs/PROJECT_REVIEW.md)
|
|
||||||
- [Architecture](docs/ARCHITECTURE.md)
|
|
||||||
- [Setup and Run](docs/SETUP_AND_RUN.md)
|
|
||||||
- [Configuration](docs/CONFIGURATION.md)
|
|
||||||
- [Testing](docs/TESTING.md)
|
|
||||||
- [Source Map](docs/SOURCE_MAP.md)
|
|
||||||
- [Unreal Avatar](docs/Prototyp1_README_EN.md)
|
|
||||||
- [Known Issues](docs/KNOWN_ISSUES.md)
|
|
||||||
|
|
||||||
## Known Limitations
|
|
||||||
|
|
||||||
- The runtime database (`data/health.db`) is currently deleted on application shutdown.
|
|
||||||
- Default Unreal-related paths in `application.properties` are machine-specific and require adaptation on other systems.
|
|
||||||
- Enabling optional external integrations without valid local paths causes startup validation to fail.
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
# Architecture
|
|
||||||
|
|
||||||
## System Overview
|
|
||||||
|
|
||||||
The application is a state-driven desktop client with an event-processing pipeline:
|
|
||||||
|
|
||||||
1. ingest prediction events from MQTT,
|
|
||||||
2. persist accepted values in SQLite,
|
|
||||||
3. evaluate risk based on recent history,
|
|
||||||
4. update UI state and rendering,
|
|
||||||
5. export animation state for external consumers.
|
|
||||||
|
|
||||||
## Module Structure
|
|
||||||
|
|
||||||
- `vassistent.bootstrap`
|
|
||||||
Application startup/shutdown orchestration (`ApplicationInitializer`, `ApplicationShutdownManager`)
|
|
||||||
- `vassistent.service`
|
|
||||||
Runtime services and integrations (MQTT, persistence, statistics, evaluation, process management)
|
|
||||||
- `vassistent.model`
|
|
||||||
Core state/data objects (`AppState`, `ProblemLevel`, `DatabaseEntry`, `RatioPoint`)
|
|
||||||
- `vassistent.controller`
|
|
||||||
Connects app state/services with Swing views
|
|
||||||
- `vassistent.ui`
|
|
||||||
Window and visual components
|
|
||||||
- `vassistent.util`
|
|
||||||
Configuration loading/validation and logging
|
|
||||||
|
|
||||||
## Runtime Flow
|
|
||||||
|
|
||||||
### Startup sequence
|
|
||||||
|
|
||||||
1. `App.main` initializes look-and-feel.
|
|
||||||
2. `ApplicationInitializer.initialize()` validates config and wires all services.
|
|
||||||
3. MQTT subscription is registered on configured topic.
|
|
||||||
4. Optional external processes are started via `ProcessManagerService`.
|
|
||||||
5. UI window/controller is created on the Swing event thread.
|
|
||||||
6. JVM shutdown hook is registered through `ApplicationShutdownManager`.
|
|
||||||
|
|
||||||
### Event processing sequence
|
|
||||||
|
|
||||||
1. MQTT message arrives (`MqttClientService.messageArrived`).
|
|
||||||
2. Payload is routed to `BinaryEventService.handlePayload`.
|
|
||||||
3. Payload validation checks:
|
|
||||||
- `valid == true`
|
|
||||||
- `_id` is present
|
|
||||||
- `prediction` is `0` or `1`
|
|
||||||
- duplicate `_id` vs last processed message is rejected
|
|
||||||
4. Accepted value is stored by `DataPersistenceService`.
|
|
||||||
5. `EvaluationService` requests ratio from `StatisticsService.getRatio(20)`.
|
|
||||||
6. Ratio is mapped to a `ProblemLevel`.
|
|
||||||
7. `AppState` updates notify registered listeners.
|
|
||||||
8. `AnimationFileService` writes the corresponding animation JSON file.
|
|
||||||
|
|
||||||
## Core Domain Rules
|
|
||||||
|
|
||||||
`EvaluationService` maps ratios to levels:
|
|
||||||
|
|
||||||
- `< 0.5` -> `NONE`
|
|
||||||
- `>= 0.5` -> `WARNING`
|
|
||||||
- `>= 0.8` -> `HIGH`
|
|
||||||
- `>= 0.9` -> `DISASTER`
|
|
||||||
|
|
||||||
Persistence schema (`binary_event`):
|
|
||||||
|
|
||||||
- `id` (autoincrement primary key)
|
|
||||||
- `value` (`0`/`1`)
|
|
||||||
- `timestamp` (ISO local date-time string)
|
|
||||||
|
|
||||||
## Integration Boundaries
|
|
||||||
|
|
||||||
- **MQTT**: Eclipse Paho client, broker URL and topic from config.
|
|
||||||
- **Database**: local SQLite file (`data/health.db` by default).
|
|
||||||
- **UI streaming tab**: JCEF browser for a configured URL (`streaming.url`).
|
|
||||||
- **External processes**: optional Python simulator and Unreal launcher script.
|
|
||||||
- **Animation output**: JSON file written to `animation.output.path`.
|
|
||||||
|
|
||||||
For Unreal-specific implementation details, see [Unreal Avatar Prototype](Prototyp1_README_EN.md).
|
|
||||||
|
|
||||||
## Shutdown Behavior
|
|
||||||
|
|
||||||
`ApplicationShutdownManager` performs:
|
|
||||||
|
|
||||||
1. MQTT disconnect,
|
|
||||||
2. managed process shutdown,
|
|
||||||
3. runtime database file deletion (`data/health.db`),
|
|
||||||
4. logger shutdown.
|
|
||||||
|
|
||||||
## Architectural Tradeoffs
|
|
||||||
|
|
||||||
- **Pros**
|
|
||||||
- Clear service boundaries and explicit startup wiring.
|
|
||||||
- Good testability for service layer and integration logic.
|
|
||||||
- Configuration validation catches many misconfigurations early.
|
|
||||||
- **Current tradeoffs**
|
|
||||||
- Local database lifecycle is ephemeral by default (deleted on shutdown).
|
|
||||||
- Startup is tightly coupled to external-process configuration when enabled.
|
|
||||||
- UI logic is mostly verified indirectly through state and service tests.
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
# Configuration
|
|
||||||
|
|
||||||
Configuration is loaded from classpath properties files in `src/main/resources/config`:
|
|
||||||
|
|
||||||
- `application.properties` for runtime behavior
|
|
||||||
- `logger.properties` for logging behavior
|
|
||||||
|
|
||||||
`AppConfigValidator` performs startup validation and fails fast on invalid settings.
|
|
||||||
|
|
||||||
## `application.properties`
|
|
||||||
|
|
||||||
### Core runtime keys
|
|
||||||
|
|
||||||
| Key | Required | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `mqtt.broker.url` | yes | MQTT broker URI (`tcp://...`, `ssl://...`, `ws://...`, `wss://...`) |
|
|
||||||
| `mqtt.client.id` | yes | MQTT client ID |
|
|
||||||
| `mqtt.topic` | yes | MQTT topic to subscribe to |
|
|
||||||
| `streaming.url` | yes | URL for embedded streaming view (`http`/`https`) |
|
|
||||||
| `animation.output.path` | yes | Target path for animation JSON output |
|
|
||||||
| `app.mode` | no | Generic runtime mode flag |
|
|
||||||
|
|
||||||
### Optional simulator keys (`mqtt_sim.*`)
|
|
||||||
|
|
||||||
These become required/validated when `mqtt_sim.enabled=true`.
|
|
||||||
|
|
||||||
| Key | Description |
|
|
||||||
|---|---|
|
|
||||||
| `mqtt_sim.enabled` | Enable Python simulator startup |
|
|
||||||
| `python.path` | Python executable path |
|
|
||||||
| `mqtt_sim.script` | Simulator script path |
|
|
||||||
| `mqtt_sim.broker` | Broker host passed to simulator |
|
|
||||||
| `mqtt_sim.port` | Broker port passed to simulator |
|
|
||||||
| `mqtt_sim.topic` | Simulator publish topic |
|
|
||||||
| `mqtt_sim.qos` | QoS (`0-2`) |
|
|
||||||
| `mqtt_sim.interval_seconds` | Publish interval in seconds |
|
|
||||||
| `mqtt_sim.username` / `mqtt_sim.password` | Optional broker auth |
|
|
||||||
| `mqtt_sim.start_id` | Optional starting `_id` |
|
|
||||||
| `mqtt_sim.client_id` | Optional simulator client id |
|
|
||||||
| `mqtt_sim.log_file` | Optional simulator log path |
|
|
||||||
|
|
||||||
### Optional Unreal keys (`unreal.*`)
|
|
||||||
|
|
||||||
These become required/validated when `unreal.enabled=true`.
|
|
||||||
|
|
||||||
| Key | Description |
|
|
||||||
|---|---|
|
|
||||||
| `unreal.enabled` | Enable Unreal startup flow |
|
|
||||||
| `unreal.executable` | PowerShell launcher script path |
|
|
||||||
| `unreal.signalling_server.script` | Signalling server BAT path |
|
|
||||||
| `unreal.target.executable` | Unreal executable path passed to script |
|
|
||||||
| `unreal.target.working_dir` | Unreal working directory passed to script |
|
|
||||||
| `unreal.target.args` | Unreal launch arguments |
|
|
||||||
| `unreal.pid.dir` | Optional PID output directory for script |
|
|
||||||
| `unreal.pid.file` | PID file used for shutdown cleanup |
|
|
||||||
| `unreal.signalling.pid.file` | Signalling PID file used for cleanup |
|
|
||||||
| `unreal.startup.delay.seconds` | Delay before Unreal launch in script |
|
|
||||||
|
|
||||||
### Process startup health checks
|
|
||||||
|
|
||||||
| Key | Description | Default |
|
|
||||||
|---|---|---|
|
|
||||||
| `process.startup.healthcheck.enabled` | Enable startup health checks for managed processes | `true` |
|
|
||||||
| `process.startup.healthcheck.timeout.millis` | Health-check timeout | `10000` |
|
|
||||||
| `process.startup.healthcheck.poll.millis` | Poll interval | `250` |
|
|
||||||
|
|
||||||
## `logger.properties`
|
|
||||||
|
|
||||||
| Key | Description | Typical value |
|
|
||||||
|---|---|---|
|
|
||||||
| `logger.level` | Minimum log level | `DEBUG` |
|
|
||||||
| `logger.file.enabled` | Toggle file logging | `true` |
|
|
||||||
| `logger.file` | Log file path | `logs/application.log` |
|
|
||||||
| `logger.max.size.mb` | Rotation threshold in MB | `10` |
|
|
||||||
|
|
||||||
## Validation Behavior
|
|
||||||
|
|
||||||
The validator checks:
|
|
||||||
|
|
||||||
- URI format and scheme for MQTT broker URL
|
|
||||||
- non-empty required keys
|
|
||||||
- numeric ranges (`port`, QoS, timeout/poll values, delays)
|
|
||||||
- file/directory existence for enabled integrations
|
|
||||||
- optional quote handling and invalid path syntax
|
|
||||||
|
|
||||||
Any validation errors are grouped and thrown as a single startup exception.
|
|
||||||
|
|
||||||
## Script Environment Mapping
|
|
||||||
|
|
||||||
### `mqtt_simulator.py`
|
|
||||||
|
|
||||||
App config keys are forwarded as environment variables:
|
|
||||||
|
|
||||||
- `mqtt_sim.broker` -> `MQTT_SIM_BROKER`
|
|
||||||
- `mqtt_sim.port` -> `MQTT_SIM_PORT`
|
|
||||||
- `mqtt_sim.topic` -> `MQTT_SIM_TOPIC`
|
|
||||||
- `mqtt_sim.username` -> `MQTT_SIM_USERNAME`
|
|
||||||
- `mqtt_sim.password` -> `MQTT_SIM_PASSWORD`
|
|
||||||
- `mqtt_sim.qos` -> `MQTT_SIM_QOS`
|
|
||||||
- `mqtt_sim.interval_seconds` -> `MQTT_SIM_INTERVAL_SECONDS`
|
|
||||||
- `mqtt_sim.start_id` -> `MQTT_SIM_START_ID`
|
|
||||||
- `mqtt_sim.client_id` -> `MQTT_SIM_CLIENT_ID`
|
|
||||||
- `mqtt_sim.log_file` -> `MQTT_SIM_LOG_FILE`
|
|
||||||
|
|
||||||
### `start_avatar.ps1`
|
|
||||||
|
|
||||||
App config keys are forwarded as environment variables:
|
|
||||||
|
|
||||||
- `unreal.target.executable` -> `VGA_UE_EXE`
|
|
||||||
- `unreal.target.args` -> `VGA_UE_ARGS`
|
|
||||||
- `unreal.target.working_dir` -> `VGA_UE_WORKDIR`
|
|
||||||
- `unreal.signalling_server.script` -> `VGA_SIGNALLING_BAT`
|
|
||||||
- `unreal.startup.delay.seconds` -> `VGA_SIGNALLING_DELAY`
|
|
||||||
- `unreal.pid.dir` (or PID parent fallback) -> `VGA_PID_DIR`
|
|
||||||
|
|
||||||
## Practical Recommendation
|
|
||||||
|
|
||||||
For first-time local runs, keep:
|
|
||||||
|
|
||||||
```properties
|
|
||||||
mqtt_sim.enabled=false
|
|
||||||
unreal.enabled=false
|
|
||||||
```
|
|
||||||
|
|
||||||
Then enable integrations incrementally after validating local paths and dependencies.
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# Documentation Index
|
|
||||||
|
|
||||||
This folder contains detailed technical documentation for the project.
|
|
||||||
|
|
||||||
## Suggested Reading Order
|
|
||||||
|
|
||||||
1. [Project Review](PROJECT_REVIEW.md)
|
|
||||||
2. [Architecture](ARCHITECTURE.md)
|
|
||||||
3. [Setup and Run](SETUP_AND_RUN.md)
|
|
||||||
4. [Unreal Avatar Prototype](Prototyp1_README_EN.md)
|
|
||||||
5. [Configuration](CONFIGURATION.md)
|
|
||||||
6. [Testing](TESTING.md)
|
|
||||||
7. [Known Issues](KNOWN_ISSUES.md)
|
|
||||||
8. [Source Map](SOURCE_MAP.md)
|
|
||||||
|
|
||||||
## Document Guide
|
|
||||||
|
|
||||||
- [Project Review](PROJECT_REVIEW.md)
|
|
||||||
End-to-end implementation summary, design decisions, quality checks, and roadmap.
|
|
||||||
- [Architecture](ARCHITECTURE.md)
|
|
||||||
Component responsibilities, runtime data flow, and integration boundaries.
|
|
||||||
- [Setup and Run](SETUP_AND_RUN.md)
|
|
||||||
Reproducible setup for minimal and full integration modes.
|
|
||||||
- [Unreal Avatar Prototype](Prototyp1_README_EN.md)
|
|
||||||
Unreal Engine prototype details (MetaHuman setup, assets, animation pipeline, and plugin requirements).
|
|
||||||
- [Configuration](CONFIGURATION.md)
|
|
||||||
Complete key reference, validation behavior, and script environment mapping.
|
|
||||||
- [Testing](TESTING.md)
|
|
||||||
Test strategy, inventory, execution, and remaining gaps.
|
|
||||||
- [Known Issues](KNOWN_ISSUES.md)
|
|
||||||
Active limitations and impact.
|
|
||||||
- [Source Map](SOURCE_MAP.md)
|
|
||||||
Package-level map for fast code navigation.
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# Known Issues
|
|
||||||
|
|
||||||
## 1. Environment-Specific Defaults
|
|
||||||
|
|
||||||
`application.properties` contains machine-specific Unreal paths and currently enables optional integrations by default.
|
|
||||||
|
|
||||||
Impact:
|
|
||||||
|
|
||||||
- first run on a different machine can fail at startup validation unless values are adapted.
|
|
||||||
|
|
||||||
## 2. Database Is Deleted on Shutdown
|
|
||||||
|
|
||||||
`ApplicationShutdownManager` deletes `data/health.db` during shutdown.
|
|
||||||
|
|
||||||
Impact:
|
|
||||||
|
|
||||||
- historical event data is not retained across sessions.
|
|
||||||
|
|
||||||
## 3. Duplicate Filtering Is Limited to Last Seen `_id`
|
|
||||||
|
|
||||||
`BinaryEventService` only rejects a duplicate when it equals the immediate previous `_id`.
|
|
||||||
|
|
||||||
Impact:
|
|
||||||
|
|
||||||
- non-consecutive duplicate IDs can still be persisted.
|
|
||||||
|
|
||||||
## 4. Startup Coupling to Optional Integrations
|
|
||||||
|
|
||||||
When `mqtt_sim.enabled=true` or `unreal.enabled=true`, startup is blocked if related files/paths are invalid.
|
|
||||||
|
|
||||||
Impact:
|
|
||||||
|
|
||||||
- optional integrations can prevent the core app from launching.
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
# Project Review
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
The virtual health assistant is built as an event-driven desktop system that converts binary health predictions into a continuously updated risk visualization.
|
|
||||||
|
|
||||||
Primary goals:
|
|
||||||
|
|
||||||
1. receive predictions in near real time via MQTT,
|
|
||||||
2. persist events locally,
|
|
||||||
3. compute rolling risk levels,
|
|
||||||
4. provide immediate visual feedback in the UI,
|
|
||||||
5. export machine-readable state for external avatar integration.
|
|
||||||
|
|
||||||
## End-to-End Pipeline
|
|
||||||
|
|
||||||
1. `MqttClientService` subscribes to the configured topic.
|
|
||||||
2. Payloads are forwarded to `BinaryEventService`.
|
|
||||||
3. Payloads are validated (`valid`, `_id`, `prediction`).
|
|
||||||
4. Accepted predictions are stored by `DataPersistenceService`.
|
|
||||||
5. `StatisticsService` computes the current ratio from the latest 20 entries.
|
|
||||||
6. `EvaluationService` maps ratio to `ProblemLevel`.
|
|
||||||
7. `AppState` notifies UI controllers and views.
|
|
||||||
8. `AnimationFileService` writes `animation.json`.
|
|
||||||
|
|
||||||
## Functional Scope
|
|
||||||
|
|
||||||
- Desktop monitoring UI with two views (stream + dashboard)
|
|
||||||
- MQTT-driven event ingestion
|
|
||||||
- SQLite event persistence
|
|
||||||
- Rolling ratio analytics
|
|
||||||
- Level classification (`NONE`, `WARNING`, `HIGH`, `DISASTER`)
|
|
||||||
- Optional external process orchestration (simulator + Unreal launcher)
|
|
||||||
- Startup configuration validation and structured logging
|
|
||||||
|
|
||||||
## Design Highlights
|
|
||||||
|
|
||||||
- **Separation of concerns**
|
|
||||||
Startup/shutdown orchestration lives in `bootstrap`; business logic in `service`; view orchestration in `controller`.
|
|
||||||
- **Config-first runtime behavior**
|
|
||||||
MQTT, simulator, Unreal, and animation output are controlled via properties.
|
|
||||||
- **Fail-fast startup validation**
|
|
||||||
`AppConfigValidator` blocks startup when enabled integrations are misconfigured.
|
|
||||||
- **Testability by construction**
|
|
||||||
Multiple services support dependency injection in tests (for example process launchers and MQTT client instances).
|
|
||||||
|
|
||||||
## Quality and Verification
|
|
||||||
|
|
||||||
Automated tests cover:
|
|
||||||
|
|
||||||
- App state transitions and listener notifications
|
|
||||||
- Payload validation and duplicate-id handling
|
|
||||||
- Ratio and threshold evaluation behavior
|
|
||||||
- SQLite persistence integration behavior
|
|
||||||
- MQTT subscribe/publish/callback flow
|
|
||||||
- Process startup/shutdown command sequencing and startup report behavior
|
|
||||||
- Animation file mapping and overwrite behavior
|
|
||||||
|
|
||||||
See [Testing](TESTING.md) for details and command examples.
|
|
||||||
|
|
||||||
## Current Limitations
|
|
||||||
|
|
||||||
- Runtime database is deleted on shutdown (`ApplicationShutdownManager`), so historical data is not retained.
|
|
||||||
- Default Unreal-related paths are machine-specific and must be adapted for new environments.
|
|
||||||
- UI behavior is not covered by end-to-end GUI tests.
|
|
||||||
- Duplicate payload protection is based on the last seen `_id` only (consecutive duplicates).
|
|
||||||
|
|
||||||
## Improvement Roadmap
|
|
||||||
|
|
||||||
1. Make database cleanup optional through configuration.
|
|
||||||
2. Add profile-based configuration (local/dev/demo) to reduce machine-specific defaults.
|
|
||||||
3. Add UI-level automated checks (or contract tests around controller/view boundaries).
|
|
||||||
4. Improve duplicate handling (e.g., bounded cache of recent IDs).
|
|
||||||
5. Add metrics/telemetry for startup health and message throughput.
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
# Prototyp1 – Unreal Engine Project
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
**Prototyp1** is a Blueprint-based project built with **Unreal Engine 5.6.1**.
|
|
||||||
It utilizes MetaHuman technology and external Fab assets to create a medical environment featuring an interactive character.
|
|
||||||
The MetaHuman character serves as avatar for the application. His job is to perform as interface between the User and the AI that is analyzing the cognitive load of the user. The MetaHuman explains the status of the cognitive Load from the driver. The main reason he exists is to improve the acceptance for advises that are given from a machine to a driver in a real car.
|
|
||||||
|
|
||||||
ToDos that can be done for the future:
|
|
||||||
|
|
||||||
- Implementation of an integrated TTS-Program.
|
|
||||||
- Tests of various styles of avatars.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
All/
|
|
||||||
├── Content/
|
|
||||||
│ ├── Dr_Doofy/
|
|
||||||
│ ├── Fab/
|
|
||||||
│ │ ├── Hospital_Wall
|
|
||||||
│ │ ├── Lowpoly_Medical_Room/
|
|
||||||
│ │ ├── Medical_X-ray_Image_Viewer_-_200_Uploads_/
|
|
||||||
│ │ ├── Modular3D_Hospital_Environment/
|
|
||||||
│ ├── MetaHuman/
|
|
||||||
│ │ ├── Common/
|
|
||||||
│ │ ├── Dr_Doofy/
|
|
||||||
│ │ ├── Body/
|
|
||||||
│ │ ├── Clothes/
|
|
||||||
│ │ ├── Face/
|
|
||||||
│ │ ├── Grooms/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Components
|
|
||||||
|
|
||||||
### MetaHuman: Dr_Doofy
|
|
||||||
The central character of the project.
|
|
||||||
The character Blueprint is the main logic blueprint.
|
|
||||||
|
|
||||||
**Contains:**
|
|
||||||
- Full skeletal rig
|
|
||||||
- Blueprint class (core logic)
|
|
||||||
- Clothing assets
|
|
||||||
- Facial animations
|
|
||||||
- Groom assets (hair, etc.)
|
|
||||||
- Voice samples & performances
|
|
||||||
- Materials (color of clothes etc.)
|
|
||||||
|
|
||||||
All character logic is handled inside the Blueprint class.
|
|
||||||
|
|
||||||
Current blueprint image: [docs/blueprint.png](docs/blueprint.png)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### External Assets (Fab)
|
|
||||||
The following asset packs were integrated via Fab:
|
|
||||||
|
|
||||||
- Hospital_Wall_LowPoly
|
|
||||||
- Medical_Room
|
|
||||||
- Medical_X-ray_Image_Viewer_-_200_Uploads_
|
|
||||||
- Modular3D_Hospital_Environment
|
|
||||||
|
|
||||||
These assets provide the visual foundation of the environment.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Camera System
|
|
||||||
A **static camera** is implemented in the project.
|
|
||||||
|
|
||||||
An external explanatory video serves as documentation for its functionality.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Voice and Animation System
|
|
||||||
|
|
||||||
### Overview
|
|
||||||
The project uses externally generated voice samples combined with automated animations.
|
|
||||||
|
|
||||||
### Workflow
|
|
||||||
|
|
||||||
1. **Voice Generation**
|
|
||||||
- Voice samples are created using an external TTS program
|
|
||||||
- Corresponding animation sequences are generated based on the audio
|
|
||||||
in Unreal Engines
|
|
||||||
|
|
||||||
2. **AI Data Pipeline**
|
|
||||||
- An external AI determines the current hazard status
|
|
||||||
- The status is sent to an interface
|
|
||||||
- The interface writes the result into a file:
|
|
||||||
`Animation.json`
|
|
||||||
|
|
||||||
3. **Processing in Unreal Engine**
|
|
||||||
- After game start, the file is read every **60 seconds**
|
|
||||||
- The system distinguishes between **four predefined states**
|
|
||||||
- Based on the state, one of four prepared animations is played
|
|
||||||
|
|
||||||
4. **Animation Triggering**
|
|
||||||
- Animations are directly linked to generated voice samples
|
|
||||||
- Selection and playback are handled entirely via Blueprint logic
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Customization
|
|
||||||
|
|
||||||
All relevant content is located in the **Content folder**.
|
|
||||||
|
|
||||||
### Typical adjustments:
|
|
||||||
- Modify Blueprint logic in the MetaHuman Blueprint
|
|
||||||
- Replace or extend voice samples
|
|
||||||
- Adjust animations
|
|
||||||
- Replace or expand environment assets
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## External Resources
|
|
||||||
|
|
||||||
### Project Download
|
|
||||||
🔗 [Download Project](https://gigamove.rwth-aachen.de/de/download/36f0d4f5c6bfd4eb91072b14f0be593c)
|
|
||||||
|
|
||||||
The link expires on 7th April 2026.
|
|
||||||
|
|
||||||
Password: 6gn56CZ6JyZzG4G
|
|
||||||
|
|
||||||
### Camera Explanation Video
|
|
||||||
🔗 [Camera Explanation](https://www.youtube.com/watch?v=UVEgpwYlQeA)
|
|
||||||
|
|
||||||
### TTS Tool (Voice Generation)
|
|
||||||
🔗 [External TTS Tool](https://ttsmaker.com/deE)
|
|
||||||
|
|
||||||
### Creating Facial Animation from voice samples
|
|
||||||
|
|
||||||
🔗 [Facial Animation](https://www.youtube.com/watch?v=XIrekfg_fmw)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- Unreal Engine **5.6.1**
|
|
||||||
- MetaHuman Plugin installed
|
|
||||||
- Access to Fab assets
|
|
||||||
- Main Level: Main-Level-J
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Blueprint-only project (no C++)
|
|
||||||
- All logic implemented via Blueprints
|
|
||||||
- JSON-based external control pipeline
|
|
||||||
- Performance depends on MetaHuman complexity
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Required Plugins
|
|
||||||
|
|
||||||
- Blueprint File Utilities V 1.0
|
|
||||||
- Json Blueprint Utilities V 1.0
|
|
||||||
- Camera Shake Priviewer V 0.1
|
|
||||||
- Engine Camereas V 0.1
|
|
||||||
- Gameplay Cameras V 0.1
|
|
||||||
- MetaHuman Animator V 5.0
|
|
||||||
- MetaHuman Creator V 1.0
|
|
||||||
- MetaHuman SDK V 1.0
|
|
||||||
- MetaHuman Live Link V 1.0
|
|
||||||
- MetaHuman Core Tech V 1.0
|
|
||||||
- MetaHuman Animatore Calbration Processing V 1.0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Created on: 2026-03-24
|
|
||||||
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
# Setup and Run
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Java 17 (JDK)
|
|
||||||
- Maven 3.9+
|
|
||||||
- MQTT broker reachable at `localhost:1883` (or custom broker in config)
|
|
||||||
- Windows for bundled PowerShell/BAT process scripts
|
|
||||||
|
|
||||||
Optional:
|
|
||||||
|
|
||||||
- Python (for automatic MQTT simulator startup)
|
|
||||||
- Unreal + Pixel Streaming setup (for avatar integration)
|
|
||||||
|
|
||||||
## Recommended Startup Modes
|
|
||||||
|
|
||||||
### Mode A: Minimal local run (recommended first)
|
|
||||||
|
|
||||||
Use this mode to run only the Java application and MQTT pipeline.
|
|
||||||
|
|
||||||
In `src/main/resources/config/application.properties`:
|
|
||||||
|
|
||||||
```properties
|
|
||||||
mqtt_sim.enabled=false
|
|
||||||
unreal.enabled=false
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mode B: Full integration run
|
|
||||||
|
|
||||||
Enable simulator and/or Unreal startup only after all referenced paths are valid on your machine.
|
|
||||||
For Unreal Engine prototype setup details (MetaHuman, assets, plugins, and animation flow), see
|
|
||||||
[Unreal Avatar Prototype](Prototyp1_README_EN.md).
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
mvn clean compile
|
|
||||||
```
|
|
||||||
|
|
||||||
## Run from CLI
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
mvn org.codehaus.mojo:exec-maven-plugin:3.5.0:java -Dexec.mainClass=vassistent.App
|
|
||||||
```
|
|
||||||
|
|
||||||
## Run from IDE
|
|
||||||
|
|
||||||
Start class:
|
|
||||||
|
|
||||||
- `vassistent.App`
|
|
||||||
|
|
||||||
## Send Test Messages
|
|
||||||
|
|
||||||
Publish payloads to configured topic (default: `PREDICTION`) in this format:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"valid": true,
|
|
||||||
"_id": 1,
|
|
||||||
"prediction": 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Validation rules:
|
|
||||||
|
|
||||||
- `valid` must be `true`
|
|
||||||
- `_id` must exist
|
|
||||||
- `prediction` must be `0` or `1`
|
|
||||||
|
|
||||||
## Startup Validation Notes
|
|
||||||
|
|
||||||
The app validates configuration at startup (`AppConfigValidator`):
|
|
||||||
|
|
||||||
- if simulator is enabled, Python executable/script and simulator settings are validated;
|
|
||||||
- if Unreal integration is enabled, script/executable paths and related directories are validated;
|
|
||||||
- invalid configuration throws an `IllegalStateException` before UI startup.
|
|
||||||
|
|
||||||
## Shutdown Behavior
|
|
||||||
|
|
||||||
On shutdown, the app:
|
|
||||||
|
|
||||||
1. disconnects MQTT,
|
|
||||||
2. stops managed external processes,
|
|
||||||
3. deletes `data/health.db` (current behavior),
|
|
||||||
4. flushes and closes the logger.
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
# Source Map
|
|
||||||
|
|
||||||
Package-level source overview for `src/main/java` and `src/test/java`.
|
|
||||||
|
|
||||||
## Main Sources
|
|
||||||
|
|
||||||
### `vassistent`
|
|
||||||
|
|
||||||
- `App.java`
|
|
||||||
Application entry point.
|
|
||||||
|
|
||||||
### `vassistent.bootstrap`
|
|
||||||
|
|
||||||
- `ApplicationContext.java`
|
|
||||||
In-memory container for shared services/state.
|
|
||||||
- `ApplicationInitializer.java`
|
|
||||||
Service wiring, startup orchestration, process startup reporting.
|
|
||||||
- `ApplicationShutdownManager.java`
|
|
||||||
Graceful shutdown: MQTT disconnect, process stop, DB cleanup, logger shutdown.
|
|
||||||
|
|
||||||
### `vassistent.controller`
|
|
||||||
|
|
||||||
- `AppWindowController.java`
|
|
||||||
Main window lifecycle and cross-view coordination.
|
|
||||||
- `DashboardController.java`
|
|
||||||
Updates chart/level view from app state.
|
|
||||||
- `StreamingController.java`
|
|
||||||
Controls stream view actions.
|
|
||||||
|
|
||||||
### `vassistent.model`
|
|
||||||
|
|
||||||
- `AppState.java`
|
|
||||||
Observable application state.
|
|
||||||
- `DatabaseEntry.java`
|
|
||||||
Persisted event value + timestamp.
|
|
||||||
- `ProblemLevel.java`
|
|
||||||
Risk-level enum.
|
|
||||||
- `RatioPoint.java`
|
|
||||||
Time-series point for chart data.
|
|
||||||
|
|
||||||
### `vassistent.service`
|
|
||||||
|
|
||||||
- `AnimationFileService.java`
|
|
||||||
Writes current animation state JSON.
|
|
||||||
- `BinaryEventService.java`
|
|
||||||
Validates payloads and triggers persistence/evaluation.
|
|
||||||
- `DataPersistenceService.java`
|
|
||||||
SQLite schema setup and event read/write methods.
|
|
||||||
- `EvaluationService.java`
|
|
||||||
Ratio-to-level mapping and state update.
|
|
||||||
- `MqttClientService.java`
|
|
||||||
MQTT connect/subscribe/publish and callback routing.
|
|
||||||
- `ProcessManagerService.java`
|
|
||||||
Optional simulator/Unreal process startup, health checks, shutdown.
|
|
||||||
- `StatisticsService.java`
|
|
||||||
Ratio and rolling-average calculations from persisted events.
|
|
||||||
|
|
||||||
### `vassistent.ui`
|
|
||||||
|
|
||||||
- `AppWindow.java`
|
|
||||||
Root frame with tabbed layout.
|
|
||||||
- `DashboardView.java`
|
|
||||||
Chart and current-level visualization.
|
|
||||||
- `PixelStreamingView.java`
|
|
||||||
JCEF browser panel for stream URL.
|
|
||||||
- `ProblemLevelBar.java`
|
|
||||||
Custom UI component for level display.
|
|
||||||
|
|
||||||
### `vassistent.util`
|
|
||||||
|
|
||||||
- `AppConfigValidator.java`
|
|
||||||
Startup configuration validation.
|
|
||||||
- `ConfigLoader.java`
|
|
||||||
Classpath properties loading.
|
|
||||||
- `Logger.java`
|
|
||||||
Asynchronous logger with file rotation support.
|
|
||||||
|
|
||||||
## Resource Files
|
|
||||||
|
|
||||||
- `src/main/resources/config/application.properties`
|
|
||||||
- `src/main/resources/config/logger.properties`
|
|
||||||
- `src/main/resources/scripts/mqtt_simulator.py`
|
|
||||||
- `src/main/resources/scripts/start_avatar.ps1`
|
|
||||||
|
|
||||||
## Test Sources
|
|
||||||
|
|
||||||
### `vassistent.model`
|
|
||||||
|
|
||||||
- `AppStateTest.java`
|
|
||||||
|
|
||||||
### `vassistent.service`
|
|
||||||
|
|
||||||
- `AnimationFileServiceTest.java`
|
|
||||||
- `BinaryEventServiceTest.java`
|
|
||||||
- `DataPersistenceServiceTest.java`
|
|
||||||
- `EvaluationServiceTest.java`
|
|
||||||
- `MqttClientServiceTest.java`
|
|
||||||
- `ProcessManagerServiceTest.java`
|
|
||||||
- `StatisticsServiceTest.java`
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# Testing
|
|
||||||
|
|
||||||
## Run the Test Suite
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
mvn test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test Strategy
|
|
||||||
|
|
||||||
The project uses a mix of unit and integration tests:
|
|
||||||
|
|
||||||
- **Unit tests** for isolated business logic and service behavior
|
|
||||||
- **Integration tests** for filesystem/SQLite behavior and output artifacts
|
|
||||||
- **Mock-based tests** for MQTT and process-launch behavior
|
|
||||||
|
|
||||||
Frameworks:
|
|
||||||
|
|
||||||
- JUnit Jupiter
|
|
||||||
- Mockito JUnit Jupiter
|
|
||||||
|
|
||||||
## Test Inventory
|
|
||||||
|
|
||||||
| Test Class | Type | Main Focus |
|
|
||||||
|---|---|---|
|
|
||||||
| `AppStateTest` | Unit | state defaults and listener notifications |
|
|
||||||
| `BinaryEventServiceTest` | Unit | payload validation and duplicate handling |
|
|
||||||
| `EvaluationServiceTest` | Unit | ratio-threshold mapping and animation side effect |
|
|
||||||
| `StatisticsServiceTest` | Unit + Integration | rolling averages and ratio calculation |
|
|
||||||
| `DataPersistenceServiceTest` | Integration | SQLite persistence and retrieval ordering |
|
|
||||||
| `MqttClientServiceTest` | Unit (mocked client) | MQTT connect/subscribe/publish callbacks |
|
|
||||||
| `ProcessManagerServiceTest` | Unit (injected launcher) | process startup/shutdown and healthcheck reporting |
|
|
||||||
| `AnimationFileServiceTest` | Integration | JSON output mapping and overwrite behavior |
|
|
||||||
|
|
||||||
## What Is Covered Well
|
|
||||||
|
|
||||||
- Core event pipeline behavior
|
|
||||||
- Threshold logic and level transitions
|
|
||||||
- Persistence query correctness
|
|
||||||
- Managed process command generation and reporting
|
|
||||||
- Animation output mapping to domain levels
|
|
||||||
|
|
||||||
## Known Testing Gaps
|
|
||||||
|
|
||||||
- No full end-to-end GUI automation (Swing/JCEF interaction)
|
|
||||||
- No performance/load tests for high MQTT throughput
|
|
||||||
- No integration tests for real external Unreal processes in CI
|
|
||||||
- No property-based or fuzz testing for malformed payload variants
|
|
||||||
|
|
||||||
## Manual Verification Checklist
|
|
||||||
|
|
||||||
After a successful run, verify:
|
|
||||||
|
|
||||||
1. MQTT status in the UI changes to connected.
|
|
||||||
2. New payloads affect chart and level bar.
|
|
||||||
3. `animation.output.path` file is updated when level changes.
|
|
||||||
4. Shutdown stops optional processes and closes the application cleanly.
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 485 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<actions>
|
||||||
|
<action>
|
||||||
|
<actionName>run</actionName>
|
||||||
|
<packagings>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
</packagings>
|
||||||
|
<goals>
|
||||||
|
<goal>clean</goal>
|
||||||
|
<goal>javafx:run</goal>
|
||||||
|
</goals>
|
||||||
|
</action>
|
||||||
|
<action>
|
||||||
|
<actionName>debug</actionName>
|
||||||
|
<goals>
|
||||||
|
<goal>clean</goal>
|
||||||
|
<goal>javafx:run@ide-debug</goal>
|
||||||
|
</goals>
|
||||||
|
<properties>
|
||||||
|
<jpda.listen>true</jpda.listen>
|
||||||
|
</properties>
|
||||||
|
</action>
|
||||||
|
<action>
|
||||||
|
<actionName>profile</actionName>
|
||||||
|
<goals>
|
||||||
|
<goal>clean</goal>
|
||||||
|
<goal>javafx:run@ide-profile</goal>
|
||||||
|
</goals>
|
||||||
|
</action>
|
||||||
|
<action>
|
||||||
|
<actionName>CUSTOM-jlink</actionName>
|
||||||
|
<displayName>jlink</displayName>
|
||||||
|
<goals>
|
||||||
|
<goal>clean</goal>
|
||||||
|
<!-- compile not needed with javafx-maven-plugin v0.0.5 -->
|
||||||
|
<goal>compile</goal>
|
||||||
|
<goal>javafx:jlink</goal>
|
||||||
|
</goals>
|
||||||
|
</action>
|
||||||
|
</actions>
|
||||||
Binary file not shown.
@@ -1,75 +1,106 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<groupId>efi.projekt</groupId>
|
||||||
<groupId>vassistent</groupId>
|
|
||||||
<artifactId>Virtueller_Gesundheitsassistent</artifactId>
|
<artifactId>Virtueller_Gesundheitsassistent</artifactId>
|
||||||
<version>1.0-SNAPSHOT</version>
|
<version>1.0-SNAPSHOT</version>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<maven.compiler.source>17</maven.compiler.source>
|
|
||||||
<maven.compiler.target>17</maven.compiler.target>
|
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
</properties>
|
</properties>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-controls</artifactId>
|
||||||
|
<version>13</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.eclipse.paho</groupId>
|
<groupId>org.eclipse.paho</groupId>
|
||||||
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
|
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
|
||||||
<version>1.2.5</version>
|
<version>1.2.5</version>
|
||||||
|
<type>jar</type>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-web</artifactId>
|
||||||
|
<version>21.0.9</version>
|
||||||
|
<type>jar</type>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-fxml</artifactId>
|
||||||
|
<version>21.0.9</version>
|
||||||
|
<type>jar</type>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>jakarta.websocket</groupId>
|
<groupId>jakarta.websocket</groupId>
|
||||||
<artifactId>jakarta.websocket-client-api</artifactId>
|
<artifactId>jakarta.websocket-client-api</artifactId>
|
||||||
<version>2.2.0</version>
|
<version>2.2.0</version>
|
||||||
|
<type>jar</type>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.junit.jupiter</groupId>
|
<groupId>org.openpnp</groupId>
|
||||||
<artifactId>junit-jupiter</artifactId>
|
<artifactId>opencv</artifactId>
|
||||||
<version>5.10.2</version>
|
<version>4.12.0</version>
|
||||||
<scope>test</scope>
|
<type>jar</type>
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.mockito</groupId>
|
|
||||||
<artifactId>mockito-junit-jupiter</artifactId>
|
|
||||||
<version>5.12.0</version>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>me.friwi</groupId>
|
|
||||||
<artifactId>jcefmaven</artifactId>
|
|
||||||
<version>141.0.10</version>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.xerial</groupId>
|
|
||||||
<artifactId>sqlite-jdbc</artifactId>
|
|
||||||
<version>3.51.2.0</version>
|
|
||||||
<scope>compile</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.jfree</groupId>
|
|
||||||
<artifactId>jfreechart</artifactId>
|
|
||||||
<version>1.5.4</version>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.formdev</groupId>
|
|
||||||
<artifactId>flatlaf</artifactId>
|
|
||||||
<version>2.6</version>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-surefire-plugin</artifactId>
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
<version>3.2.5</version>
|
<version>3.8.0</version>
|
||||||
<configuration>
|
<configuration>
|
||||||
<useModulePath>false</useModulePath>
|
<release>21</release>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-maven-plugin</artifactId>
|
||||||
|
<version>0.0.4</version>
|
||||||
|
<configuration>
|
||||||
|
<mainClass>efi.projekt.virtueller_gesundheitsassistent.App</mainClass>
|
||||||
|
</configuration>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<!-- Default configuration for running -->
|
||||||
|
<!-- Usage: mvn clean javafx:run -->
|
||||||
|
<id>default-cli</id>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<!-- Configuration for manual attach debugging -->
|
||||||
|
<!-- Usage: mvn clean javafx:run@debug -->
|
||||||
|
<id>debug</id>
|
||||||
|
<configuration>
|
||||||
|
<options>
|
||||||
|
<option>-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=localhost:8000</option>
|
||||||
|
</options>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<!-- Configuration for automatic IDE debugging -->
|
||||||
|
<id>ide-debug</id>
|
||||||
|
<configuration>
|
||||||
|
<options>
|
||||||
|
<option>-agentlib:jdwp=transport=dt_socket,server=n,address=${jpda.address}</option>
|
||||||
|
</options>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<!-- Configuration for automatic IDE profiling -->
|
||||||
|
<id>ide-profile</id>
|
||||||
|
<configuration>
|
||||||
|
<options>
|
||||||
|
<option>${profiler.jvmargs.arg1}</option>
|
||||||
|
<option>${profiler.jvmargs.arg2}</option>
|
||||||
|
<option>${profiler.jvmargs.arg3}</option>
|
||||||
|
<option>${profiler.jvmargs.arg4}</option>
|
||||||
|
<option>${profiler.jvmargs.arg5}</option>
|
||||||
|
</options>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package efi.projekt.virtueller_gesundheitsassistent;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import javafx.application.Application;
|
||||||
|
import static javafx.application.Application.launch;
|
||||||
|
import javafx.fxml.FXMLLoader;
|
||||||
|
import javafx.scene.Parent;
|
||||||
|
import javafx.scene.Scene;
|
||||||
|
import javafx.stage.Stage;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JavaFX App
|
||||||
|
* @author naumueller
|
||||||
|
*/
|
||||||
|
public class App extends Application {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void start(Stage primaryStage) throws IOException {
|
||||||
|
// Lade FXML
|
||||||
|
FXMLLoader loader = new FXMLLoader(getClass().getResource("/efi/projekt/virtueller_gesundheitsassistent/view/FxView.fxml"));
|
||||||
|
Parent root = loader.load();
|
||||||
|
|
||||||
|
// Erzeuge Model & ViewModel
|
||||||
|
|
||||||
|
Scene scene = new Scene(root, 1280, 720);
|
||||||
|
|
||||||
|
primaryStage.setTitle("Virtueller Gesundheitsassistent");
|
||||||
|
primaryStage.setScene(scene);
|
||||||
|
primaryStage.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Parent loadFXML(String fxml) throws IOException {
|
||||||
|
FXMLLoader fxmlLoader = new FXMLLoader(
|
||||||
|
App.class.getResource("/efi/projekt/virtueller_gesundheitsassistent/view/" + fxml + ".fxml")
|
||||||
|
);
|
||||||
|
return fxmlLoader.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
launch();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package efi.projekt.virtueller_gesundheitsassistent;
|
||||||
|
|
||||||
|
public class SystemInfo {
|
||||||
|
|
||||||
|
public static String javaVersion() {
|
||||||
|
return System.getProperty("java.version");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String javafxVersion() {
|
||||||
|
return System.getProperty("javafx.version");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package efi.projekt.virtueller_gesundheitsassistent.controller;
|
||||||
|
|
||||||
|
import efi.projekt.virtueller_gesundheitsassistent.service.CameraService;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.fxml.FXML;
|
||||||
|
import javafx.scene.image.ImageView;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @author naumueller
|
||||||
|
*/
|
||||||
|
public class CameraController {
|
||||||
|
|
||||||
|
@FXML private ImageView cameraView;
|
||||||
|
|
||||||
|
private final CameraService cameraService = new CameraService();
|
||||||
|
|
||||||
|
@FXML
|
||||||
|
private void initialize() {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@FXML
|
||||||
|
private void start() {
|
||||||
|
cameraService.start(image ->
|
||||||
|
Platform.runLater(() -> cameraView.setImage(image))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@FXML
|
||||||
|
private void stopCamera() {
|
||||||
|
cameraService.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package efi.projekt.virtueller_gesundheitsassistent.model;
|
||||||
|
|
||||||
|
import javafx.beans.property.BooleanProperty;
|
||||||
|
import javafx.beans.property.ObjectProperty;
|
||||||
|
import javafx.beans.property.SimpleBooleanProperty;
|
||||||
|
import javafx.beans.property.SimpleObjectProperty;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @author naumueller
|
||||||
|
*/
|
||||||
|
public class AssistantState {
|
||||||
|
|
||||||
|
private final ObjectProperty<ClassificationType> classification =
|
||||||
|
new SimpleObjectProperty<>(ClassificationType.NORMAL);
|
||||||
|
|
||||||
|
private final BooleanProperty speaking =
|
||||||
|
new SimpleBooleanProperty(false);
|
||||||
|
|
||||||
|
private final BooleanProperty monitoring =
|
||||||
|
new SimpleBooleanProperty(true);
|
||||||
|
|
||||||
|
public ObjectProperty<ClassificationType> classificationProperty() {
|
||||||
|
return classification;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BooleanProperty speakingProperty() {
|
||||||
|
return speaking;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BooleanProperty monitoringProperty() {
|
||||||
|
return monitoring;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package efi.projekt.virtueller_gesundheitsassistent.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @author naumueller
|
||||||
|
*/
|
||||||
|
public record Classification (
|
||||||
|
String label,
|
||||||
|
String text,
|
||||||
|
String mqttValue
|
||||||
|
) {}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package efi.projekt.virtueller_gesundheitsassistent.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @author naumueller
|
||||||
|
*/
|
||||||
|
public enum ClassificationType {
|
||||||
|
NORMAL,
|
||||||
|
MUEDIGKEIT,
|
||||||
|
ABLENKUNG,
|
||||||
|
STRESS,
|
||||||
|
REAKTION
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package efi.projekt.virtueller_gesundheitsassistent.service;
|
||||||
|
|
||||||
|
import efi.projekt.virtueller_gesundheitsassistent.util.Logger;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import javafx.scene.image.Image;
|
||||||
|
import org.opencv.core.Core;
|
||||||
|
import org.opencv.core.Mat;
|
||||||
|
import org.opencv.core.MatOfByte;
|
||||||
|
import org.opencv.imgcodecs.Imgcodecs;
|
||||||
|
import org.opencv.videoio.VideoCapture;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @author naumueller
|
||||||
|
*/
|
||||||
|
public class CameraService {
|
||||||
|
|
||||||
|
static {
|
||||||
|
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
private VideoCapture capture;
|
||||||
|
private ScheduledExecutorService timer;
|
||||||
|
|
||||||
|
public void start(java.util.function.Consumer<Image> frameConsumer) {
|
||||||
|
if (capture != null && capture.isOpened()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
capture = new VideoCapture(0);
|
||||||
|
|
||||||
|
if (!capture.isOpened()) {
|
||||||
|
Logger.error("CAMERA", "Kamera konnte nicht geoeffnet werden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Runnable frameGrabber = () -> {
|
||||||
|
Mat frame = new Mat();
|
||||||
|
if (capture.read(frame)) {
|
||||||
|
Image image = matToImage(frame);
|
||||||
|
frameConsumer.accept(image);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
timer = Executors.newSingleThreadScheduledExecutor();
|
||||||
|
timer.scheduleAtFixedRate(frameGrabber, 0, 33, TimeUnit.MILLISECONDS);
|
||||||
|
|
||||||
|
Logger.info("CAMERA", "Kamera gestartet");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop() {
|
||||||
|
if (timer != null && !timer.isShutdown()) {
|
||||||
|
timer.shutdown();
|
||||||
|
}
|
||||||
|
if (capture != null && capture.isOpened()) {
|
||||||
|
capture.release();
|
||||||
|
}
|
||||||
|
Logger.info("CAMERA", "Kamera gestoppt");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Image matToImage(Mat frame) {
|
||||||
|
MatOfByte buffer = new MatOfByte();
|
||||||
|
Imgcodecs.imencode(".png", frame, buffer);
|
||||||
|
return new Image(new ByteArrayInputStream(buffer.toArray()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package efi.projekt.virtueller_gesundheitsassistent.service;
|
||||||
|
|
||||||
|
import efi.projekt.virtueller_gesundheitsassistent.model.ClassificationType;
|
||||||
|
import efi.projekt.virtueller_gesundheitsassistent.util.Logger;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @author naumueller
|
||||||
|
*/
|
||||||
|
public class ClassificationService {
|
||||||
|
|
||||||
|
private static final String TOPIC = "Text";
|
||||||
|
|
||||||
|
public ClassificationService(MqttClientService mqtt) {
|
||||||
|
|
||||||
|
mqtt.subscribe(TOPIC, payload -> {
|
||||||
|
|
||||||
|
ClassificationType type = switch (payload) {
|
||||||
|
case "0" -> ClassificationType.NORMAL;
|
||||||
|
case "1" -> ClassificationType.MUEDIGKEIT;
|
||||||
|
case "2" -> ClassificationType.ABLENKUNG;
|
||||||
|
case "3" -> ClassificationType.REAKTION;
|
||||||
|
case "4" -> ClassificationType.STRESS;
|
||||||
|
default -> ClassificationType.NORMAL;
|
||||||
|
};
|
||||||
|
|
||||||
|
Logger.info("STATE", "Neue Klassifizierung: " + type);
|
||||||
|
|
||||||
|
Platform.runLater(() ->
|
||||||
|
StateService.getState()
|
||||||
|
.classificationProperty()
|
||||||
|
.set(type)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
package efi.projekt.virtueller_gesundheitsassistent.service;
|
||||||
|
|
||||||
|
import efi.projekt.virtueller_gesundheitsassistent.util.Logger;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import org.eclipse.paho.client.mqttv3.*;
|
||||||
|
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @author naumueller
|
||||||
|
*/
|
||||||
|
public class MqttClientService implements MqttCallback {
|
||||||
|
|
||||||
|
private static final String BROKER_URL = "tcp://localhost:1883";
|
||||||
|
private static final String CLIENT_ID = "JavaClientPublisherSubscriber";
|
||||||
|
|
||||||
|
private final Map<String, Consumer<String>> topicListeners =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private MqttClient client;
|
||||||
|
|
||||||
|
public MqttClientService() {
|
||||||
|
try {
|
||||||
|
client = new MqttClient(
|
||||||
|
BROKER_URL,
|
||||||
|
CLIENT_ID,
|
||||||
|
new MemoryPersistence()
|
||||||
|
);
|
||||||
|
client.setCallback(this);
|
||||||
|
|
||||||
|
MqttConnectOptions options = new MqttConnectOptions();
|
||||||
|
options.setCleanSession(true);
|
||||||
|
options.setAutomaticReconnect(true);
|
||||||
|
|
||||||
|
Logger.info("MQTT", "Verbinde mit Broker " + BROKER_URL);
|
||||||
|
client.connect(options);
|
||||||
|
Logger.info("MQTT", "Verbindung hergestellt");
|
||||||
|
|
||||||
|
} catch (MqttException e) {
|
||||||
|
Logger.error("MQTT", "Fehler beim Verbinden", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void disconnect() {
|
||||||
|
try {
|
||||||
|
if (client != null && client.isConnected()) {
|
||||||
|
client.disconnect();
|
||||||
|
Logger.info("MQTT", "Verbindung getrennt");
|
||||||
|
}
|
||||||
|
} catch (MqttException e) {
|
||||||
|
Logger.error("MQTT", "Fehler beim Trennen", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void subscribe(String topic, Consumer<String> listener) {
|
||||||
|
topicListeners.put(topic, listener);
|
||||||
|
try {
|
||||||
|
client.subscribe(topic);
|
||||||
|
Logger.info("MQTT", "Topic abonniert: " + topic);
|
||||||
|
} catch (MqttException e) {
|
||||||
|
Logger.error("MQTT", "Subscribe fehlgeschlagen: " + topic, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void publish(String topic, String message, int qos) {
|
||||||
|
try {
|
||||||
|
MqttMessage mqttMessage =
|
||||||
|
new MqttMessage(message.getBytes(StandardCharsets.UTF_8));
|
||||||
|
mqttMessage.setQos(qos);
|
||||||
|
|
||||||
|
client.publish(topic, mqttMessage);
|
||||||
|
|
||||||
|
Logger.debug(
|
||||||
|
"MQTT",
|
||||||
|
"Publish -> Topic=" + topic + ", QoS=" + qos + ", Payload=" + message
|
||||||
|
);
|
||||||
|
} catch (MqttException e) {
|
||||||
|
Logger.error("MQTT", "Publish fehlgeschlagen", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void connectionLost(Throwable cause) {
|
||||||
|
Logger.warn(
|
||||||
|
"MQTT",
|
||||||
|
"Verbindung verloren: " + cause.getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void messageArrived(String topic, MqttMessage message) throws Exception {
|
||||||
|
String payload =
|
||||||
|
new String(message.getPayload(), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
Logger.debug(
|
||||||
|
"MQTT",
|
||||||
|
"Nachricht empfangen -> Topic=" + topic + ", Payload=" + payload
|
||||||
|
);
|
||||||
|
|
||||||
|
Consumer<String> listener = topicListeners.get(topic);
|
||||||
|
if (listener != null) {
|
||||||
|
listener.accept(payload);
|
||||||
|
} else {
|
||||||
|
Logger.warn(
|
||||||
|
"MQTT",
|
||||||
|
"Keine Listener für Topic: " + topic
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deliveryComplete(IMqttDeliveryToken token) {
|
||||||
|
Logger.debug(
|
||||||
|
"MQTT",
|
||||||
|
"Delivery complete, MessageId=" + token.getMessageId()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package efi.projekt.virtueller_gesundheitsassistent.service;
|
||||||
|
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.web.WebView;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @author naumueller
|
||||||
|
*/
|
||||||
|
public class PixelStreamingService {
|
||||||
|
private final WebView webView;
|
||||||
|
|
||||||
|
public PixelStreamingService(String url) {
|
||||||
|
webView = new WebView();
|
||||||
|
webView.getEngine().load(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Node getView() {
|
||||||
|
return webView;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reload() {
|
||||||
|
webView.getEngine().reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop() {
|
||||||
|
webView.setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start() {
|
||||||
|
webView.setVisible(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package efi.projekt.virtueller_gesundheitsassistent.service;
|
||||||
|
|
||||||
|
import efi.projekt.virtueller_gesundheitsassistent.model.AssistantState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @author naumueller
|
||||||
|
*/
|
||||||
|
public class StateService {
|
||||||
|
|
||||||
|
private static final AssistantState STATE = new AssistantState();
|
||||||
|
|
||||||
|
private StateService() {}
|
||||||
|
|
||||||
|
public static AssistantState getState() {
|
||||||
|
return STATE;
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
package efi.projekt.virtueller_gesundheitsassistent.service;
|
||||||
|
|
||||||
|
import efi.projekt.virtueller_gesundheitsassistent.util.Logger;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import jakarta.websocket.*;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @author naumueller
|
||||||
|
*/
|
||||||
|
@ClientEndpoint
|
||||||
|
public class UnrealWebSocketService {
|
||||||
|
|
||||||
|
private Session session;
|
||||||
|
private final URI serverUri;
|
||||||
|
private final AtomicBoolean connected = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
|
||||||
|
public UnrealWebSocketService(String serverUrl) {
|
||||||
|
this.serverUri = URI.create(serverUrl);
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void connect() {
|
||||||
|
try {
|
||||||
|
WebSocketContainer container =
|
||||||
|
ContainerProvider.getWebSocketContainer();
|
||||||
|
|
||||||
|
Logger.info("UNREAL", "Verbinde zu " + serverUri);
|
||||||
|
container.connectToServer(this, serverUri);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
Logger.error("UNREAL", "WebSocket-Verbindung fehlgeschlagen", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isConnected() {
|
||||||
|
return connected.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void disconnect() {
|
||||||
|
try {
|
||||||
|
if (session != null && session.isOpen()) {
|
||||||
|
session.close();
|
||||||
|
Logger.info("UNREAL", "WebSocket-Verbindung geschlossen");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Logger.error("UNREAL", "Fehler beim Schließen der Verbindung", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnOpen
|
||||||
|
public void onOpen(Session session) {
|
||||||
|
this.session = session;
|
||||||
|
connected.set(true);
|
||||||
|
Logger.info("UNREAL", "WebSocket verbunden");
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnClose
|
||||||
|
public void onClose(Session session, CloseReason reason) {
|
||||||
|
connected.set(false);
|
||||||
|
Logger.warn("UNREAL", "WebSocket geschlossen: " + reason.getReasonPhrase());
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnError
|
||||||
|
public void onError(Session session, Throwable throwable) {
|
||||||
|
connected.set(false);
|
||||||
|
Logger.error("UNREAL", "WebSocket-Fehler", throwable);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnMessage
|
||||||
|
public void onMessage(String message) {
|
||||||
|
Logger.debug("UNREAL", "Nachricht empfangen: " + message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void speak(String text) {
|
||||||
|
if (!connected.get()) {
|
||||||
|
Logger.warn("UNREAL", "Nicht verbunden! Text wird nicht gesendet!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String json = buildSpeakMessage(text);
|
||||||
|
|
||||||
|
session.getAsyncRemote().sendText(json);
|
||||||
|
Logger.info("UNREAL", "Sende Speak-Command: " + text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String buildSpeakMessage(String text) {
|
||||||
|
return "{"
|
||||||
|
+ "\"type\":\"speak\","
|
||||||
|
+ "\"text\":\"" + escape(text) + "\""
|
||||||
|
+ "}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public String escape(String text) {
|
||||||
|
return text.replace("\"", "\\\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package efi.projekt.virtueller_gesundheitsassistent.util;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @author naumueller
|
||||||
|
*/
|
||||||
|
public class Logger {
|
||||||
|
public enum Level {
|
||||||
|
DEBUG,
|
||||||
|
INFO,
|
||||||
|
WARN,
|
||||||
|
ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Level currentLevel = Level.DEBUG;
|
||||||
|
|
||||||
|
private static final DateTimeFormatter FORMATTER =
|
||||||
|
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
|
private Logger() {
|
||||||
|
// Verhindert Instanziierung
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void setLevel(Level level) {
|
||||||
|
currentLevel = level;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void debug(String source, String message) {
|
||||||
|
log(Level.DEBUG, source, message, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void info(String source, String message) {
|
||||||
|
log(Level.INFO, source, message, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void warn(String source, String message) {
|
||||||
|
log(Level.WARN, source, message, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void error(String source, String message) {
|
||||||
|
log(Level.ERROR, source, message, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void error(String source, String message, Throwable throwable) {
|
||||||
|
log(Level.ERROR, source, message, throwable);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static synchronized void log(
|
||||||
|
Level level,
|
||||||
|
String source,
|
||||||
|
String message,
|
||||||
|
Throwable throwable
|
||||||
|
) {
|
||||||
|
// Log-Level filtern
|
||||||
|
if (level.ordinal() < currentLevel.ordinal()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String timestamp = LocalDateTime.now().format(FORMATTER);
|
||||||
|
|
||||||
|
String logLine = String.format(
|
||||||
|
"[%s] [%s] [%s] %s",
|
||||||
|
timestamp,
|
||||||
|
level,
|
||||||
|
source,
|
||||||
|
message
|
||||||
|
);
|
||||||
|
|
||||||
|
if (level == Level.ERROR) {
|
||||||
|
System.err.println(logLine);
|
||||||
|
if (throwable != null) {
|
||||||
|
throwable.printStackTrace(System.err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
System.out.println(logLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<?import javafx.scene.control.Tab?>
|
||||||
|
<?import javafx.scene.control.TabPane?>
|
||||||
|
<?import javafx.scene.layout.AnchorPane?>
|
||||||
|
|
||||||
|
|
||||||
|
<TabPane xmlns="http://javafx.com/javafx/21" xmlns:fx="http://javafx.com/fxml/1">
|
||||||
|
<tabs>
|
||||||
|
<Tab text="Minimal">
|
||||||
|
<fx:include source="designs/MinimalView.fxml"/>
|
||||||
|
</Tab>
|
||||||
|
<Tab text="Immersive">
|
||||||
|
<fx:include source="designs/ImmersiveView.fxml"/>
|
||||||
|
</Tab>
|
||||||
|
<Tab text="Compact">
|
||||||
|
<fx:include source="designs/CompactView.fxml"/>
|
||||||
|
</Tab>
|
||||||
|
<Tab text="Dashboard">
|
||||||
|
<fx:include source="designs/DashboardView.fxml"/>
|
||||||
|
</Tab>
|
||||||
|
</tabs>
|
||||||
|
</TabPane>
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<?import java.lang.*?>
|
||||||
|
<?import java.util.*?>
|
||||||
|
<?import javafx.scene.*?>
|
||||||
|
<?import javafx.scene.control.*?>
|
||||||
|
<?import javafx.scene.layout.*?>
|
||||||
|
|
||||||
|
<AnchorPane id="AnchorPane" prefHeight="400.0" prefWidth="600.0" xmlns:fx="http://javafx.com/fxml/1">
|
||||||
|
|
||||||
|
</AnchorPane>
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<?import javafx.scene.image.ImageView?>
|
||||||
|
<?import javafx.scene.layout.StackPane?>
|
||||||
|
|
||||||
|
|
||||||
|
<StackPane xmlns="http://javafx.com/javafx/21" xmlns:fx="http://javafx.com/fxml/1" fx:controller="efi.projekt.virtueller_gesundheitsassistent.controller.CameraController">
|
||||||
|
<children>
|
||||||
|
<ImageView fx:id="cameraView" fitHeight="240.0" fitWidth="320.0" pickOnBounds="true" preserveRatio="true" />
|
||||||
|
</children>
|
||||||
|
</StackPane>
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<?import java.lang.*?>
|
||||||
|
<?import java.util.*?>
|
||||||
|
<?import javafx.scene.*?>
|
||||||
|
<?import javafx.scene.control.*?>
|
||||||
|
<?import javafx.scene.layout.*?>
|
||||||
|
|
||||||
|
<AnchorPane id="AnchorPane" prefHeight="400.0" prefWidth="600.0" xmlns:fx="http://javafx.com/fxml/1">
|
||||||
|
|
||||||
|
</AnchorPane>
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<?import java.lang.*?>
|
||||||
|
<?import java.util.*?>
|
||||||
|
<?import javafx.scene.*?>
|
||||||
|
<?import javafx.scene.control.*?>
|
||||||
|
<?import javafx.scene.layout.*?>
|
||||||
|
|
||||||
|
<AnchorPane id="AnchorPane" prefHeight="400.0" prefWidth="600.0" xmlns:fx="http://javafx.com/fxml/1">
|
||||||
|
|
||||||
|
</AnchorPane>
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<?import java.lang.*?>
|
||||||
|
<?import java.util.*?>
|
||||||
|
<?import javafx.scene.*?>
|
||||||
|
<?import javafx.scene.control.*?>
|
||||||
|
<?import javafx.scene.layout.*?>
|
||||||
|
|
||||||
|
<AnchorPane id="AnchorPane" prefHeight="400.0" prefWidth="600.0" xmlns:fx="http://javafx.com/fxml/1">
|
||||||
|
|
||||||
|
</AnchorPane>
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<?import java.lang.*?>
|
||||||
|
<?import java.util.*?>
|
||||||
|
<?import javafx.scene.*?>
|
||||||
|
<?import javafx.scene.control.*?>
|
||||||
|
<?import javafx.scene.layout.*?>
|
||||||
|
|
||||||
|
<AnchorPane id="AnchorPane" prefHeight="400.0" prefWidth="600.0" xmlns:fx="http://javafx.com/fxml/1">
|
||||||
|
|
||||||
|
</AnchorPane>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
module efi.projekt.virtueller_gesundheitsassistent {
|
||||||
|
requires javafx.controls;
|
||||||
|
requires javafx.fxml;
|
||||||
|
requires java.base;
|
||||||
|
requires org.eclipse.paho.client.mqttv3;
|
||||||
|
requires javafx.web;
|
||||||
|
requires jakarta.websocket.client;
|
||||||
|
requires opencv;
|
||||||
|
|
||||||
|
|
||||||
|
opens efi.projekt.virtueller_gesundheitsassistent to javafx.fxml;
|
||||||
|
|
||||||
|
exports efi.projekt.virtueller_gesundheitsassistent;
|
||||||
|
}
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package vassistent;
|
|
||||||
|
|
||||||
import vassistent.bootstrap.ApplicationContext;
|
|
||||||
import vassistent.bootstrap.ApplicationInitializer;
|
|
||||||
import vassistent.bootstrap.ApplicationShutdownManager;
|
|
||||||
import vassistent.controller.AppWindowController;
|
|
||||||
|
|
||||||
import javax.swing.*;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entry point for the virtual health assistant desktop application.
|
|
||||||
*/
|
|
||||||
public class App {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts the desktop application, initializes dependencies, and opens the main window.
|
|
||||||
*
|
|
||||||
* @param args command-line arguments (currently unused)
|
|
||||||
*/
|
|
||||||
public static void main(String[] args) {
|
|
||||||
ApplicationInitializer.initLookAndFeel();
|
|
||||||
|
|
||||||
ApplicationContext context =
|
|
||||||
ApplicationInitializer.initialize();
|
|
||||||
|
|
||||||
ApplicationShutdownManager shutdownManager =
|
|
||||||
new ApplicationShutdownManager(context);
|
|
||||||
shutdownManager.registerJvmShutdownHook();
|
|
||||||
|
|
||||||
SwingUtilities.invokeLater(() -> {
|
|
||||||
new AppWindowController(context, shutdownManager)
|
|
||||||
.createAndShowWindow();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
package vassistent.bootstrap;
|
|
||||||
|
|
||||||
import vassistent.model.AppState;
|
|
||||||
import vassistent.service.*;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lightweight container that stores shared application state and services.
|
|
||||||
*/
|
|
||||||
public class ApplicationContext {
|
|
||||||
|
|
||||||
private AppState appState;
|
|
||||||
|
|
||||||
private DataPersistenceService persistenceService;
|
|
||||||
private StatisticsService statisticsService;
|
|
||||||
private EvaluationService evaluationService;
|
|
||||||
private AnimationFileService unrealService;
|
|
||||||
private MqttClientService mqttService;
|
|
||||||
private BinaryEventService binaryEventService;
|
|
||||||
private ProcessManagerService processManagerService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stores the shared in-memory application state instance.
|
|
||||||
*
|
|
||||||
* @param appState state container used by UI and services
|
|
||||||
*/
|
|
||||||
public void setAppState(AppState appState) {
|
|
||||||
this.appState = appState;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stores the persistence service responsible for database access.
|
|
||||||
*
|
|
||||||
* @param persistenceService configured persistence service
|
|
||||||
*/
|
|
||||||
public void setPersistenceService(DataPersistenceService persistenceService) {
|
|
||||||
this.persistenceService = persistenceService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stores the statistics service used for ratio calculations.
|
|
||||||
*
|
|
||||||
* @param statisticsService configured statistics service
|
|
||||||
*/
|
|
||||||
public void setStatisticsService(StatisticsService statisticsService) {
|
|
||||||
this.statisticsService = statisticsService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stores the evaluation service used to derive problem levels.
|
|
||||||
*
|
|
||||||
* @param evaluationService configured evaluation service
|
|
||||||
*/
|
|
||||||
public void setEvaluationService(EvaluationService evaluationService) {
|
|
||||||
this.evaluationService = evaluationService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stores the animation integration service.
|
|
||||||
*
|
|
||||||
* @param unrealService configured animation file service
|
|
||||||
*/
|
|
||||||
public void setAnimationFileService(AnimationFileService unrealService) {
|
|
||||||
this.unrealService = unrealService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stores the MQTT client service.
|
|
||||||
*
|
|
||||||
* @param mqttService configured MQTT service
|
|
||||||
*/
|
|
||||||
public void setMqttService(MqttClientService mqttService) {
|
|
||||||
this.mqttService = mqttService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stores the binary event service handling incoming predictions.
|
|
||||||
*
|
|
||||||
* @param binaryEventService configured event service
|
|
||||||
*/
|
|
||||||
public void setBinaryEventService(BinaryEventService binaryEventService) {
|
|
||||||
this.binaryEventService = binaryEventService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the shared observable application state.
|
|
||||||
*
|
|
||||||
* @return application state
|
|
||||||
*/
|
|
||||||
public AppState getAppState() {
|
|
||||||
return appState;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the persistence service instance.
|
|
||||||
*
|
|
||||||
* @return persistence service
|
|
||||||
*/
|
|
||||||
public DataPersistenceService getPersistenceService() {
|
|
||||||
return persistenceService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the statistics service instance.
|
|
||||||
*
|
|
||||||
* @return statistics service
|
|
||||||
*/
|
|
||||||
public StatisticsService getStatisticsService() {
|
|
||||||
return statisticsService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the evaluation service instance.
|
|
||||||
*
|
|
||||||
* @return evaluation service
|
|
||||||
*/
|
|
||||||
public EvaluationService getEvaluationService() {
|
|
||||||
return evaluationService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the animation file service instance.
|
|
||||||
*
|
|
||||||
* @return animation file service
|
|
||||||
*/
|
|
||||||
public AnimationFileService getAnimationFileService() {
|
|
||||||
return unrealService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the MQTT service instance.
|
|
||||||
*
|
|
||||||
* @return MQTT service
|
|
||||||
*/
|
|
||||||
public MqttClientService getMqttService() {
|
|
||||||
return mqttService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the binary event service instance.
|
|
||||||
*
|
|
||||||
* @return binary event service
|
|
||||||
*/
|
|
||||||
public BinaryEventService getBinaryEventService() {
|
|
||||||
return binaryEventService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the external process manager service.
|
|
||||||
*
|
|
||||||
* @return process manager service
|
|
||||||
*/
|
|
||||||
public ProcessManagerService getProcessManagerService() {
|
|
||||||
return processManagerService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stores the process manager service used for external helper processes.
|
|
||||||
*
|
|
||||||
* @param processManagerService configured process manager service
|
|
||||||
*/
|
|
||||||
public void setProcessManagerService(ProcessManagerService processManagerService) {
|
|
||||||
this.processManagerService = processManagerService;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
package vassistent.bootstrap;
|
|
||||||
|
|
||||||
import com.formdev.flatlaf.FlatDarkLaf;
|
|
||||||
import vassistent.model.AppState;
|
|
||||||
import vassistent.service.*;
|
|
||||||
import vassistent.util.AppConfigValidator;
|
|
||||||
import vassistent.util.ConfigLoader;
|
|
||||||
import vassistent.util.Logger;
|
|
||||||
|
|
||||||
import javax.swing.*;
|
|
||||||
import java.util.Properties;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bootstraps application services, subscriptions, and UI look-and-feel.
|
|
||||||
*/
|
|
||||||
public class ApplicationInitializer {
|
|
||||||
|
|
||||||
private static Properties config =
|
|
||||||
ConfigLoader.loadProperties(
|
|
||||||
"config/application.properties"
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds and wires the full application context including subscriptions and process startup.
|
|
||||||
*
|
|
||||||
* @return fully initialized application context
|
|
||||||
*/
|
|
||||||
public static ApplicationContext initialize() {
|
|
||||||
|
|
||||||
AppConfigValidator.validate(config);
|
|
||||||
|
|
||||||
ApplicationContext context = new ApplicationContext();
|
|
||||||
|
|
||||||
// ===== Model State =====
|
|
||||||
context.setAppState(new AppState());
|
|
||||||
|
|
||||||
// ===== Infrastructure Services =====
|
|
||||||
String animationOutputPath =
|
|
||||||
normalizeConfigValue(
|
|
||||||
config.getProperty(
|
|
||||||
"animation.output.path",
|
|
||||||
"data/animation.json"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
context.setAnimationFileService(
|
|
||||||
new AnimationFileService(animationOutputPath)
|
|
||||||
);
|
|
||||||
|
|
||||||
String mqttBrokerUrl =
|
|
||||||
normalizeConfigValue(
|
|
||||||
config.getProperty(
|
|
||||||
"mqtt.broker.url",
|
|
||||||
"tcp://localhost:1883"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
String mqttClientId =
|
|
||||||
normalizeConfigValue(
|
|
||||||
config.getProperty(
|
|
||||||
"mqtt.client.id",
|
|
||||||
"JavaClientPublisherSubscriber"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
context.setMqttService(
|
|
||||||
new MqttClientService(
|
|
||||||
context.getAppState(),
|
|
||||||
mqttBrokerUrl,
|
|
||||||
mqttClientId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
context.setPersistenceService(new DataPersistenceService());
|
|
||||||
|
|
||||||
context.setStatisticsService(
|
|
||||||
new StatisticsService(context.getPersistenceService())
|
|
||||||
);
|
|
||||||
|
|
||||||
context.setEvaluationService(
|
|
||||||
new EvaluationService(
|
|
||||||
context.getStatisticsService(),
|
|
||||||
context.getAppState(),
|
|
||||||
context.getAnimationFileService()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
context.setBinaryEventService(
|
|
||||||
new BinaryEventService(
|
|
||||||
context.getPersistenceService(),
|
|
||||||
context.getEvaluationService()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
context.getMqttService().subscribe(
|
|
||||||
config.getProperty("mqtt.topic"),
|
|
||||||
payload -> context.getBinaryEventService()
|
|
||||||
.handlePayload(payload)
|
|
||||||
);
|
|
||||||
|
|
||||||
context.setProcessManagerService(new ProcessManagerService(config));
|
|
||||||
|
|
||||||
Logger.info("PROCESS", "Starting configured external processes");
|
|
||||||
|
|
||||||
ProcessManagerService.StartupReport startupReport =
|
|
||||||
context.getProcessManagerService().startProcesses();
|
|
||||||
|
|
||||||
logProcessStartupReport(startupReport);
|
|
||||||
|
|
||||||
if (startupReport.hasFailures()) {
|
|
||||||
context.getProcessManagerService().shutdown();
|
|
||||||
throw new IllegalStateException(startupReport.buildFailureMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies the FlatLaf look-and-feel and selected UI defaults.
|
|
||||||
*/
|
|
||||||
public static void initLookAndFeel() {
|
|
||||||
try {
|
|
||||||
UIManager.setLookAndFeel(new FlatDarkLaf());
|
|
||||||
UIManager.put("Component.focusWidth", 1);
|
|
||||||
UIManager.put("Button.arc", 8);
|
|
||||||
UIManager.put("TextComponent.arc", 8);
|
|
||||||
} catch (Exception e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trims config values and removes optional wrapping single/double quotes.
|
|
||||||
*
|
|
||||||
* @param value raw config value
|
|
||||||
* @return normalized config value or {@code null}
|
|
||||||
*/
|
|
||||||
private static String normalizeConfigValue(String value) {
|
|
||||||
if (value == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String trimmed = value.trim();
|
|
||||||
|
|
||||||
if ((trimmed.startsWith("\"") && trimmed.endsWith("\""))
|
|
||||||
|| (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
||||||
return trimmed.substring(1, trimmed.length() - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logs startup status for all process components and the global startup result.
|
|
||||||
*
|
|
||||||
* @param startupReport startup status report from {@link ProcessManagerService}
|
|
||||||
*/
|
|
||||||
private static void logProcessStartupReport(
|
|
||||||
ProcessManagerService.StartupReport startupReport
|
|
||||||
) {
|
|
||||||
logProcessStartStatus(startupReport.getMqttSimulator());
|
|
||||||
logProcessStartStatus(startupReport.getUnreal());
|
|
||||||
|
|
||||||
if (startupReport.hasFailures()) {
|
|
||||||
Logger.error("PROCESS", startupReport.buildFailureMessage());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.info("PROCESS", "External process startup completed successfully");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logs one startup component status from the process startup report.
|
|
||||||
*
|
|
||||||
* @param status startup status for one managed process component
|
|
||||||
*/
|
|
||||||
private static void logProcessStartStatus(
|
|
||||||
ProcessManagerService.ProcessStartStatus status
|
|
||||||
) {
|
|
||||||
String component = status.getComponentName();
|
|
||||||
|
|
||||||
if (!status.isEnabled()) {
|
|
||||||
Logger.info("PROCESS", component + " startup skipped (disabled)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.isStarted()) {
|
|
||||||
Logger.info("PROCESS", component + " startup succeeded");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String error = status.getErrorMessage();
|
|
||||||
if (error == null || error.isBlank()) {
|
|
||||||
Logger.error("PROCESS", component + " startup failed");
|
|
||||||
} else {
|
|
||||||
Logger.error("PROCESS", component + " startup failed: " + error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
package vassistent.bootstrap;
|
|
||||||
|
|
||||||
import vassistent.service.MqttClientService;
|
|
||||||
import vassistent.service.ProcessManagerService;
|
|
||||||
import vassistent.util.Logger;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Coordinates graceful shutdown of MQTT, managed external processes, and runtime artifacts.
|
|
||||||
*/
|
|
||||||
public class ApplicationShutdownManager {
|
|
||||||
|
|
||||||
private static final String RUNTIME_DB_FILE = "data/health.db";
|
|
||||||
|
|
||||||
private final ApplicationContext context;
|
|
||||||
private final AtomicBoolean shutdownStarted = new AtomicBoolean(false);
|
|
||||||
private final AtomicBoolean shutdownHookRegistered = new AtomicBoolean(false);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a shutdown manager for the provided application context.
|
|
||||||
*
|
|
||||||
* @param context initialized application context
|
|
||||||
*/
|
|
||||||
public ApplicationShutdownManager(ApplicationContext context) {
|
|
||||||
this.context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers the JVM shutdown hook once for this manager instance.
|
|
||||||
*/
|
|
||||||
public void registerJvmShutdownHook() {
|
|
||||||
if (!shutdownHookRegistered.compareAndSet(false, true)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Runtime.getRuntime().addShutdownHook(
|
|
||||||
new Thread(this::shutdown)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Executes shutdown steps for managed integrations.
|
|
||||||
*/
|
|
||||||
public void shutdown() {
|
|
||||||
if (!shutdownStarted.compareAndSet(false, true)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.info("SHUTDOWN", "Shutdown sequencing started");
|
|
||||||
|
|
||||||
disconnectMqtt();
|
|
||||||
stopExternalProcesses();
|
|
||||||
deleteRuntimeDatabase();
|
|
||||||
|
|
||||||
Logger.info("SHUTDOWN", "Shutdown sequencing finished");
|
|
||||||
Logger.shutdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnects from MQTT if a client service is available.
|
|
||||||
*/
|
|
||||||
private void disconnectMqtt() {
|
|
||||||
|
|
||||||
try {
|
|
||||||
MqttClientService mqtt = context.getMqttService();
|
|
||||||
|
|
||||||
if (mqtt != null) {
|
|
||||||
mqtt.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.info("SHUTDOWN", "MQTT disconnected");
|
|
||||||
} catch (Exception e) {
|
|
||||||
Logger.error("SHUTDOWN", "MQTT shutdown failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stops all external processes that were launched by the process manager.
|
|
||||||
*/
|
|
||||||
private void stopExternalProcesses() {
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
ProcessManagerService pm = context.getProcessManagerService();
|
|
||||||
|
|
||||||
if (pm != null) {
|
|
||||||
pm.shutdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.info("SHUTDOWN", "External processes stopped");
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
Logger.error("SHUTDOWN", "Process shutdown failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes the runtime SQLite database file when present.
|
|
||||||
*/
|
|
||||||
private void deleteRuntimeDatabase() {
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
File dbFile = new File(RUNTIME_DB_FILE);
|
|
||||||
|
|
||||||
if (!dbFile.exists()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean deleted = dbFile.delete();
|
|
||||||
|
|
||||||
if (deleted) {
|
|
||||||
Logger.info("SHUTDOWN", "Runtime database deleted");
|
|
||||||
} else {
|
|
||||||
Logger.warn("SHUTDOWN", "Runtime database could not be deleted");
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
Logger.error("SHUTDOWN", "Runtime database deletion failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
package vassistent.controller;
|
|
||||||
|
|
||||||
import vassistent.bootstrap.ApplicationContext;
|
|
||||||
import vassistent.bootstrap.ApplicationShutdownManager;
|
|
||||||
import vassistent.model.AppState;
|
|
||||||
import vassistent.ui.AppWindow;
|
|
||||||
import vassistent.ui.DashboardView;
|
|
||||||
import vassistent.ui.PixelStreamingView;
|
|
||||||
import vassistent.util.Logger;
|
|
||||||
|
|
||||||
import javax.swing.*;
|
|
||||||
import java.awt.*;
|
|
||||||
import java.awt.event.WindowAdapter;
|
|
||||||
import java.awt.event.WindowEvent;
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Controls the main window lifecycle and synchronizes UI elements with app state.
|
|
||||||
*/
|
|
||||||
public class AppWindowController {
|
|
||||||
|
|
||||||
private final ApplicationContext context;
|
|
||||||
private final ApplicationShutdownManager shutdownManager;
|
|
||||||
|
|
||||||
private AppWindow window;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a controller for the main application window.
|
|
||||||
*
|
|
||||||
* @param context initialized application context with required services
|
|
||||||
* @param shutdownManager shutdown coordinator shared with application bootstrap
|
|
||||||
*/
|
|
||||||
public AppWindowController(
|
|
||||||
ApplicationContext context,
|
|
||||||
ApplicationShutdownManager shutdownManager
|
|
||||||
) {
|
|
||||||
this.context = Objects.requireNonNull(context);
|
|
||||||
this.shutdownManager = Objects.requireNonNull(shutdownManager);
|
|
||||||
subscribeAppState();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the window, wires child controllers, and makes the UI visible.
|
|
||||||
*/
|
|
||||||
public void createAndShowWindow() {
|
|
||||||
|
|
||||||
window = new AppWindow();
|
|
||||||
|
|
||||||
DashboardView dashboardView = window.getDashboardView();
|
|
||||||
PixelStreamingView pixelStreamingView = window.getStreamingView();
|
|
||||||
|
|
||||||
DashboardController dashboardController =
|
|
||||||
new DashboardController(
|
|
||||||
context.getStatisticsService(),
|
|
||||||
dashboardView,
|
|
||||||
context.getAppState()
|
|
||||||
);
|
|
||||||
|
|
||||||
StreamingController previewController = new StreamingController(pixelStreamingView);
|
|
||||||
|
|
||||||
window.addWindowListener(new WindowAdapter() {
|
|
||||||
/**
|
|
||||||
* Triggers application shutdown when the window close button is pressed.
|
|
||||||
*
|
|
||||||
* @param e window event provided by Swing
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public void windowClosing(WindowEvent e) {
|
|
||||||
shutdown();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
window.setVisible(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs application shutdown sequence and exits the process.
|
|
||||||
*/
|
|
||||||
private void shutdown() {
|
|
||||||
|
|
||||||
shutdownManager.shutdown();
|
|
||||||
|
|
||||||
if (window != null)
|
|
||||||
window.dispose();
|
|
||||||
|
|
||||||
System.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers an observer to propagate {@link AppState} changes to the UI.
|
|
||||||
*/
|
|
||||||
private void subscribeAppState() {
|
|
||||||
|
|
||||||
Logger.info("Controller",
|
|
||||||
"Starting AppState observer subscription");
|
|
||||||
|
|
||||||
AppState state = context.getAppState();
|
|
||||||
|
|
||||||
state.addListener(this::onStateChanged);
|
|
||||||
|
|
||||||
Logger.info("Controller",
|
|
||||||
"AppState observer registered");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reacts to state updates and refreshes problem level and MQTT status labels.
|
|
||||||
*
|
|
||||||
* @param appState latest application state snapshot
|
|
||||||
*/
|
|
||||||
private void onStateChanged(AppState appState) {
|
|
||||||
|
|
||||||
if (window == null) {
|
|
||||||
Logger.warn("Controller",
|
|
||||||
"Window is null, UI update skipped");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
SwingUtilities.invokeLater(() -> {
|
|
||||||
|
|
||||||
window.updateProblemLevel(
|
|
||||||
appState.getProblemLevel().name()
|
|
||||||
);
|
|
||||||
|
|
||||||
window.updateMqttStatus(
|
|
||||||
appState.isMqttConnected()
|
|
||||||
);
|
|
||||||
|
|
||||||
Logger.debug("Controller",
|
|
||||||
"Problem level UI updated -> "
|
|
||||||
+ appState.getProblemLevel());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package vassistent.controller;
|
|
||||||
|
|
||||||
import vassistent.model.AppState;
|
|
||||||
import vassistent.model.RatioPoint;
|
|
||||||
import vassistent.service.StatisticsService;
|
|
||||||
import vassistent.ui.DashboardView;
|
|
||||||
import vassistent.util.Logger;
|
|
||||||
|
|
||||||
import javax.swing.*;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.concurrent.ExecutorService;
|
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the dashboard chart and level bar whenever application state changes.
|
|
||||||
*/
|
|
||||||
public class DashboardController {
|
|
||||||
|
|
||||||
private final StatisticsService statisticsService;
|
|
||||||
private final DashboardView dashboardView;
|
|
||||||
private final ExecutorService dashboardUpdateExecutor;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a dashboard controller and subscribes to app state changes.
|
|
||||||
*
|
|
||||||
* @param statisticsService service used to compute chart data
|
|
||||||
* @param dashboardView dashboard UI component
|
|
||||||
* @param appState observable application state
|
|
||||||
*/
|
|
||||||
public DashboardController(
|
|
||||||
StatisticsService statisticsService,
|
|
||||||
DashboardView dashboardView,
|
|
||||||
AppState appState
|
|
||||||
) {
|
|
||||||
this.statisticsService = statisticsService;
|
|
||||||
this.dashboardView = dashboardView;
|
|
||||||
this.dashboardUpdateExecutor =
|
|
||||||
Executors.newSingleThreadExecutor(runnable -> {
|
|
||||||
Thread thread = new Thread(runnable, "dashboard-update");
|
|
||||||
thread.setDaemon(true);
|
|
||||||
return thread;
|
|
||||||
});
|
|
||||||
|
|
||||||
appState.addListener(this::onStateChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recomputes dashboard content when state changes.
|
|
||||||
*
|
|
||||||
* @param state latest application state
|
|
||||||
*/
|
|
||||||
private void onStateChanged(AppState state) {
|
|
||||||
dashboardUpdateExecutor.execute(() -> {
|
|
||||||
List<RatioPoint> points =
|
|
||||||
statisticsService.getLastNAverages(120);
|
|
||||||
|
|
||||||
SwingUtilities.invokeLater(() -> {
|
|
||||||
|
|
||||||
Logger.info("STATISTICS",
|
|
||||||
"Entries loaded: " + points.size());
|
|
||||||
|
|
||||||
dashboardView.updateChart(points);
|
|
||||||
|
|
||||||
if (!points.isEmpty()) {
|
|
||||||
double ratio = points.get(points.size() - 1).getRatio();
|
|
||||||
dashboardView.updateProblemLevel(ratio);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package vassistent.controller;
|
|
||||||
|
|
||||||
import vassistent.ui.PixelStreamingView;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exposes basic stream view actions such as reload and focus handling.
|
|
||||||
*/
|
|
||||||
public class StreamingController {
|
|
||||||
|
|
||||||
private final PixelStreamingView view;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a streaming controller for the given view.
|
|
||||||
*
|
|
||||||
* @param view pixel streaming view component
|
|
||||||
*/
|
|
||||||
public StreamingController(PixelStreamingView view) {
|
|
||||||
this.view = view;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads a new streaming URL in the embedded browser.
|
|
||||||
*
|
|
||||||
* @param url stream URL to load
|
|
||||||
*/
|
|
||||||
public void reloadStream(String url) {
|
|
||||||
view.loadStream(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Requests focus for the streaming component (used as a fullscreen shortcut hook).
|
|
||||||
*/
|
|
||||||
public void toggleFullscreen() {
|
|
||||||
view.requestFocus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package vassistent.model;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
import java.util.function.Consumer;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Observable in-memory state shared between services and UI controllers.
|
|
||||||
*/
|
|
||||||
public class AppState {
|
|
||||||
|
|
||||||
private final AtomicLong dataVersion = new AtomicLong(0);
|
|
||||||
private final AtomicBoolean mqttConnected = new AtomicBoolean(false);
|
|
||||||
private final AtomicReference<ProblemLevel> problemLevel =
|
|
||||||
new AtomicReference<>(ProblemLevel.NONE);
|
|
||||||
|
|
||||||
private final List<Consumer<AppState>> listeners =
|
|
||||||
new CopyOnWriteArrayList<>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers a state listener that is notified on relevant updates.
|
|
||||||
*
|
|
||||||
* @param listener callback receiving the current state instance
|
|
||||||
*/
|
|
||||||
public void addListener(Consumer<AppState> listener) {
|
|
||||||
listeners.add(
|
|
||||||
Objects.requireNonNull(
|
|
||||||
listener,
|
|
||||||
"listener must not be null"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Notifies all currently registered listeners.
|
|
||||||
*/
|
|
||||||
private void notifyListeners() {
|
|
||||||
listeners.forEach(listener -> listener.accept(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the current computed problem level.
|
|
||||||
*
|
|
||||||
* @return current problem level
|
|
||||||
*/
|
|
||||||
public ProblemLevel getProblemLevel() {
|
|
||||||
return problemLevel.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the problem level and notifies listeners.
|
|
||||||
*
|
|
||||||
* @param problemLevel new problem level
|
|
||||||
*/
|
|
||||||
public void setProblemLevel(ProblemLevel problemLevel) {
|
|
||||||
this.problemLevel.set(
|
|
||||||
Objects.requireNonNull(
|
|
||||||
problemLevel,
|
|
||||||
"problemLevel must not be null"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
dataVersion.incrementAndGet();
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Indicates whether MQTT is currently connected.
|
|
||||||
*
|
|
||||||
* @return {@code true} if connected, otherwise {@code false}
|
|
||||||
*/
|
|
||||||
public boolean isMqttConnected() {
|
|
||||||
return mqttConnected.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates MQTT connection status and notifies listeners only when changed.
|
|
||||||
*
|
|
||||||
* @param mqttConnected new MQTT connection flag
|
|
||||||
*/
|
|
||||||
public void setMqttConnected(boolean mqttConnected) {
|
|
||||||
boolean previous =
|
|
||||||
this.mqttConnected.getAndSet(mqttConnected);
|
|
||||||
|
|
||||||
if (previous != mqttConnected) {
|
|
||||||
dataVersion.incrementAndGet();
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package vassistent.model;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable timestamped value loaded from the persistence layer.
|
|
||||||
*/
|
|
||||||
public class DatabaseEntry {
|
|
||||||
|
|
||||||
private final LocalDateTime timestamp;
|
|
||||||
private final double value;
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an immutable database entry model.
|
|
||||||
*
|
|
||||||
* @param timestamp event timestamp
|
|
||||||
* @param value stored binary value as numeric type
|
|
||||||
*/
|
|
||||||
public DatabaseEntry(LocalDateTime timestamp, double value) {
|
|
||||||
this.timestamp = timestamp;
|
|
||||||
this.value = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the event timestamp.
|
|
||||||
*
|
|
||||||
* @return timestamp of this entry
|
|
||||||
*/
|
|
||||||
public LocalDateTime getTimestamp() {
|
|
||||||
return timestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the stored value.
|
|
||||||
*
|
|
||||||
* @return numeric event value
|
|
||||||
*/
|
|
||||||
public double getValue() {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package vassistent.model;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Severity levels derived from the calculated prediction ratio.
|
|
||||||
*/
|
|
||||||
public enum ProblemLevel {
|
|
||||||
NONE,
|
|
||||||
WARNING,
|
|
||||||
HIGH,
|
|
||||||
DISASTER
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package vassistent.model;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable chart data point containing timestamp and ratio value.
|
|
||||||
*/
|
|
||||||
public class RatioPoint {
|
|
||||||
|
|
||||||
private final LocalDateTime timestamp;
|
|
||||||
private final double ratio;
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an immutable ratio point for chart rendering.
|
|
||||||
*
|
|
||||||
* @param timestamp timestamp associated with the ratio
|
|
||||||
* @param ratio ratio value in range {@code [0, 1]}
|
|
||||||
*/
|
|
||||||
public RatioPoint(LocalDateTime timestamp, double ratio) {
|
|
||||||
this.timestamp = timestamp;
|
|
||||||
this.ratio = ratio;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the ratio timestamp.
|
|
||||||
*
|
|
||||||
* @return point timestamp
|
|
||||||
*/
|
|
||||||
public LocalDateTime getTimestamp() {
|
|
||||||
return timestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the ratio value.
|
|
||||||
*
|
|
||||||
* @return ratio for this point
|
|
||||||
*/
|
|
||||||
public double getRatio() {
|
|
||||||
return ratio;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
package vassistent.service;
|
|
||||||
|
|
||||||
import vassistent.model.ProblemLevel;
|
|
||||||
import vassistent.util.Logger;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.FileWriter;
|
|
||||||
import java.io.IOException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes the current problem level as animation state JSON for Unreal integration.
|
|
||||||
*/
|
|
||||||
public class AnimationFileService {
|
|
||||||
private static final String DEFAULT_OUTPUT_PATH = "data/animation.json";
|
|
||||||
private final String outputPath;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the animation file service with default output path.
|
|
||||||
*/
|
|
||||||
public AnimationFileService() {
|
|
||||||
this(DEFAULT_OUTPUT_PATH);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the animation file service with an explicit output path.
|
|
||||||
*
|
|
||||||
* @param outputPath target JSON file path
|
|
||||||
*/
|
|
||||||
public AnimationFileService(String outputPath) {
|
|
||||||
this.outputPath = outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes the mapped animation state for the given level to the configured JSON file.
|
|
||||||
*
|
|
||||||
* @param level evaluated problem level
|
|
||||||
*/
|
|
||||||
public void wirteAnimationState(ProblemLevel level) {
|
|
||||||
|
|
||||||
String animation = mapLevelToAnimation(level);
|
|
||||||
|
|
||||||
File file = new File(outputPath);
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
File parent = file.getParentFile();
|
|
||||||
if (parent != null) {
|
|
||||||
parent.mkdirs();
|
|
||||||
}
|
|
||||||
|
|
||||||
String json = """
|
|
||||||
{
|
|
||||||
"animation": "%s"
|
|
||||||
}
|
|
||||||
""".formatted(animation);
|
|
||||||
|
|
||||||
try (FileWriter writer = new FileWriter(file, false)) {
|
|
||||||
writer.write(json);
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.info("ANIMATION FILE", "Animation JSON written");
|
|
||||||
|
|
||||||
} catch (IOException e) {
|
|
||||||
Logger.error("ANIMATION FILE", "Failed to write animation file", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps a problem level to the animation identifier expected by Unreal.
|
|
||||||
*
|
|
||||||
* @param level evaluated problem level
|
|
||||||
* @return animation key written into the JSON payload
|
|
||||||
*/
|
|
||||||
private String mapLevelToAnimation(ProblemLevel level) {
|
|
||||||
|
|
||||||
return switch (level) {
|
|
||||||
case NONE -> "no";
|
|
||||||
case WARNING -> "warning";
|
|
||||||
case HIGH -> "high";
|
|
||||||
case DISASTER -> "disaster";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
package vassistent.service;
|
|
||||||
|
|
||||||
import com.google.gson.JsonObject;
|
|
||||||
import com.google.gson.JsonParser;
|
|
||||||
import vassistent.util.Logger;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates incoming JSON payloads and forwards accepted predictions to storage and evaluation.
|
|
||||||
*/
|
|
||||||
public class BinaryEventService {
|
|
||||||
private final DataPersistenceService persistenceService;
|
|
||||||
private final EvaluationService evaluationService;
|
|
||||||
|
|
||||||
private Integer lastId = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a binary event service with required persistence and evaluation dependencies.
|
|
||||||
*
|
|
||||||
* @param persistenceService service used to persist accepted predictions
|
|
||||||
* @param evaluationService service used to recompute risk level after inserts
|
|
||||||
*/
|
|
||||||
public BinaryEventService(
|
|
||||||
DataPersistenceService persistenceService,
|
|
||||||
EvaluationService evaluationService
|
|
||||||
) {
|
|
||||||
this.persistenceService = persistenceService;
|
|
||||||
this.evaluationService = evaluationService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses, validates, and processes an incoming MQTT payload.
|
|
||||||
*
|
|
||||||
* @param payload raw JSON message body
|
|
||||||
*/
|
|
||||||
public synchronized void handlePayload(String payload) {
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
JsonObject json = JsonParser.parseString(payload).getAsJsonObject();
|
|
||||||
|
|
||||||
if (!json.has("valid") || !json.get("valid").getAsBoolean()) {
|
|
||||||
Logger.warn("EVENT", "Payload is not valid");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!json.has("_id") || !json.has("prediction")) {
|
|
||||||
Logger.warn("EVENT", "Payload is incomplete");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
int id = json.get("_id").getAsInt();
|
|
||||||
|
|
||||||
if (lastId != null && lastId == id) {
|
|
||||||
Logger.warn("EVENT", "Payload ID already processed: " + id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastId = id;
|
|
||||||
|
|
||||||
int prediction = json.get("prediction").getAsInt();
|
|
||||||
|
|
||||||
if (prediction != 0 && prediction != 1) {
|
|
||||||
Logger.warn("EVENT", "Invalid prediction: " + prediction);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
persistenceService.store(prediction);
|
|
||||||
evaluationService.evaluate();
|
|
||||||
|
|
||||||
Logger.debug("EVENT", "Prediction processed: " + prediction);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
Logger.error("EVENT", "Payload processing failed: " + payload, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
package vassistent.service;
|
|
||||||
|
|
||||||
import vassistent.model.DatabaseEntry;
|
|
||||||
import vassistent.util.Logger;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.sql.*;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles SQLite initialization and read/write operations for binary event data.
|
|
||||||
*/
|
|
||||||
public class DataPersistenceService {
|
|
||||||
|
|
||||||
private static final String DB_FOLDER = "data";
|
|
||||||
private final String DB_URL;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a persistence service using the default database location.
|
|
||||||
*/
|
|
||||||
public DataPersistenceService() {
|
|
||||||
DB_URL = "jdbc:sqlite:data/health.db";
|
|
||||||
createDatabaseFolder();
|
|
||||||
init();
|
|
||||||
}
|
|
||||||
|
|
||||||
// For tests
|
|
||||||
/**
|
|
||||||
* Creates a persistence service using a custom JDBC URL (primarily for tests).
|
|
||||||
*
|
|
||||||
* @param DB_URL JDBC URL to use for all database operations
|
|
||||||
*/
|
|
||||||
public DataPersistenceService(String DB_URL) {
|
|
||||||
this.DB_URL = DB_URL;
|
|
||||||
createDatabaseFolder();
|
|
||||||
init();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensures that the local database directory exists.
|
|
||||||
*/
|
|
||||||
public void createDatabaseFolder() {
|
|
||||||
File folder = new File(DB_FOLDER);
|
|
||||||
if (!folder.exists()) {
|
|
||||||
boolean created = folder.mkdirs();
|
|
||||||
if (created) {
|
|
||||||
Logger.info("DB", "Database folder created");
|
|
||||||
} else {
|
|
||||||
Logger.warn("DB", "Could not create database folder");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the database schema if it does not exist.
|
|
||||||
*/
|
|
||||||
private void init() {
|
|
||||||
try (
|
|
||||||
Connection c = DriverManager.getConnection(DB_URL); Statement s = c.createStatement()) {
|
|
||||||
|
|
||||||
s.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS binary_event (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
value INTEGER NOT NULL,
|
|
||||||
timestamp DATETIME NOT NULL
|
|
||||||
)
|
|
||||||
""");
|
|
||||||
|
|
||||||
Logger.info("DB", "Database initialized");
|
|
||||||
|
|
||||||
} catch (SQLException e) {
|
|
||||||
Logger.error("DB", "Database initialization failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stores a binary prediction value with the current timestamp.
|
|
||||||
*
|
|
||||||
* @param value prediction value (expected {@code 0} or {@code 1})
|
|
||||||
*/
|
|
||||||
public void store(int value) {
|
|
||||||
try (
|
|
||||||
Connection c = DriverManager.getConnection(DB_URL); PreparedStatement ps = c.prepareStatement(
|
|
||||||
"INSERT INTO binary_event (value, timestamp) VALUES (?, ?)"
|
|
||||||
)) {
|
|
||||||
ps.setInt(1, value);
|
|
||||||
ps.setString(2, LocalDateTime.now().toString());
|
|
||||||
ps.executeUpdate();
|
|
||||||
//Logger.debug("DB", "Database insert successful: " + ps.toString());
|
|
||||||
} catch (SQLException e) {
|
|
||||||
Logger.error("DB", "Database insert failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the newest entries and returns them in chronological order.
|
|
||||||
*
|
|
||||||
* @param count maximum number of entries to return
|
|
||||||
* @return list of entries ordered from oldest to newest within the selected window
|
|
||||||
*/
|
|
||||||
public List<DatabaseEntry> getLastEntries(int count) {
|
|
||||||
List<DatabaseEntry> result = new ArrayList<>();
|
|
||||||
|
|
||||||
String query = """
|
|
||||||
SELECT value, timestamp
|
|
||||||
FROM binary_event
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT ?
|
|
||||||
""";
|
|
||||||
try (Connection c = DriverManager.getConnection(DB_URL); PreparedStatement ps = c.prepareStatement(query)) {
|
|
||||||
|
|
||||||
ps.setInt(1, count);
|
|
||||||
|
|
||||||
ResultSet rs = ps.executeQuery();
|
|
||||||
|
|
||||||
while (rs.next()) {
|
|
||||||
|
|
||||||
String ts = rs.getString("timestamp");
|
|
||||||
|
|
||||||
LocalDateTime timestamp =
|
|
||||||
LocalDateTime.parse(ts);
|
|
||||||
|
|
||||||
double value = rs.getDouble("value");
|
|
||||||
|
|
||||||
result.add(new DatabaseEntry(timestamp, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (SQLException e) {
|
|
||||||
Logger.error("DB",
|
|
||||||
"Failed to load latest entries", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
Collections.reverse(result);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the configured JDBC connection URL.
|
|
||||||
*
|
|
||||||
* @return JDBC URL used by this service
|
|
||||||
*/
|
|
||||||
public String getDbUrl() {
|
|
||||||
return DB_URL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package vassistent.service;
|
|
||||||
|
|
||||||
|
|
||||||
import vassistent.model.AppState;
|
|
||||||
import vassistent.model.ProblemLevel;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts statistical ratios into problem levels and triggers animation updates.
|
|
||||||
*/
|
|
||||||
public class EvaluationService {
|
|
||||||
private final StatisticsService statisticsService;
|
|
||||||
private final AppState appState;
|
|
||||||
private final AnimationFileService animationFileService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an evaluation service.
|
|
||||||
*
|
|
||||||
* @param statisticsService service used to calculate ratios
|
|
||||||
* @param appState shared application state to update
|
|
||||||
* @param animationFileService service used to propagate animation changes
|
|
||||||
*/
|
|
||||||
public EvaluationService(
|
|
||||||
StatisticsService statisticsService,
|
|
||||||
AppState appState,
|
|
||||||
AnimationFileService animationFileService
|
|
||||||
) {
|
|
||||||
|
|
||||||
this.statisticsService = statisticsService;
|
|
||||||
this.appState = appState;
|
|
||||||
this.animationFileService = animationFileService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recalculates the current ratio, updates state, and writes animation output.
|
|
||||||
*/
|
|
||||||
public void evaluate() {
|
|
||||||
double ratio = statisticsService.getRatio(20);
|
|
||||||
|
|
||||||
ProblemLevel level = calculateLevel(ratio);
|
|
||||||
|
|
||||||
appState.setProblemLevel(level);
|
|
||||||
animationFileService.wirteAnimationState(level);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps a ratio value to a problem level threshold.
|
|
||||||
*
|
|
||||||
* @param ratio computed ratio in the range {@code [0, 1]}
|
|
||||||
* @return derived problem level
|
|
||||||
*/
|
|
||||||
private ProblemLevel calculateLevel(Double ratio) {
|
|
||||||
if (ratio >= 0.9) {
|
|
||||||
return ProblemLevel.DISASTER;
|
|
||||||
} else if (ratio >= 0.8) {
|
|
||||||
return ProblemLevel.HIGH;
|
|
||||||
} else if (ratio >= 0.5) {
|
|
||||||
return ProblemLevel.WARNING;
|
|
||||||
} else {
|
|
||||||
return ProblemLevel.NONE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
package vassistent.service;
|
|
||||||
|
|
||||||
import org.eclipse.paho.client.mqttv3.*;
|
|
||||||
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence;
|
|
||||||
import vassistent.model.AppState;
|
|
||||||
import vassistent.util.Logger;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.function.Consumer;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MQTT client wrapper that manages connection state, topic listeners, and callbacks.
|
|
||||||
*/
|
|
||||||
public class MqttClientService implements MqttCallback {
|
|
||||||
|
|
||||||
private AppState appState;
|
|
||||||
private static final String DEFAULT_BROKER_URL = "tcp://localhost:1883";
|
|
||||||
private static final String DEFAULT_CLIENT_ID = "JavaClientPublisherSubscriber";
|
|
||||||
private final String brokerUrl;
|
|
||||||
private final String clientId;
|
|
||||||
|
|
||||||
private final Map<String, Consumer<String>> topicListeners =
|
|
||||||
new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
private MqttClient client;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates and connects an MQTT client for the application.
|
|
||||||
*
|
|
||||||
* @param appState shared state updated with connection status
|
|
||||||
*/
|
|
||||||
public MqttClientService(AppState appState) {
|
|
||||||
this(
|
|
||||||
appState,
|
|
||||||
DEFAULT_BROKER_URL,
|
|
||||||
DEFAULT_CLIENT_ID,
|
|
||||||
null,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates and connects an MQTT client with configurable broker settings.
|
|
||||||
*
|
|
||||||
* @param appState shared state updated with connection status
|
|
||||||
* @param brokerUrl MQTT broker URL (for example {@code tcp://localhost:1883})
|
|
||||||
* @param clientId MQTT client identifier
|
|
||||||
*/
|
|
||||||
public MqttClientService(AppState appState, String brokerUrl, String clientId) {
|
|
||||||
this(
|
|
||||||
appState,
|
|
||||||
brokerUrl,
|
|
||||||
clientId,
|
|
||||||
null,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an MQTT service with an optionally injected client (primarily for tests).
|
|
||||||
*
|
|
||||||
* @param appState shared state updated with connection status
|
|
||||||
* @param injectedClient externally provided MQTT client, or {@code null} to create a default client
|
|
||||||
* @param connectOnStart whether the service should connect immediately
|
|
||||||
*/
|
|
||||||
MqttClientService(AppState appState, MqttClient injectedClient, boolean connectOnStart) {
|
|
||||||
this(
|
|
||||||
appState,
|
|
||||||
DEFAULT_BROKER_URL,
|
|
||||||
DEFAULT_CLIENT_ID,
|
|
||||||
injectedClient,
|
|
||||||
connectOnStart
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an MQTT service with configurable broker settings and optional injected client.
|
|
||||||
*
|
|
||||||
* @param appState shared state updated with connection status
|
|
||||||
* @param brokerUrl MQTT broker URL
|
|
||||||
* @param clientId MQTT client identifier
|
|
||||||
* @param injectedClient externally provided MQTT client, or {@code null} to create a default client
|
|
||||||
* @param connectOnStart whether the service should connect immediately
|
|
||||||
*/
|
|
||||||
MqttClientService(
|
|
||||||
AppState appState,
|
|
||||||
String brokerUrl,
|
|
||||||
String clientId,
|
|
||||||
MqttClient injectedClient,
|
|
||||||
boolean connectOnStart
|
|
||||||
) {
|
|
||||||
this.appState = appState;
|
|
||||||
this.brokerUrl = brokerUrl;
|
|
||||||
this.clientId = clientId;
|
|
||||||
|
|
||||||
try {
|
|
||||||
client = injectedClient != null
|
|
||||||
? injectedClient
|
|
||||||
: new MqttClient(
|
|
||||||
brokerUrl,
|
|
||||||
clientId,
|
|
||||||
new MemoryPersistence()
|
|
||||||
);
|
|
||||||
|
|
||||||
client.setCallback(this);
|
|
||||||
|
|
||||||
if (connectOnStart) {
|
|
||||||
connectClient();
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (MqttException e) {
|
|
||||||
Logger.error("MQTT", "Failed to connect", e);
|
|
||||||
appState.setMqttConnected(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connects the configured MQTT client with default options.
|
|
||||||
*
|
|
||||||
* @throws MqttException if connection fails
|
|
||||||
*/
|
|
||||||
private void connectClient() throws MqttException {
|
|
||||||
MqttConnectOptions options = new MqttConnectOptions();
|
|
||||||
options.setCleanSession(true);
|
|
||||||
options.setAutomaticReconnect(true);
|
|
||||||
|
|
||||||
Logger.info("MQTT", "Connecting to broker " + brokerUrl);
|
|
||||||
client.connect(options);
|
|
||||||
appState.setMqttConnected(true);
|
|
||||||
Logger.info("MQTT", "Connection established");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnects the MQTT client when connected.
|
|
||||||
*/
|
|
||||||
public void disconnect() {
|
|
||||||
try {
|
|
||||||
if (client != null && client.isConnected()) {
|
|
||||||
client.disconnect();
|
|
||||||
Logger.info("MQTT", "Connection closed");
|
|
||||||
}
|
|
||||||
} catch (MqttException e) {
|
|
||||||
Logger.error("MQTT", "Failed to disconnect", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribes to a topic and registers a payload listener.
|
|
||||||
*
|
|
||||||
* @param topic MQTT topic
|
|
||||||
* @param listener callback receiving payload strings
|
|
||||||
*/
|
|
||||||
public void subscribe(String topic, Consumer<String> listener) {
|
|
||||||
topicListeners.put(topic, listener);
|
|
||||||
try {
|
|
||||||
client.subscribe(topic);
|
|
||||||
Logger.info("MQTT", "Subscribed to topic: " + topic);
|
|
||||||
} catch (MqttException e) {
|
|
||||||
Logger.error("MQTT", "Subscribe failed: " + topic, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publishes a message to a topic.
|
|
||||||
*
|
|
||||||
* @param topic MQTT topic
|
|
||||||
* @param message payload body
|
|
||||||
* @param qos MQTT quality-of-service level
|
|
||||||
*/
|
|
||||||
public void publish(String topic, String message, int qos) {
|
|
||||||
try {
|
|
||||||
MqttMessage mqttMessage =
|
|
||||||
new MqttMessage(message.getBytes(StandardCharsets.UTF_8));
|
|
||||||
mqttMessage.setQos(qos);
|
|
||||||
|
|
||||||
client.publish(topic, mqttMessage);
|
|
||||||
|
|
||||||
Logger.debug(
|
|
||||||
"MQTT",
|
|
||||||
"Publish -> Topic=" + topic + ", QoS=" + qos + ", Payload=" + message
|
|
||||||
);
|
|
||||||
} catch (MqttException e) {
|
|
||||||
Logger.error("MQTT", "Publish failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MQTT callback invoked when the broker connection is lost.
|
|
||||||
*
|
|
||||||
* @param cause cause of the connection loss
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public void connectionLost(Throwable cause) {
|
|
||||||
Logger.warn(
|
|
||||||
"MQTT",
|
|
||||||
"Connection lost: " + cause.getMessage()
|
|
||||||
);
|
|
||||||
|
|
||||||
appState.setMqttConnected(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MQTT callback invoked for each incoming message.
|
|
||||||
*
|
|
||||||
* @param topic topic of the received message
|
|
||||||
* @param message received MQTT message
|
|
||||||
* @throws Exception propagated by callback handling if listener execution fails
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public void messageArrived(String topic, MqttMessage message) throws Exception {
|
|
||||||
String payload =
|
|
||||||
new String(message.getPayload(), StandardCharsets.UTF_8);
|
|
||||||
|
|
||||||
//Logger.debug(
|
|
||||||
// "MQTT",
|
|
||||||
// "Message received -> Topic=" + topic + ", Payload=" + payload
|
|
||||||
//);
|
|
||||||
|
|
||||||
Consumer<String> listener = topicListeners.get(topic);
|
|
||||||
if (listener != null) {
|
|
||||||
listener.accept(payload);
|
|
||||||
Logger.debug("MQTT", "Payload accepted");
|
|
||||||
} else {
|
|
||||||
Logger.warn(
|
|
||||||
"MQTT",
|
|
||||||
"No listener for topic: " + topic
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MQTT callback invoked when a published message is fully delivered.
|
|
||||||
*
|
|
||||||
* @param token delivery token associated with the completed publish
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public void deliveryComplete(IMqttDeliveryToken token) {
|
|
||||||
Logger.debug(
|
|
||||||
"MQTT",
|
|
||||||
"Delivery complete, MessageId=" + token.getMessageId()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,779 +0,0 @@
|
|||||||
package vassistent.service;
|
|
||||||
|
|
||||||
import vassistent.util.Logger;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Properties;
|
|
||||||
import java.util.function.BooleanSupplier;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts and stops optional external helper processes used by the application.
|
|
||||||
*/
|
|
||||||
public class ProcessManagerService {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents startup status for a single managed process component.
|
|
||||||
*/
|
|
||||||
public static final class ProcessStartStatus {
|
|
||||||
|
|
||||||
private final String componentName;
|
|
||||||
private final boolean enabled;
|
|
||||||
private final boolean started;
|
|
||||||
private final String errorMessage;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an immutable process start status instance.
|
|
||||||
*
|
|
||||||
* @param componentName logical component name
|
|
||||||
* @param enabled whether component startup was enabled
|
|
||||||
* @param started whether startup completed without launch error
|
|
||||||
* @param errorMessage optional startup error details
|
|
||||||
*/
|
|
||||||
private ProcessStartStatus(
|
|
||||||
String componentName,
|
|
||||||
boolean enabled,
|
|
||||||
boolean started,
|
|
||||||
String errorMessage
|
|
||||||
) {
|
|
||||||
this.componentName = componentName;
|
|
||||||
this.enabled = enabled;
|
|
||||||
this.started = started;
|
|
||||||
this.errorMessage = errorMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a disabled status for a component.
|
|
||||||
*
|
|
||||||
* @param componentName logical component name
|
|
||||||
* @return disabled status instance
|
|
||||||
*/
|
|
||||||
static ProcessStartStatus disabled(String componentName) {
|
|
||||||
return new ProcessStartStatus(componentName, false, false, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a successful startup status for a component.
|
|
||||||
*
|
|
||||||
* @param componentName logical component name
|
|
||||||
* @return successful status instance
|
|
||||||
*/
|
|
||||||
static ProcessStartStatus started(String componentName) {
|
|
||||||
return new ProcessStartStatus(componentName, true, true, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a failed startup status for a component.
|
|
||||||
*
|
|
||||||
* @param componentName logical component name
|
|
||||||
* @param errorMessage startup failure details
|
|
||||||
* @return failure status instance
|
|
||||||
*/
|
|
||||||
static ProcessStartStatus failed(String componentName, String errorMessage) {
|
|
||||||
return new ProcessStartStatus(componentName, true, false, errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns whether startup failed for an enabled component.
|
|
||||||
*
|
|
||||||
* @return {@code true} when startup failed
|
|
||||||
*/
|
|
||||||
public boolean hasFailed() {
|
|
||||||
return enabled && !started;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns logical component name.
|
|
||||||
*
|
|
||||||
* @return component name
|
|
||||||
*/
|
|
||||||
public String getComponentName() {
|
|
||||||
return componentName;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns whether startup was enabled.
|
|
||||||
*
|
|
||||||
* @return enabled flag
|
|
||||||
*/
|
|
||||||
public boolean isEnabled() {
|
|
||||||
return enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns whether startup completed without launch error.
|
|
||||||
*
|
|
||||||
* @return started flag
|
|
||||||
*/
|
|
||||||
public boolean isStarted() {
|
|
||||||
return started;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns startup error details when available.
|
|
||||||
*
|
|
||||||
* @return error details or {@code null}
|
|
||||||
*/
|
|
||||||
public String getErrorMessage() {
|
|
||||||
return errorMessage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Aggregates startup status for all managed process components.
|
|
||||||
*/
|
|
||||||
public static final class StartupReport {
|
|
||||||
|
|
||||||
private final ProcessStartStatus mqttSimulator;
|
|
||||||
private final ProcessStartStatus unreal;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a startup report.
|
|
||||||
*
|
|
||||||
* @param mqttSimulator startup status of MQTT simulator component
|
|
||||||
* @param unreal startup status of Unreal launcher component
|
|
||||||
*/
|
|
||||||
StartupReport(
|
|
||||||
ProcessStartStatus mqttSimulator,
|
|
||||||
ProcessStartStatus unreal
|
|
||||||
) {
|
|
||||||
this.mqttSimulator = Objects.requireNonNull(mqttSimulator);
|
|
||||||
this.unreal = Objects.requireNonNull(unreal);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns startup status of MQTT simulator component.
|
|
||||||
*
|
|
||||||
* @return MQTT simulator startup status
|
|
||||||
*/
|
|
||||||
public ProcessStartStatus getMqttSimulator() {
|
|
||||||
return mqttSimulator;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns startup status of Unreal launcher component.
|
|
||||||
*
|
|
||||||
* @return Unreal startup status
|
|
||||||
*/
|
|
||||||
public ProcessStartStatus getUnreal() {
|
|
||||||
return unreal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns whether at least one enabled process failed to launch.
|
|
||||||
*
|
|
||||||
* @return {@code true} when startup has failures
|
|
||||||
*/
|
|
||||||
public boolean hasFailures() {
|
|
||||||
return mqttSimulator.hasFailed() || unreal.hasFailed();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a compact startup failure message suitable for exceptions.
|
|
||||||
*
|
|
||||||
* @return failure message
|
|
||||||
*/
|
|
||||||
public String buildFailureMessage() {
|
|
||||||
StringBuilder message =
|
|
||||||
new StringBuilder("External process startup failed:");
|
|
||||||
|
|
||||||
appendFailure(message, mqttSimulator);
|
|
||||||
appendFailure(message, unreal);
|
|
||||||
|
|
||||||
return message.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appends one failed component summary to a message builder.
|
|
||||||
*
|
|
||||||
* @param message target message builder
|
|
||||||
* @param status component startup status
|
|
||||||
*/
|
|
||||||
private static void appendFailure(
|
|
||||||
StringBuilder message,
|
|
||||||
ProcessStartStatus status
|
|
||||||
) {
|
|
||||||
if (!status.hasFailed()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
message.append(System.lineSeparator())
|
|
||||||
.append("- ")
|
|
||||||
.append(status.getComponentName())
|
|
||||||
.append(": ");
|
|
||||||
|
|
||||||
String error = status.getErrorMessage();
|
|
||||||
if (error == null || error.isBlank()) {
|
|
||||||
message.append("unknown startup error");
|
|
||||||
} else {
|
|
||||||
message.append(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@FunctionalInterface
|
|
||||||
interface ProcessLauncher {
|
|
||||||
Process launch(ProcessBuilder processBuilder) throws IOException;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final String DEFAULT_UNREAL_PID_FILE =
|
|
||||||
"C:\\Users\\Student\\Documents\\Dannick\\avatar\\unreal.pid";
|
|
||||||
|
|
||||||
private static final String DEFAULT_SIGNALLING_PID_FILE =
|
|
||||||
"C:\\Users\\Student\\Documents\\Dannick\\avatar\\signalling.pid";
|
|
||||||
|
|
||||||
private static final boolean DEFAULT_STARTUP_HEALTHCHECK_ENABLED = true;
|
|
||||||
private static final long DEFAULT_STARTUP_HEALTHCHECK_TIMEOUT_MILLIS = 10_000L;
|
|
||||||
private static final long DEFAULT_STARTUP_HEALTHCHECK_POLL_MILLIS = 250L;
|
|
||||||
|
|
||||||
private final Properties config;
|
|
||||||
private final ProcessLauncher processLauncher;
|
|
||||||
private final String unrealPidFile;
|
|
||||||
private final String signallingPidFile;
|
|
||||||
private final boolean startupHealthcheckEnabled;
|
|
||||||
private final long startupHealthcheckTimeoutMillis;
|
|
||||||
private final long startupHealthcheckPollMillis;
|
|
||||||
|
|
||||||
private Process pythonProcess;
|
|
||||||
private Process unrealProcess;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a process manager using runtime configuration values.
|
|
||||||
*
|
|
||||||
* @param config loaded application properties
|
|
||||||
*/
|
|
||||||
public ProcessManagerService(Properties config) {
|
|
||||||
this(
|
|
||||||
config,
|
|
||||||
ProcessBuilder::start,
|
|
||||||
cleanConfigValue(
|
|
||||||
config.getProperty(
|
|
||||||
"unreal.pid.file",
|
|
||||||
DEFAULT_UNREAL_PID_FILE
|
|
||||||
)
|
|
||||||
),
|
|
||||||
cleanConfigValue(
|
|
||||||
config.getProperty(
|
|
||||||
"unreal.signalling.pid.file",
|
|
||||||
DEFAULT_SIGNALLING_PID_FILE
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a process manager with injectable process launcher and PID file paths.
|
|
||||||
*
|
|
||||||
* @param config loaded application properties
|
|
||||||
* @param processLauncher strategy used to launch process builders
|
|
||||||
* @param unrealPidFile PID file path for Unreal process cleanup
|
|
||||||
* @param signallingPidFile PID file path for signalling process cleanup
|
|
||||||
*/
|
|
||||||
ProcessManagerService(
|
|
||||||
Properties config,
|
|
||||||
ProcessLauncher processLauncher,
|
|
||||||
String unrealPidFile,
|
|
||||||
String signallingPidFile
|
|
||||||
) {
|
|
||||||
this.config = Objects.requireNonNull(config);
|
|
||||||
this.processLauncher = Objects.requireNonNull(processLauncher);
|
|
||||||
this.unrealPidFile = Objects.requireNonNull(unrealPidFile);
|
|
||||||
this.signallingPidFile = Objects.requireNonNull(signallingPidFile);
|
|
||||||
this.startupHealthcheckEnabled = Boolean.parseBoolean(
|
|
||||||
config.getProperty(
|
|
||||||
"process.startup.healthcheck.enabled",
|
|
||||||
String.valueOf(DEFAULT_STARTUP_HEALTHCHECK_ENABLED)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
this.startupHealthcheckTimeoutMillis = readPositiveLong(
|
|
||||||
config.getProperty("process.startup.healthcheck.timeout.millis"),
|
|
||||||
DEFAULT_STARTUP_HEALTHCHECK_TIMEOUT_MILLIS
|
|
||||||
);
|
|
||||||
this.startupHealthcheckPollMillis = readPositiveLong(
|
|
||||||
config.getProperty("process.startup.healthcheck.poll.millis"),
|
|
||||||
DEFAULT_STARTUP_HEALTHCHECK_POLL_MILLIS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts optional helper processes according to configuration flags.
|
|
||||||
*
|
|
||||||
* @return startup status report for all managed components
|
|
||||||
*/
|
|
||||||
public StartupReport startProcesses() {
|
|
||||||
|
|
||||||
ProcessStartStatus mqttSimulator = startPythonIfEnabled();
|
|
||||||
ProcessStartStatus unreal = startUnrealIfEnabled();
|
|
||||||
|
|
||||||
return new StartupReport(mqttSimulator, unreal);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts the Python MQTT simulator if enabled.
|
|
||||||
*
|
|
||||||
* @return startup status for the simulator component
|
|
||||||
*/
|
|
||||||
private ProcessStartStatus startPythonIfEnabled() {
|
|
||||||
|
|
||||||
boolean enabled = Boolean.parseBoolean(
|
|
||||||
config.getProperty("mqtt_sim.enabled", "false")
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!enabled) {
|
|
||||||
return ProcessStartStatus.disabled("mqtt_sim");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
String script = cleanConfigValue(config.getProperty("mqtt_sim.script"));
|
|
||||||
String python = cleanConfigValue(config.getProperty("python.path"));
|
|
||||||
|
|
||||||
ProcessBuilder pb = new ProcessBuilder(python, script);
|
|
||||||
pb.redirectErrorStream(true);
|
|
||||||
configureMqttSimulatorEnvironment(pb);
|
|
||||||
|
|
||||||
pythonProcess = processLauncher.launch(pb);
|
|
||||||
verifyPythonStartupHealth();
|
|
||||||
|
|
||||||
Logger.info("PROCESS", "MQTT simulator started");
|
|
||||||
return ProcessStartStatus.started("mqtt_sim");
|
|
||||||
} catch (Exception e) {
|
|
||||||
if (e instanceof InterruptedException) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
}
|
|
||||||
Logger.error("PROCESS", "MQTT simulator startup failed", e);
|
|
||||||
return ProcessStartStatus.failed(
|
|
||||||
"mqtt_sim",
|
|
||||||
exceptionMessage(e)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts Unreal-related processes if enabled.
|
|
||||||
*
|
|
||||||
* @return startup status for the Unreal component
|
|
||||||
*/
|
|
||||||
private ProcessStartStatus startUnrealIfEnabled() {
|
|
||||||
|
|
||||||
boolean enabled = Boolean.parseBoolean(config.getProperty("unreal.enabled", "false"));
|
|
||||||
|
|
||||||
if (!enabled) {
|
|
||||||
return ProcessStartStatus.disabled("unreal");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
startUnrealEngine();
|
|
||||||
verifyUnrealStartupHealth();
|
|
||||||
return ProcessStartStatus.started("unreal");
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
if (e instanceof InterruptedException) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
}
|
|
||||||
Logger.error("PROCESS", "Unreal startup failed", e);
|
|
||||||
return ProcessStartStatus.failed(
|
|
||||||
"unreal",
|
|
||||||
exceptionMessage(e)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts the configured Unreal executable script via PowerShell.
|
|
||||||
*
|
|
||||||
* @throws IOException if process startup fails
|
|
||||||
*/
|
|
||||||
private void startUnrealEngine() throws IOException {
|
|
||||||
|
|
||||||
String unrealPsScript =
|
|
||||||
cleanConfigValue(config.getProperty("unreal.executable"));
|
|
||||||
ProcessBuilder pb = new ProcessBuilder(
|
|
||||||
"powershell.exe",
|
|
||||||
"-ExecutionPolicy", "Bypass",
|
|
||||||
"-File",
|
|
||||||
unrealPsScript
|
|
||||||
);
|
|
||||||
|
|
||||||
configureUnrealScriptEnvironment(pb);
|
|
||||||
pb.redirectErrorStream(true);
|
|
||||||
|
|
||||||
unrealProcess = processLauncher.launch(pb);
|
|
||||||
|
|
||||||
//pb.directory(new File(exe).getParentFile());
|
|
||||||
|
|
||||||
Logger.info("PROCESS",
|
|
||||||
"Unreal Engine started " + pb.command());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies that the launched MQTT simulator process stays alive shortly after startup.
|
|
||||||
*
|
|
||||||
* @throws InterruptedException if health-check waiting is interrupted
|
|
||||||
*/
|
|
||||||
private void verifyPythonStartupHealth() throws InterruptedException {
|
|
||||||
if (!startupHealthcheckEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean started = waitUntil(() -> pythonProcess != null && pythonProcess.isAlive());
|
|
||||||
if (!started) {
|
|
||||||
throw new IllegalStateException(
|
|
||||||
"MQTT simulator startup health check failed: process is not running."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies Unreal startup by waiting for both PID files and checking whether those PIDs are alive.
|
|
||||||
*
|
|
||||||
* @throws InterruptedException if health-check waiting is interrupted
|
|
||||||
*/
|
|
||||||
private void verifyUnrealStartupHealth() throws InterruptedException {
|
|
||||||
if (!startupHealthcheckEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean healthy = waitUntil(() ->
|
|
||||||
isPidAlive(readPidFromFile(unrealPidFile))
|
|
||||||
&& isPidAlive(readPidFromFile(signallingPidFile))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!healthy) {
|
|
||||||
throw new IllegalStateException(buildUnrealHealthCheckFailureMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stops all managed processes and attempts cleanup based on known PID files.
|
|
||||||
*/
|
|
||||||
public void shutdown() {
|
|
||||||
|
|
||||||
Logger.info("PROCESS", "External process shutdown started");
|
|
||||||
|
|
||||||
terminateProcess(pythonProcess);
|
|
||||||
terminateProcess(unrealProcess);
|
|
||||||
|
|
||||||
killProcessFromPidFile(unrealPidFile);
|
|
||||||
killProcessFromPidFile(signallingPidFile);
|
|
||||||
|
|
||||||
Logger.info("PROCESS", "External processes stopped");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Terminates a process tree for a tracked process handle.
|
|
||||||
*
|
|
||||||
* @param process process handle to terminate; ignored when {@code null}
|
|
||||||
*/
|
|
||||||
private void terminateProcess(Process process) {
|
|
||||||
|
|
||||||
if (process == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
long pid = process.pid();
|
|
||||||
|
|
||||||
ProcessBuilder pb = new ProcessBuilder(
|
|
||||||
"taskkill",
|
|
||||||
"/PID",
|
|
||||||
String.valueOf(pid),
|
|
||||||
"/T",
|
|
||||||
"/F"
|
|
||||||
);
|
|
||||||
|
|
||||||
processLauncher.launch(pb).waitFor();
|
|
||||||
|
|
||||||
Logger.info("PROCESS",
|
|
||||||
"Process tree terminated -> PID " + pid);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
Logger.error("PROCESS",
|
|
||||||
"Failed to terminate process", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Terminates a process tree using a PID read from a file.
|
|
||||||
*
|
|
||||||
* @param file absolute path to PID file
|
|
||||||
*/
|
|
||||||
private void killProcessFromPidFile(String file) {
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
long pid = Long.parseLong(
|
|
||||||
Files.readString(Path.of(file)).trim()
|
|
||||||
);
|
|
||||||
|
|
||||||
ProcessBuilder pb = new ProcessBuilder(
|
|
||||||
"taskkill",
|
|
||||||
"/PID",
|
|
||||||
String.valueOf(pid),
|
|
||||||
"/T",
|
|
||||||
"/F"
|
|
||||||
);
|
|
||||||
|
|
||||||
processLauncher.launch(pb).waitFor();
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
Logger.error("PROCESS", "PID kill failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the currently tracked Python simulator process.
|
|
||||||
*
|
|
||||||
* @return Python process or {@code null}
|
|
||||||
*/
|
|
||||||
Process getPythonProcess() {
|
|
||||||
return pythonProcess;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the currently tracked Unreal process.
|
|
||||||
*
|
|
||||||
* @return Unreal process or {@code null}
|
|
||||||
*/
|
|
||||||
Process getUnrealProcess() {
|
|
||||||
return unrealProcess;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Waits until a startup condition is satisfied or health-check timeout is reached.
|
|
||||||
*
|
|
||||||
* @param condition startup condition to evaluate
|
|
||||||
* @return {@code true} when condition becomes true before timeout
|
|
||||||
* @throws InterruptedException if waiting is interrupted
|
|
||||||
*/
|
|
||||||
private boolean waitUntil(BooleanSupplier condition) throws InterruptedException {
|
|
||||||
long deadline = System.currentTimeMillis() + startupHealthcheckTimeoutMillis;
|
|
||||||
|
|
||||||
while (System.currentTimeMillis() <= deadline) {
|
|
||||||
if (condition.getAsBoolean()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
sleepUntilNextPoll(deadline);
|
|
||||||
}
|
|
||||||
|
|
||||||
return condition.getAsBoolean();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sleeps until next health-check poll or until timeout deadline, whichever is sooner.
|
|
||||||
*
|
|
||||||
* @param deadlineMillis absolute timeout deadline in milliseconds
|
|
||||||
* @throws InterruptedException if sleep is interrupted
|
|
||||||
*/
|
|
||||||
private void sleepUntilNextPoll(long deadlineMillis) throws InterruptedException {
|
|
||||||
long remaining = deadlineMillis - System.currentTimeMillis();
|
|
||||||
if (remaining <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
long sleepMillis = Math.min(startupHealthcheckPollMillis, remaining);
|
|
||||||
if (sleepMillis > 0) {
|
|
||||||
Thread.sleep(sleepMillis);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads a PID from a file path when present and parseable.
|
|
||||||
*
|
|
||||||
* @param pidFile absolute PID file path
|
|
||||||
* @return parsed PID value or {@code null} when unavailable
|
|
||||||
*/
|
|
||||||
private static Long readPidFromFile(String pidFile) {
|
|
||||||
try {
|
|
||||||
if (pidFile == null || pidFile.isBlank()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
Path path = Path.of(pidFile);
|
|
||||||
if (!Files.isRegularFile(path)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Long.parseLong(Files.readString(path).trim());
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns whether a PID maps to a currently alive process.
|
|
||||||
*
|
|
||||||
* @param pid process identifier
|
|
||||||
* @return {@code true} when the process exists and is alive
|
|
||||||
*/
|
|
||||||
private static boolean isPidAlive(Long pid) {
|
|
||||||
if (pid == null || pid <= 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return ProcessHandle.of(pid)
|
|
||||||
.map(ProcessHandle::isAlive)
|
|
||||||
.orElse(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a detailed health-check failure message for Unreal startup diagnostics.
|
|
||||||
*
|
|
||||||
* @return failure message with PID file and liveness details
|
|
||||||
*/
|
|
||||||
private String buildUnrealHealthCheckFailureMessage() {
|
|
||||||
Long unrealPid = readPidFromFile(unrealPidFile);
|
|
||||||
Long signallingPid = readPidFromFile(signallingPidFile);
|
|
||||||
|
|
||||||
return "Unreal startup health check failed: "
|
|
||||||
+ describePidState("unreal", unrealPidFile, unrealPid)
|
|
||||||
+ "; "
|
|
||||||
+ describePidState("signalling", signallingPidFile, signallingPid);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Describes PID-file and liveness state for one process component.
|
|
||||||
*
|
|
||||||
* @param componentName component identifier
|
|
||||||
* @param pidFile configured PID file path
|
|
||||||
* @param pid parsed PID value
|
|
||||||
* @return human-readable state summary
|
|
||||||
*/
|
|
||||||
private static String describePidState(
|
|
||||||
String componentName,
|
|
||||||
String pidFile,
|
|
||||||
Long pid
|
|
||||||
) {
|
|
||||||
if (pid == null) {
|
|
||||||
return componentName + " PID file missing or invalid (" + pidFile + ")";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isPidAlive(pid)) {
|
|
||||||
return componentName + " PID " + pid + " is not running";
|
|
||||||
}
|
|
||||||
|
|
||||||
return componentName + " PID " + pid + " is running";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes string values loaded from properties and strips optional quotes.
|
|
||||||
*
|
|
||||||
* @param value raw config value
|
|
||||||
* @return normalized value or {@code null}
|
|
||||||
*/
|
|
||||||
private static String cleanConfigValue(String value) {
|
|
||||||
if (value == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String trimmed = value.trim();
|
|
||||||
|
|
||||||
if ((trimmed.startsWith("\"") && trimmed.endsWith("\""))
|
|
||||||
|| (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
||||||
return trimmed.substring(1, trimmed.length() - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a compact message for startup exception reporting.
|
|
||||||
*
|
|
||||||
* @param exception startup exception
|
|
||||||
* @return compact exception details
|
|
||||||
*/
|
|
||||||
private static String exceptionMessage(Exception exception) {
|
|
||||||
String type = exception.getClass().getSimpleName();
|
|
||||||
String message = exception.getMessage();
|
|
||||||
|
|
||||||
if (message == null || message.isBlank()) {
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
|
|
||||||
return type + ": " + message;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses an optional positive long value and falls back when absent or invalid.
|
|
||||||
*
|
|
||||||
* @param rawValue raw config value
|
|
||||||
* @param fallback fallback value
|
|
||||||
* @return parsed positive value or fallback
|
|
||||||
*/
|
|
||||||
private static long readPositiveLong(String rawValue, long fallback) {
|
|
||||||
String value = cleanConfigValue(rawValue);
|
|
||||||
if (value == null || value.isBlank()) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
long parsed = Long.parseLong(value);
|
|
||||||
return parsed > 0 ? parsed : fallback;
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Passes optional simulator settings from app config to the Python process environment.
|
|
||||||
*
|
|
||||||
* @param processBuilder simulator process builder
|
|
||||||
*/
|
|
||||||
private void configureMqttSimulatorEnvironment(ProcessBuilder processBuilder) {
|
|
||||||
putEnvFromConfig(processBuilder, "mqtt_sim.broker", "MQTT_SIM_BROKER");
|
|
||||||
putEnvFromConfig(processBuilder, "mqtt_sim.port", "MQTT_SIM_PORT");
|
|
||||||
putEnvFromConfig(processBuilder, "mqtt_sim.topic", "MQTT_SIM_TOPIC");
|
|
||||||
putEnvFromConfig(processBuilder, "mqtt_sim.username", "MQTT_SIM_USERNAME");
|
|
||||||
putEnvFromConfig(processBuilder, "mqtt_sim.password", "MQTT_SIM_PASSWORD");
|
|
||||||
putEnvFromConfig(processBuilder, "mqtt_sim.qos", "MQTT_SIM_QOS");
|
|
||||||
putEnvFromConfig(processBuilder, "mqtt_sim.interval_seconds", "MQTT_SIM_INTERVAL_SECONDS");
|
|
||||||
putEnvFromConfig(processBuilder, "mqtt_sim.start_id", "MQTT_SIM_START_ID");
|
|
||||||
putEnvFromConfig(processBuilder, "mqtt_sim.client_id", "MQTT_SIM_CLIENT_ID");
|
|
||||||
putEnvFromConfig(processBuilder, "mqtt_sim.log_file", "MQTT_SIM_LOG_FILE");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Passes optional Unreal launcher settings from app config to the PowerShell process environment.
|
|
||||||
*
|
|
||||||
* @param processBuilder Unreal process builder
|
|
||||||
*/
|
|
||||||
private void configureUnrealScriptEnvironment(ProcessBuilder processBuilder) {
|
|
||||||
putEnvFromConfig(processBuilder, "unreal.target.executable", "VGA_UE_EXE");
|
|
||||||
putEnvFromConfig(processBuilder, "unreal.target.args", "VGA_UE_ARGS");
|
|
||||||
putEnvFromConfig(processBuilder, "unreal.target.working_dir", "VGA_UE_WORKDIR");
|
|
||||||
putEnvFromConfig(processBuilder, "unreal.signalling_server.script", "VGA_SIGNALLING_BAT");
|
|
||||||
putEnvFromConfig(processBuilder, "unreal.startup.delay.seconds", "VGA_SIGNALLING_DELAY");
|
|
||||||
|
|
||||||
String pidDir = cleanConfigValue(config.getProperty("unreal.pid.dir"));
|
|
||||||
if (pidDir == null || pidDir.isBlank()) {
|
|
||||||
try {
|
|
||||||
Path parent = Path.of(unrealPidFile).getParent();
|
|
||||||
if (parent != null) {
|
|
||||||
pidDir = parent.toString();
|
|
||||||
}
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
pidDir = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pidDir != null && !pidDir.isBlank()) {
|
|
||||||
processBuilder.environment().put("VGA_PID_DIR", pidDir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a config value to process environment when present.
|
|
||||||
*
|
|
||||||
* @param processBuilder target process builder
|
|
||||||
* @param configKey application properties key
|
|
||||||
* @param envName environment variable name
|
|
||||||
*/
|
|
||||||
private void putEnvFromConfig(
|
|
||||||
ProcessBuilder processBuilder,
|
|
||||||
String configKey,
|
|
||||||
String envName
|
|
||||||
) {
|
|
||||||
String value = cleanConfigValue(config.getProperty(configKey));
|
|
||||||
if (value != null && !value.isBlank()) {
|
|
||||||
processBuilder.environment().put(envName, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
package vassistent.service;
|
|
||||||
|
|
||||||
import vassistent.model.DatabaseEntry;
|
|
||||||
import vassistent.model.RatioPoint;
|
|
||||||
import vassistent.util.Logger;
|
|
||||||
|
|
||||||
import java.sql.*;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes ratios and time-series aggregates from persisted binary events.
|
|
||||||
*/
|
|
||||||
public class StatisticsService {
|
|
||||||
private final DataPersistenceService persistenceService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a statistics service for persisted event data.
|
|
||||||
*
|
|
||||||
* @param persistenceService persistence service to query raw events
|
|
||||||
*/
|
|
||||||
public StatisticsService(DataPersistenceService persistenceService) {
|
|
||||||
this.persistenceService = persistenceService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes the average of the newest {@code lastN} binary values.
|
|
||||||
*
|
|
||||||
* @param lastN number of newest entries to include
|
|
||||||
* @return ratio in range {@code [0, 1]} or {@code 0.0} when unavailable
|
|
||||||
*/
|
|
||||||
public double getRatio(int lastN) {
|
|
||||||
if (lastN <= 0) {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
String query = """
|
|
||||||
SELECT AVG(value)
|
|
||||||
FROM (
|
|
||||||
SELECT value
|
|
||||||
FROM binary_event
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT ?
|
|
||||||
)
|
|
||||||
""";
|
|
||||||
|
|
||||||
try (Connection c = DriverManager.getConnection(persistenceService.getDbUrl()); PreparedStatement ps = c.prepareStatement(query)) {
|
|
||||||
ps.setInt(1, lastN);
|
|
||||||
|
|
||||||
ResultSet rs = ps.executeQuery();
|
|
||||||
if (rs.next()) {
|
|
||||||
return rs.getDouble(1);
|
|
||||||
}
|
|
||||||
} catch (SQLException e) {
|
|
||||||
Logger.error("Evaluation", "Couldn't get ratio.", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes rolling averages for chart rendering.
|
|
||||||
*
|
|
||||||
* The rolling window includes the current entry and up to {@code windowSize - 1}
|
|
||||||
* previous entries.
|
|
||||||
*
|
|
||||||
* @param windowSize number of entries considered per rolling average window
|
|
||||||
* @return chronological list of ratio points
|
|
||||||
*/
|
|
||||||
public List<RatioPoint> getLastNAverages(int windowSize) {
|
|
||||||
|
|
||||||
if (windowSize <= 0) {
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
|
|
||||||
List<DatabaseEntry> entries = persistenceService.getLastEntries(windowSize + 1);
|
|
||||||
|
|
||||||
List<RatioPoint> result = new ArrayList<>();
|
|
||||||
|
|
||||||
if (entries.isEmpty()) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
double rollingSum = 0.0;
|
|
||||||
int windowStart = 0;
|
|
||||||
|
|
||||||
for (int i = 0; i < entries.size(); i++) {
|
|
||||||
rollingSum += entries.get(i).getValue();
|
|
||||||
|
|
||||||
if (i - windowStart + 1 > windowSize) {
|
|
||||||
rollingSum -= entries.get(windowStart).getValue();
|
|
||||||
windowStart++;
|
|
||||||
}
|
|
||||||
|
|
||||||
int windowCount = i - windowStart + 1;
|
|
||||||
double avg = rollingSum / windowCount;
|
|
||||||
|
|
||||||
|
|
||||||
result.add(new RatioPoint(
|
|
||||||
entries.get(i).getTimestamp(),
|
|
||||||
avg
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.info("STATISTICS",
|
|
||||||
"Computed averages: " + result.size());
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,417 +0,0 @@
|
|||||||
package vassistent.ui;
|
|
||||||
|
|
||||||
import vassistent.util.ConfigLoader;
|
|
||||||
|
|
||||||
import javax.swing.*;
|
|
||||||
import java.awt.*;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Properties;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main Swing frame containing streaming and dashboard tabs plus status indicators.
|
|
||||||
*/
|
|
||||||
public class AppWindow extends JFrame {
|
|
||||||
|
|
||||||
private static final int EXPANDED_DASHBOARD_WIDTH = 460;
|
|
||||||
private static final int EXPANDED_DIVIDER_SIZE = 8;
|
|
||||||
|
|
||||||
private static final Properties config =
|
|
||||||
ConfigLoader.loadProperties(
|
|
||||||
"config/application.properties"
|
|
||||||
);
|
|
||||||
|
|
||||||
private PixelStreamingView streamingView;
|
|
||||||
private DashboardView dashboardView;
|
|
||||||
private JTabbedPane tabs;
|
|
||||||
private JSplitPane driveSplitPane;
|
|
||||||
private JPanel dashboardCard;
|
|
||||||
private JToggleButton dashboardToggleButton;
|
|
||||||
|
|
||||||
private JLabel mqttStatusLabel;
|
|
||||||
private JLabel problemLevelLabel;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates and initializes the main application window and its child views.
|
|
||||||
*/
|
|
||||||
public AppWindow() {
|
|
||||||
|
|
||||||
setTitle("Virtueller Gesundheitsassistent");
|
|
||||||
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
|
|
||||||
setMinimumSize(new Dimension(1280, 720));
|
|
||||||
setSize(1700, 900);
|
|
||||||
setLocationRelativeTo(null);
|
|
||||||
getContentPane().setBackground(new Color(15, 19, 26));
|
|
||||||
|
|
||||||
setLayout(new BorderLayout(12, 12));
|
|
||||||
|
|
||||||
String streamUrl =
|
|
||||||
normalizeConfigValue(
|
|
||||||
config.getProperty("streaming.url", "http://localhost")
|
|
||||||
);
|
|
||||||
|
|
||||||
streamingView = new PixelStreamingView(
|
|
||||||
streamUrl,
|
|
||||||
false,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
dashboardView = new DashboardView();
|
|
||||||
|
|
||||||
add(createHeaderPanel(), BorderLayout.NORTH);
|
|
||||||
tabs = createTabPane();
|
|
||||||
add(tabs, BorderLayout.CENTER);
|
|
||||||
add(createStatusBar(), BorderLayout.SOUTH);
|
|
||||||
SwingUtilities.invokeLater(() -> setDashboardVisible(false));
|
|
||||||
|
|
||||||
Font uiFont = new Font("Segoe UI", Font.PLAIN, 16);
|
|
||||||
UIManager.put("Label.font", uiFont);
|
|
||||||
UIManager.put("TabbedPane.font", uiFont);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the tabbed pane that hosts streaming and dashboard views.
|
|
||||||
*
|
|
||||||
* @return configured tabbed pane
|
|
||||||
*/
|
|
||||||
private JTabbedPane createTabPane() {
|
|
||||||
|
|
||||||
JTabbedPane tabs = new JTabbedPane();
|
|
||||||
tabs.setBorder(BorderFactory.createEmptyBorder(0, 10, 6, 10));
|
|
||||||
tabs.setFocusable(false);
|
|
||||||
tabs.putClientProperty("JTabbedPane.tabHeight", 38);
|
|
||||||
tabs.putClientProperty("JTabbedPane.hideTabAreaWithOneTab", true);
|
|
||||||
tabs.addTab("Drive View", createDriveViewPanel());
|
|
||||||
|
|
||||||
return tabs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the center-display optimized drive panel.
|
|
||||||
*
|
|
||||||
* @return drive panel with large assistant stream and compact dashboard
|
|
||||||
*/
|
|
||||||
private JPanel createDriveViewPanel() {
|
|
||||||
JPanel drivePanel = new JPanel(new BorderLayout());
|
|
||||||
drivePanel.setOpaque(false);
|
|
||||||
drivePanel.setBorder(BorderFactory.createEmptyBorder(6, 0, 0, 0));
|
|
||||||
|
|
||||||
JPanel streamCard = createCardPanel();
|
|
||||||
streamCard.setLayout(new BorderLayout(0, 12));
|
|
||||||
streamCard.add(
|
|
||||||
createSectionHeader(
|
|
||||||
"Virtual Health Assistant",
|
|
||||||
null
|
|
||||||
),
|
|
||||||
BorderLayout.NORTH
|
|
||||||
);
|
|
||||||
streamCard.add(streamingView, BorderLayout.CENTER);
|
|
||||||
|
|
||||||
dashboardCard = createCardPanel();
|
|
||||||
dashboardCard.setLayout(new BorderLayout(0, 12));
|
|
||||||
dashboardCard.add(
|
|
||||||
createSectionHeader(
|
|
||||||
"Health Overview",
|
|
||||||
"Live trend and current risk level"
|
|
||||||
),
|
|
||||||
BorderLayout.NORTH
|
|
||||||
);
|
|
||||||
dashboardCard.add(dashboardView, BorderLayout.CENTER);
|
|
||||||
dashboardCard.setPreferredSize(new Dimension(EXPANDED_DASHBOARD_WIDTH, 0));
|
|
||||||
dashboardCard.setMinimumSize(new Dimension(380, 0));
|
|
||||||
|
|
||||||
driveSplitPane =
|
|
||||||
new JSplitPane(
|
|
||||||
JSplitPane.HORIZONTAL_SPLIT,
|
|
||||||
streamCard,
|
|
||||||
dashboardCard
|
|
||||||
);
|
|
||||||
|
|
||||||
driveSplitPane.setBorder(null);
|
|
||||||
driveSplitPane.setOpaque(false);
|
|
||||||
driveSplitPane.setContinuousLayout(true);
|
|
||||||
driveSplitPane.setOneTouchExpandable(false);
|
|
||||||
driveSplitPane.setDividerSize(EXPANDED_DIVIDER_SIZE);
|
|
||||||
driveSplitPane.setResizeWeight(0.72);
|
|
||||||
driveSplitPane.setDividerLocation(0.72);
|
|
||||||
|
|
||||||
drivePanel.add(driveSplitPane, BorderLayout.CENTER);
|
|
||||||
|
|
||||||
return drivePanel;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a top banner with project title and context.
|
|
||||||
*
|
|
||||||
* @return header panel
|
|
||||||
*/
|
|
||||||
private JPanel createHeaderPanel() {
|
|
||||||
JPanel header = new JPanel(new BorderLayout(12, 0));
|
|
||||||
header.setBorder(BorderFactory.createEmptyBorder(12, 16, 0, 16));
|
|
||||||
header.setOpaque(false);
|
|
||||||
|
|
||||||
JLabel title = new JLabel("Virtueller Gesundheitsassistent");
|
|
||||||
title.setFont(new Font("Segoe UI Semibold", Font.PLAIN, 30));
|
|
||||||
title.setForeground(new Color(238, 244, 255));
|
|
||||||
|
|
||||||
JPanel textStack = new JPanel();
|
|
||||||
textStack.setLayout(new BoxLayout(textStack, BoxLayout.Y_AXIS));
|
|
||||||
textStack.setOpaque(false);
|
|
||||||
title.setAlignmentX(Component.LEFT_ALIGNMENT);
|
|
||||||
textStack.add(title);
|
|
||||||
textStack.add(Box.createVerticalStrut(4));
|
|
||||||
|
|
||||||
header.add(textStack, BorderLayout.WEST);
|
|
||||||
header.add(createDashboardToggleButton(), BorderLayout.EAST);
|
|
||||||
|
|
||||||
return header;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the status bar with MQTT and problem-level indicators.
|
|
||||||
*
|
|
||||||
* @return configured status bar panel
|
|
||||||
*/
|
|
||||||
private JPanel createStatusBar() {
|
|
||||||
|
|
||||||
JPanel statusBar = new JPanel(new FlowLayout(FlowLayout.LEFT, 12, 8));
|
|
||||||
statusBar.setBackground(new Color(13, 16, 23));
|
|
||||||
|
|
||||||
statusBar.setBorder(BorderFactory.createCompoundBorder(
|
|
||||||
BorderFactory.createMatteBorder(1, 0, 0, 0, new Color(54, 64, 78)),
|
|
||||||
BorderFactory.createEmptyBorder(6, 12, 8, 12)
|
|
||||||
));
|
|
||||||
|
|
||||||
mqttStatusLabel = createStatusChip("MQTT: Disconnected");
|
|
||||||
problemLevelLabel = createStatusChip("Problem: NONE");
|
|
||||||
|
|
||||||
statusBar.add(mqttStatusLabel);
|
|
||||||
statusBar.add(problemLevelLabel);
|
|
||||||
|
|
||||||
return statusBar;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the MQTT connection status label.
|
|
||||||
*
|
|
||||||
* @param connected whether MQTT is currently connected
|
|
||||||
*/
|
|
||||||
public void updateMqttStatus(boolean connected) {
|
|
||||||
mqttStatusLabel.setText("MQTT: "
|
|
||||||
+ (connected ? "Connected" : "Disconnected"));
|
|
||||||
mqttStatusLabel.setBackground(
|
|
||||||
connected ? new Color(30, 101, 70) : new Color(108, 47, 47)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the currently displayed problem level label.
|
|
||||||
*
|
|
||||||
* @param level problem level name to display
|
|
||||||
*/
|
|
||||||
public void updateProblemLevel(String level) {
|
|
||||||
problemLevelLabel.setText("Problem: " + level);
|
|
||||||
problemLevelLabel.setBackground(getProblemChipColor(level));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the dashboard view component.
|
|
||||||
*
|
|
||||||
* @return dashboard view
|
|
||||||
*/
|
|
||||||
public DashboardView getDashboardView() {
|
|
||||||
return dashboardView;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the pixel streaming view component.
|
|
||||||
*
|
|
||||||
* @return streaming view
|
|
||||||
*/
|
|
||||||
public PixelStreamingView getStreamingView() {
|
|
||||||
return streamingView;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the main tabbed pane.
|
|
||||||
*
|
|
||||||
* @return tab container
|
|
||||||
*/
|
|
||||||
public JTabbedPane getTabs() {
|
|
||||||
return tabs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a panel with a subtle border and dark background.
|
|
||||||
*
|
|
||||||
* @return styled card panel
|
|
||||||
*/
|
|
||||||
private JPanel createCardPanel() {
|
|
||||||
JPanel card = new JPanel();
|
|
||||||
card.setOpaque(true);
|
|
||||||
card.setBackground(new Color(24, 31, 41));
|
|
||||||
card.setBorder(BorderFactory.createCompoundBorder(
|
|
||||||
BorderFactory.createLineBorder(new Color(61, 74, 92)),
|
|
||||||
BorderFactory.createEmptyBorder(12, 12, 12, 12)
|
|
||||||
));
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a section heading with title and subtitle text.
|
|
||||||
*
|
|
||||||
* @param title section title
|
|
||||||
* @param subtitle section subtitle
|
|
||||||
* @return heading panel
|
|
||||||
*/
|
|
||||||
private JPanel createSectionHeader(String title, String subtitle) {
|
|
||||||
JPanel panel = new JPanel();
|
|
||||||
panel.setOpaque(false);
|
|
||||||
panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
|
|
||||||
|
|
||||||
JLabel titleLabel = new JLabel(title);
|
|
||||||
titleLabel.setAlignmentX(Component.LEFT_ALIGNMENT);
|
|
||||||
titleLabel.setFont(new Font("Segoe UI Semibold", Font.PLAIN, 20));
|
|
||||||
titleLabel.setForeground(new Color(233, 241, 255));
|
|
||||||
|
|
||||||
JLabel subtitleLabel = new JLabel(subtitle);
|
|
||||||
subtitleLabel.setAlignmentX(Component.LEFT_ALIGNMENT);
|
|
||||||
subtitleLabel.setFont(new Font("Segoe UI", Font.PLAIN, 13));
|
|
||||||
subtitleLabel.setForeground(new Color(143, 159, 182));
|
|
||||||
|
|
||||||
panel.add(titleLabel);
|
|
||||||
panel.add(Box.createVerticalStrut(2));
|
|
||||||
panel.add(subtitleLabel);
|
|
||||||
|
|
||||||
return panel;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates one status label used in the bottom status bar.
|
|
||||||
*
|
|
||||||
* @param text initial status text
|
|
||||||
* @return styled status label
|
|
||||||
*/
|
|
||||||
private JLabel createStatusChip(String text) {
|
|
||||||
JLabel label = new JLabel(text);
|
|
||||||
label.setOpaque(true);
|
|
||||||
label.setFont(new Font("Segoe UI Semibold", Font.PLAIN, 14));
|
|
||||||
label.setForeground(new Color(237, 244, 255));
|
|
||||||
label.setBackground(new Color(56, 66, 83));
|
|
||||||
label.setBorder(BorderFactory.createEmptyBorder(5, 12, 5, 12));
|
|
||||||
return label;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a status chip background color for the current problem level.
|
|
||||||
*
|
|
||||||
* @param level problem level name
|
|
||||||
* @return matching background color
|
|
||||||
*/
|
|
||||||
private Color getProblemChipColor(String level) {
|
|
||||||
if (level == null) {
|
|
||||||
return new Color(56, 66, 83);
|
|
||||||
}
|
|
||||||
|
|
||||||
String normalized = level.trim().toUpperCase(Locale.ROOT);
|
|
||||||
|
|
||||||
switch (normalized) {
|
|
||||||
case "WARNING":
|
|
||||||
return new Color(134, 104, 29);
|
|
||||||
case "HIGH":
|
|
||||||
return new Color(168, 92, 31);
|
|
||||||
case "DISASTER":
|
|
||||||
return new Color(140, 49, 49);
|
|
||||||
default:
|
|
||||||
return new Color(46, 99, 61);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the button used to toggle dashboard visibility.
|
|
||||||
*
|
|
||||||
* @return configured toggle button
|
|
||||||
*/
|
|
||||||
private JToggleButton createDashboardToggleButton() {
|
|
||||||
dashboardToggleButton = new JToggleButton("Dashboard einblenden");
|
|
||||||
dashboardToggleButton.setFocusable(false);
|
|
||||||
dashboardToggleButton.setFont(new Font("Segoe UI Semibold", Font.PLAIN, 14));
|
|
||||||
dashboardToggleButton.setForeground(new Color(233, 241, 255));
|
|
||||||
dashboardToggleButton.setBackground(new Color(39, 77, 57));
|
|
||||||
dashboardToggleButton.setBorder(BorderFactory.createEmptyBorder(8, 14, 8, 14));
|
|
||||||
dashboardToggleButton.addActionListener(
|
|
||||||
event -> setDashboardVisible(dashboardToggleButton.isSelected())
|
|
||||||
);
|
|
||||||
return dashboardToggleButton;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows or hides the dashboard side panel in drive view.
|
|
||||||
*
|
|
||||||
* @param visible whether dashboard should be visible
|
|
||||||
*/
|
|
||||||
private void setDashboardVisible(boolean visible) {
|
|
||||||
if (driveSplitPane == null || dashboardCard == null || dashboardToggleButton == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dashboardCard.setVisible(visible);
|
|
||||||
driveSplitPane.setDividerSize(visible ? EXPANDED_DIVIDER_SIZE : 0);
|
|
||||||
|
|
||||||
if (visible) {
|
|
||||||
dashboardToggleButton.setSelected(true);
|
|
||||||
dashboardToggleButton.setText("Dashboard ausblenden");
|
|
||||||
dashboardToggleButton.setBackground(new Color(69, 86, 109));
|
|
||||||
|
|
||||||
SwingUtilities.invokeLater(() -> {
|
|
||||||
int width = driveSplitPane.getWidth();
|
|
||||||
if (width <= 0) {
|
|
||||||
driveSplitPane.setDividerLocation(0.72);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
int dividerLocation = Math.max(
|
|
||||||
(int) (width * 0.55),
|
|
||||||
width - EXPANDED_DASHBOARD_WIDTH
|
|
||||||
);
|
|
||||||
driveSplitPane.setDividerLocation(dividerLocation);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
dashboardToggleButton.setSelected(false);
|
|
||||||
dashboardToggleButton.setText("Dashboard einblenden");
|
|
||||||
dashboardToggleButton.setBackground(new Color(39, 77, 57));
|
|
||||||
|
|
||||||
SwingUtilities.invokeLater(() -> {
|
|
||||||
int width = driveSplitPane.getWidth();
|
|
||||||
if (width > 0) {
|
|
||||||
driveSplitPane.setDividerLocation(width);
|
|
||||||
} else {
|
|
||||||
driveSplitPane.setDividerLocation(1.0d);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
driveSplitPane.revalidate();
|
|
||||||
driveSplitPane.repaint();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trims a config value and strips optional wrapping quotes.
|
|
||||||
*
|
|
||||||
* @param value raw config value
|
|
||||||
* @return normalized value or {@code null}
|
|
||||||
*/
|
|
||||||
private static String normalizeConfigValue(String value) {
|
|
||||||
if (value == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String trimmed = value.trim();
|
|
||||||
|
|
||||||
if ((trimmed.startsWith("\"") && trimmed.endsWith("\""))
|
|
||||||
|| (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
||||||
return trimmed.substring(1, trimmed.length() - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
package vassistent.ui;
|
|
||||||
|
|
||||||
import org.jfree.chart.ChartFactory;
|
|
||||||
import org.jfree.chart.ChartPanel;
|
|
||||||
import org.jfree.chart.JFreeChart;
|
|
||||||
import org.jfree.chart.axis.DateAxis;
|
|
||||||
import org.jfree.chart.axis.NumberAxis;
|
|
||||||
import org.jfree.chart.axis.NumberTickUnit;
|
|
||||||
import org.jfree.chart.plot.ValueMarker;
|
|
||||||
import org.jfree.chart.plot.XYPlot;
|
|
||||||
import org.jfree.chart.renderer.xy.XYLineAndShapeRenderer;
|
|
||||||
import org.jfree.chart.ui.Layer;
|
|
||||||
import org.jfree.chart.ui.RectangleAnchor;
|
|
||||||
import org.jfree.chart.ui.TextAnchor;
|
|
||||||
import org.jfree.data.time.Millisecond;
|
|
||||||
import org.jfree.data.time.TimeSeries;
|
|
||||||
import org.jfree.data.time.TimeSeriesCollection;
|
|
||||||
import vassistent.model.RatioPoint;
|
|
||||||
|
|
||||||
import javax.swing.*;
|
|
||||||
import java.awt.*;
|
|
||||||
import java.text.SimpleDateFormat;
|
|
||||||
import java.time.ZoneId;
|
|
||||||
import java.util.Date;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dashboard panel that renders the ratio history chart and current level indicator.
|
|
||||||
*/
|
|
||||||
public class DashboardView extends JPanel {
|
|
||||||
|
|
||||||
private TimeSeries series;
|
|
||||||
private ProblemLevelBar levelBar;
|
|
||||||
|
|
||||||
private JButton reloadPixelStreamingViewButton;
|
|
||||||
private JButton openFullscreenButton;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the dashboard panel with problem-level bar and time-series chart.
|
|
||||||
*/
|
|
||||||
public DashboardView() {
|
|
||||||
setLayout(new BorderLayout(0, 12));
|
|
||||||
setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4));
|
|
||||||
setOpaque(false);
|
|
||||||
|
|
||||||
JPanel card = new JPanel(new BorderLayout(0, 14));
|
|
||||||
card.setBackground(new Color(22, 28, 37));
|
|
||||||
card.setOpaque(true);
|
|
||||||
card.setBorder(BorderFactory.createCompoundBorder(
|
|
||||||
BorderFactory.createLineBorder(
|
|
||||||
new Color(58, 72, 90)
|
|
||||||
),
|
|
||||||
BorderFactory.createEmptyBorder(14, 14, 14, 14)
|
|
||||||
));
|
|
||||||
|
|
||||||
// ---------- TOP: Problem Level ----------
|
|
||||||
JPanel topWrapper = new JPanel(new BorderLayout(0, 8));
|
|
||||||
topWrapper.setBorder(BorderFactory.createEmptyBorder(0, 0, 8, 0));
|
|
||||||
topWrapper.setOpaque(false);
|
|
||||||
|
|
||||||
JLabel topLabel = new JLabel("Current Risk Level");
|
|
||||||
topLabel.setFont(new Font("Segoe UI Semibold", Font.PLAIN, 24));
|
|
||||||
topLabel.setForeground(new Color(230, 237, 249));
|
|
||||||
topWrapper.add(topLabel, BorderLayout.NORTH);
|
|
||||||
|
|
||||||
levelBar = new ProblemLevelBar();
|
|
||||||
topWrapper.add(levelBar, BorderLayout.CENTER);
|
|
||||||
|
|
||||||
card.add(topWrapper, BorderLayout.NORTH);
|
|
||||||
|
|
||||||
// ---------- CENTER: Chart ----------
|
|
||||||
|
|
||||||
series = new TimeSeries("Ratio");
|
|
||||||
|
|
||||||
TimeSeriesCollection dataset = new TimeSeriesCollection(series);
|
|
||||||
|
|
||||||
JFreeChart chart = ChartFactory.createTimeSeriesChart(
|
|
||||||
"Ratio Verlauf",
|
|
||||||
"Zeit",
|
|
||||||
"Durchschnitt",
|
|
||||||
dataset
|
|
||||||
);
|
|
||||||
|
|
||||||
XYPlot plot = chart.getXYPlot();
|
|
||||||
chart.setBackgroundPaint(new Color(22, 28, 37));
|
|
||||||
chart.getTitle().setPaint(new Color(222, 231, 245));
|
|
||||||
chart.getTitle().setFont(new Font("Segoe UI Semibold", Font.PLAIN, 16));
|
|
||||||
|
|
||||||
long tenMinutes = 10 * 60 * 1000L;
|
|
||||||
|
|
||||||
DateAxis domainAxis = (DateAxis) plot.getDomainAxis();
|
|
||||||
domainAxis.setFixedAutoRange(tenMinutes);
|
|
||||||
domainAxis.setAutoRange(true);
|
|
||||||
domainAxis.setDateFormatOverride(new SimpleDateFormat("HH:mm:ss"));
|
|
||||||
domainAxis.setLabelPaint(new Color(170, 185, 208));
|
|
||||||
domainAxis.setTickLabelPaint(new Color(170, 185, 208));
|
|
||||||
domainAxis.setTickMarkPaint(new Color(89, 105, 128));
|
|
||||||
|
|
||||||
XYLineAndShapeRenderer renderer =
|
|
||||||
(XYLineAndShapeRenderer) plot.getRenderer();
|
|
||||||
|
|
||||||
renderer.setDefaultShapesVisible(false);
|
|
||||||
renderer.setSeriesPaint(0, new Color(67, 175, 255));
|
|
||||||
renderer.setSeriesStroke(0,
|
|
||||||
new BasicStroke(
|
|
||||||
3.0f,
|
|
||||||
BasicStroke.CAP_ROUND,
|
|
||||||
BasicStroke.JOIN_ROUND
|
|
||||||
));
|
|
||||||
|
|
||||||
// Threshold Lines
|
|
||||||
addThresholdLine(plot, 0.5, "Warning");
|
|
||||||
addThresholdLine(plot, 0.8, "High");
|
|
||||||
addThresholdLine(plot, 0.9, "Disaster");
|
|
||||||
|
|
||||||
chart.getXYPlot().getRangeAxis().setRange(0.0, 1.02);
|
|
||||||
NumberAxis rangeAxis = (NumberAxis) chart.getXYPlot().getRangeAxis();
|
|
||||||
rangeAxis.setTickUnit(new NumberTickUnit(0.1));
|
|
||||||
rangeAxis.setLabelPaint(new Color(170, 185, 208));
|
|
||||||
rangeAxis.setTickLabelPaint(new Color(170, 185, 208));
|
|
||||||
rangeAxis.setTickMarkPaint(new Color(89, 105, 128));
|
|
||||||
|
|
||||||
plot.setBackgroundPaint(new Color(16, 20, 27));
|
|
||||||
plot.setOutlineVisible(false);
|
|
||||||
plot.setRangeGridlinePaint(new Color(89, 105, 128, 120));
|
|
||||||
plot.setDomainGridlinePaint(new Color(89, 105, 128, 120));
|
|
||||||
|
|
||||||
chart.setAntiAlias(true);
|
|
||||||
chart.setTextAntiAlias(true);
|
|
||||||
|
|
||||||
ChartPanel chartPanel = new ChartPanel(chart);
|
|
||||||
chartPanel.setOpaque(false);
|
|
||||||
chartPanel.setBackground(new Color(16, 20, 27));
|
|
||||||
chartPanel.setBorder(BorderFactory.createCompoundBorder(
|
|
||||||
BorderFactory.createLineBorder(new Color(58, 72, 90)),
|
|
||||||
BorderFactory.createEmptyBorder(8, 8, 8, 8)
|
|
||||||
));
|
|
||||||
|
|
||||||
card.add(chartPanel, BorderLayout.CENTER);
|
|
||||||
|
|
||||||
add(card, BorderLayout.CENTER);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces chart data with the given ratio points.
|
|
||||||
*
|
|
||||||
* @param points ratio points ordered by time
|
|
||||||
*/
|
|
||||||
public void updateChart(List<RatioPoint> points) {
|
|
||||||
if (points == null || points.isEmpty())
|
|
||||||
return;
|
|
||||||
|
|
||||||
series.clear();
|
|
||||||
|
|
||||||
for (RatioPoint point : points) {
|
|
||||||
|
|
||||||
series.addOrUpdate(
|
|
||||||
new Millisecond(
|
|
||||||
Date.from(
|
|
||||||
point.getTimestamp()
|
|
||||||
.atZone(ZoneId.systemDefault())
|
|
||||||
.toInstant()
|
|
||||||
)
|
|
||||||
),
|
|
||||||
point.getRatio()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the visual problem level bar using the latest ratio.
|
|
||||||
*
|
|
||||||
* @param ratio latest computed ratio
|
|
||||||
*/
|
|
||||||
public void updateProblemLevel(double ratio) {
|
|
||||||
levelBar.setRatio(ratio);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the button intended to reload the pixel streaming view.
|
|
||||||
*
|
|
||||||
* @return reload button (may be {@code null} until implemented)
|
|
||||||
*/
|
|
||||||
public JButton getReloadPixelStreamingViewButton() {
|
|
||||||
return reloadPixelStreamingViewButton;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the button intended to open fullscreen mode.
|
|
||||||
*
|
|
||||||
* @return fullscreen button (may be {@code null} until implemented)
|
|
||||||
*/
|
|
||||||
public JButton getOpenFullscreenButton() {
|
|
||||||
return openFullscreenButton;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a horizontal threshold marker with label to the chart.
|
|
||||||
*
|
|
||||||
* @param plot target plot
|
|
||||||
* @param value y-axis value for the threshold
|
|
||||||
* @param label label rendered near the marker
|
|
||||||
*/
|
|
||||||
private void addThresholdLine(XYPlot plot, double value, String label) {
|
|
||||||
|
|
||||||
ValueMarker marker = new ValueMarker(value);
|
|
||||||
|
|
||||||
marker.setPaint(getThresholdColor(label));
|
|
||||||
|
|
||||||
float[] dash = {6.0f, 6.0f};
|
|
||||||
marker.setStroke(new BasicStroke(
|
|
||||||
1f,
|
|
||||||
BasicStroke.CAP_BUTT,
|
|
||||||
BasicStroke.JOIN_MITER,
|
|
||||||
10.0f,
|
|
||||||
dash,
|
|
||||||
0.0f
|
|
||||||
));
|
|
||||||
|
|
||||||
marker.setLabel(label);
|
|
||||||
marker.setLabelFont(new Font("Segoe UI Semibold", Font.PLAIN, 12));
|
|
||||||
marker.setLabelPaint(new Color(188, 203, 223));
|
|
||||||
|
|
||||||
marker.setLabelAnchor(RectangleAnchor.RIGHT);
|
|
||||||
marker.setLabelTextAnchor(TextAnchor.TOP_RIGHT);
|
|
||||||
|
|
||||||
plot.addRangeMarker(marker, Layer.BACKGROUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a distinct marker color for each threshold level.
|
|
||||||
*
|
|
||||||
* @param label threshold label
|
|
||||||
* @return translucent marker color
|
|
||||||
*/
|
|
||||||
private Color getThresholdColor(String label) {
|
|
||||||
switch (label) {
|
|
||||||
case "Warning":
|
|
||||||
return new Color(255, 210, 69, 165);
|
|
||||||
case "High":
|
|
||||||
return new Color(255, 153, 65, 170);
|
|
||||||
case "Disaster":
|
|
||||||
return new Color(255, 92, 92, 175);
|
|
||||||
default:
|
|
||||||
return new Color(168, 178, 194, 140);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
package vassistent.ui;
|
|
||||||
|
|
||||||
import org.cef.CefApp;
|
|
||||||
import org.cef.CefClient;
|
|
||||||
import org.cef.CefSettings;
|
|
||||||
import org.cef.browser.CefBrowser;
|
|
||||||
import org.cef.handler.CefAppHandlerAdapter;
|
|
||||||
|
|
||||||
import javax.swing.*;
|
|
||||||
import java.awt.*;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Embedded JCEF browser panel used to display the avatar pixel stream.
|
|
||||||
*/
|
|
||||||
public class PixelStreamingView extends JPanel {
|
|
||||||
|
|
||||||
private static final long serialVersionUID = -5570653778104813836L;
|
|
||||||
private final CefApp cefApp;
|
|
||||||
private final CefClient client;
|
|
||||||
private final CefBrowser browser;
|
|
||||||
private final Component browserUI_;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an embedded Chromium browser panel.
|
|
||||||
*
|
|
||||||
* @param startURL initial URL to load
|
|
||||||
* @param useOSR whether off-screen rendering is enabled
|
|
||||||
* @param isTransparent whether transparent rendering is enabled
|
|
||||||
*/
|
|
||||||
public PixelStreamingView(String startURL, boolean useOSR, boolean isTransparent) {
|
|
||||||
super(new BorderLayout());
|
|
||||||
setOpaque(true);
|
|
||||||
setBackground(new Color(8, 11, 16));
|
|
||||||
|
|
||||||
CefApp.addAppHandler(new CefAppHandlerAdapter(null) {
|
|
||||||
/**
|
|
||||||
* Handles JCEF application state transitions.
|
|
||||||
*
|
|
||||||
* @param state latest JCEF lifecycle state
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public void stateHasChanged(CefApp.CefAppState state) {
|
|
||||||
if (state == CefApp.CefAppState.TERMINATED){
|
|
||||||
System.out.println("TERMINATED");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
CefSettings settings = new CefSettings();
|
|
||||||
settings.windowless_rendering_enabled = useOSR;
|
|
||||||
|
|
||||||
cefApp = CefApp.getInstance(settings);
|
|
||||||
client = cefApp.createClient();
|
|
||||||
|
|
||||||
browser = client.createBrowser(startURL, useOSR, isTransparent);
|
|
||||||
browserUI_ = browser.getUIComponent();
|
|
||||||
|
|
||||||
JPanel browserContainer = new JPanel(new BorderLayout());
|
|
||||||
browserContainer.setOpaque(true);
|
|
||||||
browserContainer.setBackground(new Color(6, 9, 14));
|
|
||||||
browserContainer.setBorder(BorderFactory.createCompoundBorder(
|
|
||||||
BorderFactory.createLineBorder(new Color(56, 68, 86)),
|
|
||||||
BorderFactory.createEmptyBorder(6, 6, 6, 6)
|
|
||||||
));
|
|
||||||
browserContainer.add(browserUI_, BorderLayout.CENTER);
|
|
||||||
|
|
||||||
add(browserContainer, BorderLayout.CENTER);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disposes browser resources held by JCEF objects.
|
|
||||||
*/
|
|
||||||
public void dispose() {
|
|
||||||
if (browser != null) browser.close(true);
|
|
||||||
if (client != null) client.dispose();
|
|
||||||
if (cefApp != null) cefApp.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the given stream URL in the embedded browser.
|
|
||||||
*
|
|
||||||
* @param url stream URL
|
|
||||||
*/
|
|
||||||
public void loadStream(String url) {
|
|
||||||
browser.loadURL(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
package vassistent.ui;
|
|
||||||
|
|
||||||
import vassistent.model.ProblemLevel;
|
|
||||||
|
|
||||||
import javax.swing.*;
|
|
||||||
import java.awt.*;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom progress-style component that visualizes ratio and mapped problem level.
|
|
||||||
*/
|
|
||||||
public class ProblemLevelBar extends JPanel {
|
|
||||||
|
|
||||||
private double ratio = 0.0;
|
|
||||||
private ProblemLevel level = ProblemLevel.NONE;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the level bar component with preferred sizing and transparent background.
|
|
||||||
*/
|
|
||||||
public ProblemLevelBar() {
|
|
||||||
setPreferredSize(new Dimension(420, 88));
|
|
||||||
setMinimumSize(new Dimension(320, 72));
|
|
||||||
setOpaque(false);
|
|
||||||
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the displayed ratio and derived problem level.
|
|
||||||
*
|
|
||||||
* @param ratio ratio value, clamped to {@code [0, 1]}
|
|
||||||
*/
|
|
||||||
public void setRatio(double ratio) {
|
|
||||||
this.ratio = Math.max(0.0, Math.min(1.0, ratio));
|
|
||||||
this.level = calculateLevel(this.ratio);
|
|
||||||
repaint();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps a ratio to a problem level threshold.
|
|
||||||
*
|
|
||||||
* @param ratio ratio value in {@code [0, 1]}
|
|
||||||
* @return mapped problem level
|
|
||||||
*/
|
|
||||||
private ProblemLevel calculateLevel(Double ratio) {
|
|
||||||
if (ratio >= 0.9) {
|
|
||||||
return ProblemLevel.DISASTER;
|
|
||||||
} else if (ratio >= 0.8) {
|
|
||||||
return ProblemLevel.HIGH;
|
|
||||||
} else if (ratio >= 0.5) {
|
|
||||||
return ProblemLevel.WARNING;
|
|
||||||
} else {
|
|
||||||
return ProblemLevel.NONE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Paints the bar background, filled level area, and textual status.
|
|
||||||
*
|
|
||||||
* @param g graphics context provided by Swing
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
protected void paintComponent(Graphics g) {
|
|
||||||
super.paintComponent(g);
|
|
||||||
|
|
||||||
int width = getWidth();
|
|
||||||
int height = getHeight();
|
|
||||||
|
|
||||||
Graphics2D g2 = (Graphics2D) g;
|
|
||||||
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
|
|
||||||
RenderingHints.VALUE_ANTIALIAS_ON);
|
|
||||||
g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
|
|
||||||
RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
|
||||||
|
|
||||||
int arc = Math.max(22, height / 2);
|
|
||||||
|
|
||||||
GradientPaint trackGradient = new GradientPaint(
|
|
||||||
0, 0, new Color(51, 60, 76),
|
|
||||||
0, height, new Color(29, 36, 48)
|
|
||||||
);
|
|
||||||
g2.setPaint(trackGradient);
|
|
||||||
g2.fillRoundRect(0, 0, width, height, arc, arc);
|
|
||||||
|
|
||||||
int filledWidth = (int) (width * ratio);
|
|
||||||
if (filledWidth > 0) {
|
|
||||||
filledWidth = Math.max(filledWidth, arc / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
Color baseColor = getColorForLevel(level);
|
|
||||||
GradientPaint fillGradient = new GradientPaint(
|
|
||||||
0, 0, baseColor.darker(),
|
|
||||||
Math.max(1, filledWidth), 0, baseColor
|
|
||||||
);
|
|
||||||
g2.setPaint(fillGradient);
|
|
||||||
g2.fillRoundRect(0, 0, filledWidth, height, arc, arc);
|
|
||||||
|
|
||||||
g2.setStroke(new BasicStroke(2f));
|
|
||||||
g2.setColor(new Color(175, 190, 212));
|
|
||||||
g2.drawRoundRect(1, 1, width - 2, height - 2, arc, arc);
|
|
||||||
|
|
||||||
String text = "Problem " + level.name() + " " + (int) (ratio * 100) + "%";
|
|
||||||
g2.setFont(new Font("Segoe UI Semibold",
|
|
||||||
Font.PLAIN,
|
|
||||||
Math.max(19, (int) (height * 0.33))));
|
|
||||||
g2.setColor(new Color(244, 248, 255));
|
|
||||||
|
|
||||||
FontMetrics fm = g2.getFontMetrics();
|
|
||||||
int textX = (width - fm.stringWidth(text)) / 2;
|
|
||||||
int textY = (height - fm.getHeight()) / 2 + fm.getAscent();
|
|
||||||
g2.drawString(text, textX, textY);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a base color for the given problem level.
|
|
||||||
*
|
|
||||||
* @param level problem level
|
|
||||||
* @return display color for the level
|
|
||||||
*/
|
|
||||||
private Color getColorForLevel(ProblemLevel level) {
|
|
||||||
switch (level) {
|
|
||||||
case DISASTER:
|
|
||||||
return new Color(214, 70, 70);
|
|
||||||
case HIGH:
|
|
||||||
return new Color(234, 134, 45);
|
|
||||||
case WARNING:
|
|
||||||
return new Color(219, 180, 52);
|
|
||||||
default:
|
|
||||||
return new Color(53, 178, 107);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,619 +0,0 @@
|
|||||||
package vassistent.util;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.InvalidPathException;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Properties;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates startup configuration and fails fast on invalid values.
|
|
||||||
*/
|
|
||||||
public final class AppConfigValidator {
|
|
||||||
|
|
||||||
private static final Set<String> SUPPORTED_MQTT_SCHEMES =
|
|
||||||
Set.of("tcp", "ssl", "ws", "wss");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility class constructor.
|
|
||||||
*/
|
|
||||||
private AppConfigValidator() {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates all required startup configuration values.
|
|
||||||
*
|
|
||||||
* @param config loaded application properties
|
|
||||||
*/
|
|
||||||
public static void validate(Properties config) {
|
|
||||||
Objects.requireNonNull(config, "config must not be null");
|
|
||||||
|
|
||||||
List<String> errors = new ArrayList<>();
|
|
||||||
|
|
||||||
validateRequiredMqttSettings(config, errors);
|
|
||||||
validateCoreUiAndOutputSettings(config, errors);
|
|
||||||
validateProcessStartupSettings(config, errors);
|
|
||||||
|
|
||||||
boolean mqttSimulatorEnabled =
|
|
||||||
readBoolean(config, errors, "mqtt_sim.enabled", false);
|
|
||||||
boolean unrealEnabled =
|
|
||||||
readBoolean(config, errors, "unreal.enabled", false);
|
|
||||||
|
|
||||||
if (mqttSimulatorEnabled) {
|
|
||||||
validateMqttSimulatorSettings(config, errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (unrealEnabled) {
|
|
||||||
validateUnrealSettings(config, errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!errors.isEmpty()) {
|
|
||||||
throw new IllegalStateException(buildValidationMessage(errors));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates optional process startup health-check settings.
|
|
||||||
*
|
|
||||||
* @param config loaded properties
|
|
||||||
* @param errors collected validation errors
|
|
||||||
*/
|
|
||||||
private static void validateProcessStartupSettings(
|
|
||||||
Properties config,
|
|
||||||
List<String> errors
|
|
||||||
) {
|
|
||||||
readBoolean(config, errors, "process.startup.healthcheck.enabled", true);
|
|
||||||
validateOptionalIntegerInRange(
|
|
||||||
config,
|
|
||||||
errors,
|
|
||||||
"process.startup.healthcheck.timeout.millis",
|
|
||||||
1,
|
|
||||||
Integer.MAX_VALUE
|
|
||||||
);
|
|
||||||
validateOptionalIntegerInRange(
|
|
||||||
config,
|
|
||||||
errors,
|
|
||||||
"process.startup.healthcheck.poll.millis",
|
|
||||||
1,
|
|
||||||
Integer.MAX_VALUE
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates mandatory MQTT runtime keys.
|
|
||||||
*
|
|
||||||
* @param config loaded properties
|
|
||||||
* @param errors collected validation errors
|
|
||||||
*/
|
|
||||||
private static void validateRequiredMqttSettings(
|
|
||||||
Properties config,
|
|
||||||
List<String> errors
|
|
||||||
) {
|
|
||||||
validateRequiredNonBlank(config, errors, "mqtt.topic");
|
|
||||||
validateRequiredNonBlank(config, errors, "mqtt.client.id");
|
|
||||||
|
|
||||||
String brokerUrl =
|
|
||||||
validateRequiredNonBlank(config, errors, "mqtt.broker.url");
|
|
||||||
if (brokerUrl == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
URI uri = URI.create(brokerUrl);
|
|
||||||
String scheme = uri.getScheme();
|
|
||||||
if (scheme == null
|
|
||||||
|| !SUPPORTED_MQTT_SCHEMES.contains(
|
|
||||||
scheme.toLowerCase(Locale.ROOT))) {
|
|
||||||
errors.add(
|
|
||||||
"Property 'mqtt.broker.url' must use one of "
|
|
||||||
+ SUPPORTED_MQTT_SCHEMES + ": "
|
|
||||||
+ brokerUrl
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uri.getHost() == null || uri.getHost().isBlank()) {
|
|
||||||
errors.add(
|
|
||||||
"Property 'mqtt.broker.url' must include a host: "
|
|
||||||
+ brokerUrl
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
int port = uri.getPort();
|
|
||||||
if (port != -1 && (port < 1 || port > 65535)) {
|
|
||||||
errors.add(
|
|
||||||
"Property 'mqtt.broker.url' contains invalid port "
|
|
||||||
+ port + ": " + brokerUrl
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
errors.add(
|
|
||||||
"Property 'mqtt.broker.url' is not a valid URI: " + brokerUrl
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates UI and animation output settings.
|
|
||||||
*
|
|
||||||
* @param config loaded properties
|
|
||||||
* @param errors collected validation errors
|
|
||||||
*/
|
|
||||||
private static void validateCoreUiAndOutputSettings(
|
|
||||||
Properties config,
|
|
||||||
List<String> errors
|
|
||||||
) {
|
|
||||||
String streamingUrl =
|
|
||||||
validateRequiredNonBlank(config, errors, "streaming.url");
|
|
||||||
if (streamingUrl != null) {
|
|
||||||
try {
|
|
||||||
URI uri = URI.create(streamingUrl);
|
|
||||||
String scheme = uri.getScheme();
|
|
||||||
if (scheme == null
|
|
||||||
|| (!"http".equalsIgnoreCase(scheme)
|
|
||||||
&& !"https".equalsIgnoreCase(scheme))) {
|
|
||||||
errors.add(
|
|
||||||
"Property 'streaming.url' must start with http:// or https://: "
|
|
||||||
+ streamingUrl
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
errors.add(
|
|
||||||
"Property 'streaming.url' is not a valid URI: " + streamingUrl
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
validateRequiredNonBlank(config, errors, "animation.output.path");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates simulator settings that are required only when simulator startup is enabled.
|
|
||||||
*
|
|
||||||
* @param config loaded properties
|
|
||||||
* @param errors collected validation errors
|
|
||||||
*/
|
|
||||||
private static void validateMqttSimulatorSettings(
|
|
||||||
Properties config,
|
|
||||||
List<String> errors
|
|
||||||
) {
|
|
||||||
validateExistingFile(config, errors, "python.path");
|
|
||||||
validateExistingFile(config, errors, "mqtt_sim.script");
|
|
||||||
validateRequiredNonBlank(config, errors, "mqtt_sim.broker");
|
|
||||||
validateRequiredIntegerInRange(config, errors, "mqtt_sim.port", 1, 65535);
|
|
||||||
validateOptionalIntegerInRange(config, errors, "mqtt_sim.qos", 0, 2);
|
|
||||||
validateOptionalPositiveDouble(config, errors, "mqtt_sim.interval_seconds");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates Unreal-related settings that are required only when Unreal startup is enabled.
|
|
||||||
*
|
|
||||||
* @param config loaded properties
|
|
||||||
* @param errors collected validation errors
|
|
||||||
*/
|
|
||||||
private static void validateUnrealSettings(
|
|
||||||
Properties config,
|
|
||||||
List<String> errors
|
|
||||||
) {
|
|
||||||
validateExistingFile(config, errors, "unreal.executable");
|
|
||||||
validateExistingFile(config, errors, "unreal.signalling_server.script");
|
|
||||||
validateExistingFile(config, errors, "unreal.target.executable");
|
|
||||||
validateExistingDirectory(config, errors, "unreal.target.working_dir");
|
|
||||||
validateOptionalExistingDirectory(config, errors, "unreal.pid.dir");
|
|
||||||
validateOptionalNonNegativeInteger(config, errors, "unreal.startup.delay.seconds");
|
|
||||||
validateOptionalPidFileParent(config, errors, "unreal.pid.file");
|
|
||||||
validateOptionalPidFileParent(config, errors, "unreal.signalling.pid.file");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates that a required property exists and is not blank.
|
|
||||||
*
|
|
||||||
* @param config loaded properties
|
|
||||||
* @param errors collected validation errors
|
|
||||||
* @param key property key
|
|
||||||
* @return normalized value or {@code null} when missing/blank
|
|
||||||
*/
|
|
||||||
private static String validateRequiredNonBlank(
|
|
||||||
Properties config,
|
|
||||||
List<String> errors,
|
|
||||||
String key
|
|
||||||
) {
|
|
||||||
String value = normalizeConfigValue(config.getProperty(key));
|
|
||||||
if (value == null || value.isBlank()) {
|
|
||||||
errors.add("Missing required property '" + key + "'.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates that a property points to an existing regular file.
|
|
||||||
*
|
|
||||||
* @param config loaded properties
|
|
||||||
* @param errors collected validation errors
|
|
||||||
* @param key property key
|
|
||||||
*/
|
|
||||||
private static void validateExistingFile(
|
|
||||||
Properties config,
|
|
||||||
List<String> errors,
|
|
||||||
String key
|
|
||||||
) {
|
|
||||||
String rawValue = config.getProperty(key);
|
|
||||||
String normalized = validateRequiredNonBlank(config, errors, key);
|
|
||||||
if (normalized == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Path path = parsePath(rawValue, normalized, key, errors);
|
|
||||||
if (path == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Files.isRegularFile(path)) {
|
|
||||||
errors.add(
|
|
||||||
"Property '" + key + "' must reference an existing file: " + normalized
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates that a property points to an existing directory.
|
|
||||||
*
|
|
||||||
* @param config loaded properties
|
|
||||||
* @param errors collected validation errors
|
|
||||||
* @param key property key
|
|
||||||
*/
|
|
||||||
private static void validateExistingDirectory(
|
|
||||||
Properties config,
|
|
||||||
List<String> errors,
|
|
||||||
String key
|
|
||||||
) {
|
|
||||||
String rawValue = config.getProperty(key);
|
|
||||||
String normalized = validateRequiredNonBlank(config, errors, key);
|
|
||||||
if (normalized == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Path path = parsePath(rawValue, normalized, key, errors);
|
|
||||||
if (path == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Files.isDirectory(path)) {
|
|
||||||
errors.add(
|
|
||||||
"Property '" + key + "' must reference an existing directory: " + normalized
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates an optional directory property when a value is provided.
|
|
||||||
*
|
|
||||||
* @param config loaded properties
|
|
||||||
* @param errors collected validation errors
|
|
||||||
* @param key property key
|
|
||||||
*/
|
|
||||||
private static void validateOptionalExistingDirectory(
|
|
||||||
Properties config,
|
|
||||||
List<String> errors,
|
|
||||||
String key
|
|
||||||
) {
|
|
||||||
String rawValue = config.getProperty(key);
|
|
||||||
String normalized = normalizeConfigValue(rawValue);
|
|
||||||
if (normalized == null || normalized.isBlank()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Path path = parsePath(rawValue, normalized, key, errors);
|
|
||||||
if (path == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Files.isDirectory(path)) {
|
|
||||||
errors.add(
|
|
||||||
"Property '" + key + "' must reference an existing directory: " + normalized
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates an optional integer property with a minimum/maximum range.
|
|
||||||
*
|
|
||||||
* @param config loaded properties
|
|
||||||
* @param errors collected validation errors
|
|
||||||
* @param key property key
|
|
||||||
* @param min minimum valid value
|
|
||||||
* @param max maximum valid value
|
|
||||||
*/
|
|
||||||
private static void validateOptionalIntegerInRange(
|
|
||||||
Properties config,
|
|
||||||
List<String> errors,
|
|
||||||
String key,
|
|
||||||
int min,
|
|
||||||
int max
|
|
||||||
) {
|
|
||||||
String value = normalizeConfigValue(config.getProperty(key));
|
|
||||||
if (value == null || value.isBlank()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
int parsed = Integer.parseInt(value);
|
|
||||||
if (parsed < min || parsed > max) {
|
|
||||||
errors.add(
|
|
||||||
"Property '" + key + "' must be between "
|
|
||||||
+ min + " and " + max + ", but was " + parsed + "."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
errors.add(
|
|
||||||
"Property '" + key + "' must be an integer, but was '" + value + "'."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a required integer property with a minimum/maximum range.
|
|
||||||
*
|
|
||||||
* @param config loaded properties
|
|
||||||
* @param errors collected validation errors
|
|
||||||
* @param key property key
|
|
||||||
* @param min minimum valid value
|
|
||||||
* @param max maximum valid value
|
|
||||||
*/
|
|
||||||
private static void validateRequiredIntegerInRange(
|
|
||||||
Properties config,
|
|
||||||
List<String> errors,
|
|
||||||
String key,
|
|
||||||
int min,
|
|
||||||
int max
|
|
||||||
) {
|
|
||||||
String value = validateRequiredNonBlank(config, errors, key);
|
|
||||||
if (value == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
int parsed = Integer.parseInt(value);
|
|
||||||
if (parsed < min || parsed > max) {
|
|
||||||
errors.add(
|
|
||||||
"Property '" + key + "' must be between "
|
|
||||||
+ min + " and " + max + ", but was " + parsed + "."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
errors.add(
|
|
||||||
"Property '" + key + "' must be an integer, but was '" + value + "'."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates an optional numeric property that must be greater than zero.
|
|
||||||
*
|
|
||||||
* @param config loaded properties
|
|
||||||
* @param errors collected validation errors
|
|
||||||
* @param key property key
|
|
||||||
*/
|
|
||||||
private static void validateOptionalPositiveDouble(
|
|
||||||
Properties config,
|
|
||||||
List<String> errors,
|
|
||||||
String key
|
|
||||||
) {
|
|
||||||
String value = normalizeConfigValue(config.getProperty(key));
|
|
||||||
if (value == null || value.isBlank()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
double parsed = Double.parseDouble(value);
|
|
||||||
if (parsed <= 0.0) {
|
|
||||||
errors.add(
|
|
||||||
"Property '" + key + "' must be greater than 0, but was " + parsed + "."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
errors.add(
|
|
||||||
"Property '" + key + "' must be numeric, but was '" + value + "'."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates an optional integer that must be non-negative.
|
|
||||||
*
|
|
||||||
* @param config loaded properties
|
|
||||||
* @param errors collected validation errors
|
|
||||||
* @param key property key
|
|
||||||
*/
|
|
||||||
private static void validateOptionalNonNegativeInteger(
|
|
||||||
Properties config,
|
|
||||||
List<String> errors,
|
|
||||||
String key
|
|
||||||
) {
|
|
||||||
String value = normalizeConfigValue(config.getProperty(key));
|
|
||||||
if (value == null || value.isBlank()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
int parsed = Integer.parseInt(value);
|
|
||||||
if (parsed < 0) {
|
|
||||||
errors.add(
|
|
||||||
"Property '" + key + "' must be >= 0, but was " + parsed + "."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
errors.add(
|
|
||||||
"Property '" + key + "' must be an integer, but was '" + value + "'."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates that an optional PID file path has an existing parent directory.
|
|
||||||
*
|
|
||||||
* @param config loaded properties
|
|
||||||
* @param errors collected validation errors
|
|
||||||
* @param key property key
|
|
||||||
*/
|
|
||||||
private static void validateOptionalPidFileParent(
|
|
||||||
Properties config,
|
|
||||||
List<String> errors,
|
|
||||||
String key
|
|
||||||
) {
|
|
||||||
String rawValue = config.getProperty(key);
|
|
||||||
String normalized = normalizeConfigValue(rawValue);
|
|
||||||
|
|
||||||
if (normalized == null || normalized.isBlank()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Path path = parsePath(rawValue, normalized, key, errors);
|
|
||||||
if (path == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Path parent = path.getParent();
|
|
||||||
if (parent != null && !Files.isDirectory(parent)) {
|
|
||||||
errors.add(
|
|
||||||
"Parent directory for '" + key + "' does not exist: " + parent
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a boolean property using strict true/false values.
|
|
||||||
*
|
|
||||||
* @param config loaded properties
|
|
||||||
* @param errors collected validation errors
|
|
||||||
* @param key property key
|
|
||||||
* @param defaultValue fallback when value is absent/blank
|
|
||||||
* @return parsed boolean value or default value
|
|
||||||
*/
|
|
||||||
private static boolean readBoolean(
|
|
||||||
Properties config,
|
|
||||||
List<String> errors,
|
|
||||||
String key,
|
|
||||||
boolean defaultValue
|
|
||||||
) {
|
|
||||||
String value = normalizeConfigValue(config.getProperty(key));
|
|
||||||
if (value == null || value.isBlank()) {
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("true".equalsIgnoreCase(value)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("false".equalsIgnoreCase(value)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
errors.add(
|
|
||||||
"Property '" + key + "' must be 'true' or 'false', but was '" + value + "'."
|
|
||||||
);
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses a path value and reports invalid quoting/path syntax.
|
|
||||||
*
|
|
||||||
* @param rawValue raw property value
|
|
||||||
* @param normalized normalized property value
|
|
||||||
* @param key property key
|
|
||||||
* @param errors collected validation errors
|
|
||||||
* @return parsed path or {@code null} when invalid
|
|
||||||
*/
|
|
||||||
private static Path parsePath(
|
|
||||||
String rawValue,
|
|
||||||
String normalized,
|
|
||||||
String key,
|
|
||||||
List<String> errors
|
|
||||||
) {
|
|
||||||
if (hasMismatchedWrappingQuote(rawValue)) {
|
|
||||||
errors.add(
|
|
||||||
"Property '" + key + "' has mismatched wrapping quotes: " + rawValue
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return Path.of(normalized);
|
|
||||||
} catch (InvalidPathException e) {
|
|
||||||
errors.add(
|
|
||||||
"Property '" + key + "' contains an invalid path: " + normalized
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detects mismatched leading/trailing single or double quote wrappers.
|
|
||||||
*
|
|
||||||
* @param rawValue raw property value
|
|
||||||
* @return {@code true} when wrapper quotes are mismatched
|
|
||||||
*/
|
|
||||||
private static boolean hasMismatchedWrappingQuote(String rawValue) {
|
|
||||||
if (rawValue == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
String trimmed = rawValue.trim();
|
|
||||||
if (trimmed.isEmpty()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean startsDouble = trimmed.startsWith("\"");
|
|
||||||
boolean endsDouble = trimmed.endsWith("\"");
|
|
||||||
|
|
||||||
boolean startsSingle = trimmed.startsWith("'");
|
|
||||||
boolean endsSingle = trimmed.endsWith("'");
|
|
||||||
|
|
||||||
return startsDouble != endsDouble || startsSingle != endsSingle;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trims config values and removes matching single/double quote wrappers.
|
|
||||||
*
|
|
||||||
* @param value raw config value
|
|
||||||
* @return normalized value or {@code null}
|
|
||||||
*/
|
|
||||||
private static String normalizeConfigValue(String value) {
|
|
||||||
if (value == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String trimmed = value.trim();
|
|
||||||
|
|
||||||
if ((trimmed.startsWith("\"") && trimmed.endsWith("\""))
|
|
||||||
|| (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
||||||
return trimmed.substring(1, trimmed.length() - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a single human-readable validation exception message.
|
|
||||||
*
|
|
||||||
* @param errors collected validation errors
|
|
||||||
* @return formatted message
|
|
||||||
*/
|
|
||||||
private static String buildValidationMessage(List<String> errors) {
|
|
||||||
StringBuilder message = new StringBuilder(
|
|
||||||
"Configuration validation failed (" + errors.size() + " issue(s)):"
|
|
||||||
);
|
|
||||||
|
|
||||||
for (int i = 0; i < errors.size(); i++) {
|
|
||||||
message.append(System.lineSeparator())
|
|
||||||
.append(i + 1)
|
|
||||||
.append(". ")
|
|
||||||
.append(errors.get(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
return message.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package vassistent.util;
|
|
||||||
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.util.Properties;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility for loading classpath-based properties files.
|
|
||||||
*/
|
|
||||||
public class ConfigLoader {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a config loader instance.
|
|
||||||
*/
|
|
||||||
public ConfigLoader() {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads a properties resource from the application classpath.
|
|
||||||
*
|
|
||||||
* @param path classpath-relative resource path
|
|
||||||
* @return loaded properties, or empty properties when resource is missing
|
|
||||||
*/
|
|
||||||
public static Properties loadProperties(String path) {
|
|
||||||
|
|
||||||
Properties props = new Properties();
|
|
||||||
|
|
||||||
try (InputStream is =
|
|
||||||
Logger.class.getClassLoader()
|
|
||||||
.getResourceAsStream(path)) {
|
|
||||||
|
|
||||||
if (is != null)
|
|
||||||
props.load(is);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
Logger.error("CONFIG",
|
|
||||||
"Failed to load properties", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return props;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
package vassistent.util;
|
|
||||||
|
|
||||||
import java.io.*;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.Properties;
|
|
||||||
import java.util.concurrent.BlockingQueue;
|
|
||||||
import java.util.concurrent.LinkedBlockingQueue;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Asynchronous application logger with level filtering and file rotation support.
|
|
||||||
*/
|
|
||||||
public class Logger {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log severity levels in ascending order.
|
|
||||||
*/
|
|
||||||
public enum Level {
|
|
||||||
DEBUG, INFO, WARN, ERROR
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Level currentLevel = Level.DEBUG;
|
|
||||||
|
|
||||||
private static boolean logToFile = true;
|
|
||||||
private static String logFilePath = "logs/application.log";
|
|
||||||
private static int maxFileSizeMB = 10;
|
|
||||||
|
|
||||||
private static final DateTimeFormatter FORMATTER =
|
|
||||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
|
||||||
|
|
||||||
private static final BlockingQueue<String> logQueue =
|
|
||||||
new LinkedBlockingQueue<>();
|
|
||||||
|
|
||||||
static {
|
|
||||||
loadConfig();
|
|
||||||
startLogWriterThread();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility class constructor.
|
|
||||||
*/
|
|
||||||
private Logger() {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads logger configuration from {@code config/logger.properties}.
|
|
||||||
*/
|
|
||||||
private static void loadConfig() {
|
|
||||||
try (InputStream is = Logger.class.getClassLoader()
|
|
||||||
.getResourceAsStream("config/logger.properties")) {
|
|
||||||
|
|
||||||
if (is == null) return;
|
|
||||||
|
|
||||||
Properties props = new Properties();
|
|
||||||
props.load(is);
|
|
||||||
|
|
||||||
currentLevel =
|
|
||||||
Level.valueOf(
|
|
||||||
props.getProperty("logger.level", "DEBUG"));
|
|
||||||
|
|
||||||
logToFile =
|
|
||||||
Boolean.parseBoolean(
|
|
||||||
props.getProperty("logger.file.enabled", "true"));
|
|
||||||
|
|
||||||
logFilePath =
|
|
||||||
props.getProperty("logger.file", "logs/application.log");
|
|
||||||
|
|
||||||
maxFileSizeMB =
|
|
||||||
Integer.parseInt(
|
|
||||||
props.getProperty("logger.max.size.mb", "10"));
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("Logger config error: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes a debug-level log entry.
|
|
||||||
*
|
|
||||||
* @param source logical source component
|
|
||||||
* @param message log message
|
|
||||||
*/
|
|
||||||
public static void debug(String source, String message) {
|
|
||||||
log(Level.DEBUG, source, message, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes an info-level log entry.
|
|
||||||
*
|
|
||||||
* @param source logical source component
|
|
||||||
* @param message log message
|
|
||||||
*/
|
|
||||||
public static void info(String source, String message) {
|
|
||||||
log(Level.INFO, source, message, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes a warning-level log entry.
|
|
||||||
*
|
|
||||||
* @param source logical source component
|
|
||||||
* @param message log message
|
|
||||||
*/
|
|
||||||
public static void warn(String source, String message) {
|
|
||||||
log(Level.WARN, source, message, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes an error-level log entry without stack trace.
|
|
||||||
*
|
|
||||||
* @param source logical source component
|
|
||||||
* @param message log message
|
|
||||||
*/
|
|
||||||
public static void error(String source, String message) {
|
|
||||||
log(Level.ERROR, source, message, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes an error-level log entry with stack trace.
|
|
||||||
*
|
|
||||||
* @param source logical source component
|
|
||||||
* @param message log message
|
|
||||||
* @param t associated throwable
|
|
||||||
*/
|
|
||||||
public static void error(String source, String message, Throwable t) {
|
|
||||||
log(Level.ERROR, source, message, t);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enqueues a log entry for asynchronous output if the level passes filtering.
|
|
||||||
*
|
|
||||||
* @param level severity level
|
|
||||||
* @param source logical source component
|
|
||||||
* @param message log message
|
|
||||||
* @param throwable optional throwable to append
|
|
||||||
*/
|
|
||||||
public static void log(Level level, String source, String message, Throwable throwable) {
|
|
||||||
|
|
||||||
if (level.ordinal() < currentLevel.ordinal()) return;
|
|
||||||
|
|
||||||
String timestamp =
|
|
||||||
LocalDateTime.now().format(FORMATTER);
|
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
|
|
||||||
sb.append(String.format("[%s] [%s] [%s] %s",
|
|
||||||
timestamp, level, source, message));
|
|
||||||
|
|
||||||
if (throwable != null) {
|
|
||||||
StringWriter sw = new StringWriter();
|
|
||||||
throwable.printStackTrace(new PrintWriter(sw));
|
|
||||||
sb.append("\n").append(sw);
|
|
||||||
}
|
|
||||||
|
|
||||||
logQueue.offer(sb.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts the daemon thread that drains the log queue and writes outputs.
|
|
||||||
*/
|
|
||||||
private static void startLogWriterThread() {
|
|
||||||
|
|
||||||
Thread writerThread = new Thread(() -> {
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
String logEntry = logQueue.take();
|
|
||||||
|
|
||||||
if (logToFile) {
|
|
||||||
|
|
||||||
rotateIfNeeded();
|
|
||||||
|
|
||||||
File logFile = new File(logFilePath);
|
|
||||||
logFile.getParentFile().mkdirs();
|
|
||||||
|
|
||||||
try (FileWriter fw = new FileWriter(logFile, true)) {
|
|
||||||
fw.write(logEntry + "\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
System.out.println(logEntry);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("Logger writer error: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
writerThread.setDaemon(true);
|
|
||||||
writerThread.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rotates the active log file when size exceeds configured threshold.
|
|
||||||
*/
|
|
||||||
private static void rotateIfNeeded() {
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
File file = new File(logFilePath);
|
|
||||||
|
|
||||||
if (!file.exists()) return;
|
|
||||||
|
|
||||||
long maxBytes =
|
|
||||||
maxFileSizeMB * 1024L * 1024L;
|
|
||||||
|
|
||||||
if (file.length() > maxBytes) {
|
|
||||||
|
|
||||||
String archiveName =
|
|
||||||
logFilePath.replace(".log",
|
|
||||||
"_" + System.currentTimeMillis() + ".log");
|
|
||||||
|
|
||||||
File archive = new File(archiveName);
|
|
||||||
|
|
||||||
boolean renamed = file.renameTo(archive);
|
|
||||||
|
|
||||||
if (renamed) {
|
|
||||||
System.out.println("Logger Rotation: " + archiveName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("Logger rotation error: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Performs a best-effort flush of queued messages during shutdown.
|
|
||||||
*/
|
|
||||||
public static void shutdown() {
|
|
||||||
|
|
||||||
System.out.println("Logger shutdown started");
|
|
||||||
|
|
||||||
try {
|
|
||||||
Thread.sleep(500); // Wait for queue flush
|
|
||||||
|
|
||||||
while (!logQueue.isEmpty()) {
|
|
||||||
String log = logQueue.poll();
|
|
||||||
if (log != null) {
|
|
||||||
System.out.println(log);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("Logger shutdown error: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
Manifest-Version: 1.0
|
|
||||||
Main-Class: vassistent.App
|
|
||||||
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# ===== MODE =====
|
|
||||||
app.mode=test
|
|
||||||
|
|
||||||
# ===== UI =====
|
|
||||||
streaming.url=http://localhost
|
|
||||||
|
|
||||||
# ===== PYTHON =====
|
|
||||||
python.path=C:\\Program Files\\PyManager\\python.exe
|
|
||||||
|
|
||||||
# ===== MQTT CLIENT =====
|
|
||||||
mqtt.broker.url=tcp://localhost:1883
|
|
||||||
mqtt.client.id=JavaClientPublisherSubscriber
|
|
||||||
mqtt.topic=PREDICTION
|
|
||||||
|
|
||||||
# ===== MQTT SIMULATOR =====
|
|
||||||
mqtt_sim.enabled=true
|
|
||||||
mqtt_sim.script=src/main/resources/scripts/mqtt_simulator.py
|
|
||||||
mqtt_sim.broker=localhost
|
|
||||||
mqtt_sim.port=1883
|
|
||||||
mqtt_sim.topic=PREDICTION
|
|
||||||
mqtt_sim.qos=0
|
|
||||||
mqtt_sim.interval_seconds=5
|
|
||||||
# mqtt_sim.username=
|
|
||||||
# mqtt_sim.password=
|
|
||||||
# mqtt_sim.start_id=1
|
|
||||||
# mqtt_sim.client_id=mqtt-simulator
|
|
||||||
# mqtt_sim.log_file=logs/mqtt_simulator.log
|
|
||||||
|
|
||||||
# ===== ANIMATION =====
|
|
||||||
animation.output.path=data/animation.json
|
|
||||||
|
|
||||||
# ===== PROCESS STARTUP HEALTHCHECK =====
|
|
||||||
process.startup.healthcheck.enabled=true
|
|
||||||
process.startup.healthcheck.timeout.millis=10000
|
|
||||||
process.startup.healthcheck.poll.millis=250
|
|
||||||
|
|
||||||
# ===== UNREAL ENGINE =====
|
|
||||||
unreal.enabled=true
|
|
||||||
# unreal.executable=C:\\Users\\Student\\Documents\\Dannick\\avatar\\start_avatar.ps1
|
|
||||||
# unreal.signalling_server.script=C:\\Users\\Student\\Documents\\Dannick\\avatar\\Windows\\Prototyp1\\Samples\\PixelStreaming\\WebServers\\SignallingWebServer\\platform_scripts\\cmd\\start_with_stun.bat
|
|
||||||
# unreal.pid.file=C:\\Users\\Student\\Documents\\Dannick\\avatar\\unreal.pid
|
|
||||||
# unreal.signalling.pid.file=C:\\Users\\Student\\Documents\\Dannick\\avatar\\signalling.pid
|
|
||||||
# unreal.pid.dir=C:\\Users\\Student\\Documents\\Dannick\\avatar
|
|
||||||
# unreal.target.executable=C:\\Users\\Student\\Documents\\Dannick\\avatar\\Windows\\Prototyp1.exe
|
|
||||||
# unreal.target.args=-PixelStreamingURL=ws://127.0.0.1 -RenderOffscreen -PixelStreamingWebRTCDisableTransmitAudio
|
|
||||||
# unreal.target.working_dir=C:\\Users\\Student\\Documents\\Dannick\\avatar\\Windows
|
|
||||||
# unreal.startup.delay.seconds=5
|
|
||||||
|
|
||||||
# ===== TEST =====
|
|
||||||
unreal.executable=C:\\Privat\\Dokumente\\Niklas_Aumueller\\TH\\MSY\\Semester_2\\Projektarbeit\\Windows\\start_avatar.ps1
|
|
||||||
unreal.signalling_server.script=C:\\Privat\\Dokumente\\Niklas_Aumueller\\TH\\MSY\\Semester_2\\Projektarbeit\\Windows\\Prototyp1\\Samples\\PixelStreaming\\WebServers\\SignallingWebServer\\platform_scripts\\cmd\\start_with_stun.bat
|
|
||||||
|
|
||||||
unreal.target.executable=C:\\Privat\\Dokumente\\Niklas_Aumueller\\TH\\MSY\\Semester_2\\Projektarbeit\\Windows\\Prototyp1.exe
|
|
||||||
unreal.target.working_dir=C:\\Privat\\Dokumente\\Niklas_Aumueller\\TH\\MSY\\Semester_2\\Projektarbeit\\Windows
|
|
||||||
unreal.target.args=-PixelStreamingURL=ws://127.0.0.1 -RenderOffscreen -PixelStreamingWebRTCDisableTransmitAudio
|
|
||||||
|
|
||||||
unreal.pid.dir=C:\\Privat\\Dokumente\\Niklas_Aumueller\\TH\\MSY\\Semester_2\\Projektarbeit\\Windows
|
|
||||||
unreal.pid.file=C:\\Privat\\Dokumente\\Niklas_Aumueller\\TH\\MSY\\Semester_2\\Projektarbeit\\Windows\\unreal.pid
|
|
||||||
unreal.signalling.pid.file=C:\\Privat\\Dokumente\\Niklas_Aumueller\\TH\\MSY\\Semester_2\\Projektarbeit\\Windows\\signalling.pid
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
logger.level=DEBUG
|
|
||||||
logger.file.enabled=true
|
|
||||||
logger.file=logs/application.log
|
|
||||||
logger.max.size.mb=10
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
import paho.mqtt.client as mqtt
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_BROKER = "141.75.223.13"
|
|
||||||
DEFAULT_PORT = 1883
|
|
||||||
DEFAULT_TOPIC = "PREDICTION"
|
|
||||||
DEFAULT_QOS = 0
|
|
||||||
DEFAULT_INTERVAL_SECONDS = 5.0
|
|
||||||
DEFAULT_START_ID = 1
|
|
||||||
DEFAULT_CLIENT_ID = "mqtt-simulator"
|
|
||||||
DEFAULT_LOG_FILE = "logs/mqtt_simulator.log"
|
|
||||||
|
|
||||||
|
|
||||||
def env_int(name, default):
|
|
||||||
value = os.getenv(name)
|
|
||||||
if value is None or value.strip() == "":
|
|
||||||
return default
|
|
||||||
|
|
||||||
try:
|
|
||||||
return int(value)
|
|
||||||
except ValueError:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def env_float(name, default):
|
|
||||||
value = os.getenv(name)
|
|
||||||
if value is None or value.strip() == "":
|
|
||||||
return default
|
|
||||||
|
|
||||||
try:
|
|
||||||
return float(value)
|
|
||||||
except ValueError:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def build_args():
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="MQTT simulator for binary prediction events."
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument("--broker", default=os.getenv("MQTT_SIM_BROKER", DEFAULT_BROKER))
|
|
||||||
parser.add_argument("--port", type=int, default=env_int("MQTT_SIM_PORT", DEFAULT_PORT))
|
|
||||||
parser.add_argument("--topic", default=os.getenv("MQTT_SIM_TOPIC", DEFAULT_TOPIC))
|
|
||||||
parser.add_argument("--username", default=os.getenv("MQTT_SIM_USERNAME"))
|
|
||||||
parser.add_argument("--password", default=os.getenv("MQTT_SIM_PASSWORD"))
|
|
||||||
parser.add_argument("--qos", type=int, choices=[0, 1, 2], default=env_int("MQTT_SIM_QOS", DEFAULT_QOS))
|
|
||||||
parser.add_argument(
|
|
||||||
"--interval-seconds",
|
|
||||||
type=float,
|
|
||||||
default=env_float("MQTT_SIM_INTERVAL_SECONDS", DEFAULT_INTERVAL_SECONDS),
|
|
||||||
)
|
|
||||||
parser.add_argument("--start-id", type=int, default=env_int("MQTT_SIM_START_ID", DEFAULT_START_ID))
|
|
||||||
parser.add_argument("--client-id", default=os.getenv("MQTT_SIM_CLIENT_ID", DEFAULT_CLIENT_ID))
|
|
||||||
parser.add_argument("--log-file", default=os.getenv("MQTT_SIM_LOG_FILE", DEFAULT_LOG_FILE))
|
|
||||||
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def configure_logging(log_file):
|
|
||||||
log_dir = os.path.dirname(log_file)
|
|
||||||
if log_dir:
|
|
||||||
os.makedirs(log_dir, exist_ok=True)
|
|
||||||
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format="%(asctime)s - %(levelname)s - %(message)s",
|
|
||||||
handlers=[
|
|
||||||
logging.FileHandler(log_file),
|
|
||||||
logging.StreamHandler(sys.stdout),
|
|
||||||
],
|
|
||||||
force=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def on_connect(client, userdata, flags, rc):
|
|
||||||
if rc == 0:
|
|
||||||
logging.info("Erfolgreich mit Broker verbunden")
|
|
||||||
else:
|
|
||||||
logging.error("Verbindung fehlgeschlagen mit Code %s", rc)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
args = build_args()
|
|
||||||
configure_logging(args.log_file)
|
|
||||||
|
|
||||||
logging.info("Python script gestartet")
|
|
||||||
logging.info(
|
|
||||||
"Konfiguration: broker=%s port=%s topic=%s qos=%s interval=%s",
|
|
||||||
args.broker,
|
|
||||||
args.port,
|
|
||||||
args.topic,
|
|
||||||
args.qos,
|
|
||||||
args.interval_seconds,
|
|
||||||
)
|
|
||||||
|
|
||||||
client = mqtt.Client(client_id=args.client_id)
|
|
||||||
|
|
||||||
if args.username:
|
|
||||||
client.username_pw_set(args.username, args.password)
|
|
||||||
|
|
||||||
client.on_connect = on_connect
|
|
||||||
|
|
||||||
try:
|
|
||||||
logging.info("Verbinde mit Broker %s:%s", args.broker, args.port)
|
|
||||||
client.connect(args.broker, args.port, 60)
|
|
||||||
client.loop_start()
|
|
||||||
|
|
||||||
logging.info("Starte kontinuierliches Senden...")
|
|
||||||
|
|
||||||
current_id = args.start_id
|
|
||||||
|
|
||||||
while True:
|
|
||||||
payload = {
|
|
||||||
"valid": True,
|
|
||||||
"_id": current_id,
|
|
||||||
"prediction": random.randint(0, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
json_payload = json.dumps(payload)
|
|
||||||
client.publish(args.topic, json_payload, qos=args.qos)
|
|
||||||
logging.info("Gesendet an '%s': %s", args.topic, json_payload)
|
|
||||||
|
|
||||||
current_id += 1
|
|
||||||
time.sleep(args.interval_seconds)
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
logging.info("Publisher manuell beendet")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
logging.exception("Unerwarteter Fehler")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
client.loop_stop()
|
|
||||||
client.disconnect()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
param(
|
|
||||||
[string]$PidDir = $env:VGA_PID_DIR,
|
|
||||||
[string]$UeExe = $env:VGA_UE_EXE,
|
|
||||||
[string]$UeArgs = $env:VGA_UE_ARGS,
|
|
||||||
[string]$SignallingBat = $env:VGA_SIGNALLING_BAT,
|
|
||||||
[string]$UeWorkingDir = $env:VGA_UE_WORKDIR,
|
|
||||||
[int]$SignallingStartupDelaySeconds = $(if ($env:VGA_SIGNALLING_DELAY) { [int]$env:VGA_SIGNALLING_DELAY } else { 5 }),
|
|
||||||
[switch]$ShowSignallingWindow
|
|
||||||
)
|
|
||||||
|
|
||||||
if (-not $PidDir) {
|
|
||||||
$PidDir = "C:\Users\Student\Documents\Dannick\avatar"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $UeExe) {
|
|
||||||
$UeExe = "C:\Users\Student\Documents\Dannick\avatar\Windows\Prototyp1.exe"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $UeArgs) {
|
|
||||||
$UeArgs = "-PixelStreamingURL=ws://127.0.0.1 -RenderOffscreen -PixelStreamingWebRTCDisableTransmitAudio"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $SignallingBat) {
|
|
||||||
$SignallingBat = "C:\Users\Student\Documents\Dannick\avatar\Windows\Prototyp1\Samples\PixelStreaming\WebServers\SignallingWebServer\platform_scripts\cmd\start_with_stun.bat"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $UeWorkingDir) {
|
|
||||||
$UeWorkingDir = Split-Path -Path $UeExe -Parent
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Path -LiteralPath $SignallingBat)) {
|
|
||||||
throw "Signalling script not found: $SignallingBat"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Path -LiteralPath $UeExe)) {
|
|
||||||
throw "Unreal executable not found: $UeExe"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Path -LiteralPath $UeWorkingDir)) {
|
|
||||||
throw "Unreal working directory not found: $UeWorkingDir"
|
|
||||||
}
|
|
||||||
|
|
||||||
New-Item -ItemType Directory -Path $PidDir -Force | Out-Null
|
|
||||||
|
|
||||||
$signallingWindowStyle = if ($ShowSignallingWindow.IsPresent) { "Normal" } else { "Hidden" }
|
|
||||||
$signalling = Start-Process -FilePath $SignallingBat -PassThru -WindowStyle $signallingWindowStyle
|
|
||||||
|
|
||||||
Start-Sleep -Seconds $SignallingStartupDelaySeconds
|
|
||||||
|
|
||||||
$ueStartParams = @{
|
|
||||||
FilePath = $UeExe
|
|
||||||
WorkingDirectory = $UeWorkingDir
|
|
||||||
PassThru = $true
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($UeArgs) {
|
|
||||||
$ueStartParams["ArgumentList"] = $UeArgs
|
|
||||||
}
|
|
||||||
|
|
||||||
$ue = Start-Process @ueStartParams
|
|
||||||
|
|
||||||
$ue.Id | Out-File (Join-Path $PidDir "unreal.pid") -Encoding ascii
|
|
||||||
$signalling.Id | Out-File (Join-Path $PidDir "signalling.pid") -Encoding ascii
|
|
||||||
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
package vassistent.model;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit tests for {@link AppState}.
|
|
||||||
*/
|
|
||||||
class AppStateTest {
|
|
||||||
|
|
||||||
private AppState appState;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a fresh app state instance for each test.
|
|
||||||
*/
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
appState = new AppState();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies problem level defaults to NONE.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void defaultProblemLevel_isNone() {
|
|
||||||
assertEquals(ProblemLevel.NONE, appState.getProblemLevel());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies MQTT status defaults to disconnected.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void defaultMqttState_isDisconnected() {
|
|
||||||
assertFalse(appState.isMqttConnected());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies listeners are notified when problem level changes.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void setProblemLevel_notifiesListeners() {
|
|
||||||
AtomicInteger notifications = new AtomicInteger(0);
|
|
||||||
appState.addListener(state -> notifications.incrementAndGet());
|
|
||||||
|
|
||||||
appState.setProblemLevel(ProblemLevel.HIGH);
|
|
||||||
|
|
||||||
assertEquals(1, notifications.get());
|
|
||||||
assertEquals(ProblemLevel.HIGH, appState.getProblemLevel());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies MQTT listener notifications happen only when value changes.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void setMqttConnected_notifiesOnlyOnChange() {
|
|
||||||
AtomicInteger notifications = new AtomicInteger(0);
|
|
||||||
appState.addListener(state -> notifications.incrementAndGet());
|
|
||||||
|
|
||||||
appState.setMqttConnected(true);
|
|
||||||
appState.setMqttConnected(true);
|
|
||||||
appState.setMqttConnected(false);
|
|
||||||
|
|
||||||
assertEquals(2, notifications.get());
|
|
||||||
assertFalse(appState.isMqttConnected());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies setting MQTT state to true updates the state value.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void setMqttConnected_updatesValue() {
|
|
||||||
appState.setMqttConnected(true);
|
|
||||||
|
|
||||||
assertTrue(appState.isMqttConnected());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package vassistent.service;
|
|
||||||
|
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
|
||||||
import org.junit.jupiter.params.provider.CsvSource;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
|
||||||
import vassistent.model.ProblemLevel;
|
|
||||||
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Integration tests for {@link AnimationFileService}.
|
|
||||||
*/
|
|
||||||
class AnimationFileServiceTest {
|
|
||||||
|
|
||||||
@TempDir
|
|
||||||
Path tempDir;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies level-to-animation mapping is correctly serialized into JSON output.
|
|
||||||
*
|
|
||||||
* @param level problem level name
|
|
||||||
* @param expectedAnimation expected serialized animation value
|
|
||||||
* @throws Exception if file access fails
|
|
||||||
*/
|
|
||||||
@ParameterizedTest
|
|
||||||
@CsvSource({
|
|
||||||
"NONE, no",
|
|
||||||
"WARNING, warning",
|
|
||||||
"HIGH, high",
|
|
||||||
"DISASTER, disaster"
|
|
||||||
})
|
|
||||||
void wirteAnimationState_writesMappedAnimation(String level, String expectedAnimation) throws Exception {
|
|
||||||
Path outputFile = tempDir.resolve("animation.json");
|
|
||||||
AnimationFileService service = new AnimationFileService(outputFile.toString());
|
|
||||||
|
|
||||||
service.wirteAnimationState(ProblemLevel.valueOf(level));
|
|
||||||
|
|
||||||
assertTrue(Files.exists(outputFile));
|
|
||||||
String content = Files.readString(outputFile);
|
|
||||||
assertTrue(content.contains("\"animation\": \"" + expectedAnimation + "\""));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies parent directories are created automatically when missing.
|
|
||||||
*
|
|
||||||
* @throws Exception if file access fails
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void wirteAnimationState_createsParentDirectory() throws Exception {
|
|
||||||
Path nestedFile = tempDir.resolve("nested/path/animation.json");
|
|
||||||
AnimationFileService service = new AnimationFileService(nestedFile.toString());
|
|
||||||
|
|
||||||
service.wirteAnimationState(ProblemLevel.WARNING);
|
|
||||||
|
|
||||||
assertTrue(Files.exists(nestedFile));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies subsequent writes overwrite prior animation state.
|
|
||||||
*
|
|
||||||
* @throws Exception if file access fails
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void wirteAnimationState_overwritesPreviousContent() throws Exception {
|
|
||||||
Path outputFile = tempDir.resolve("animation.json");
|
|
||||||
AnimationFileService service = new AnimationFileService(outputFile.toString());
|
|
||||||
|
|
||||||
service.wirteAnimationState(ProblemLevel.NONE);
|
|
||||||
service.wirteAnimationState(ProblemLevel.DISASTER);
|
|
||||||
|
|
||||||
String content = Files.readString(outputFile);
|
|
||||||
assertTrue(content.contains("\"animation\": \"disaster\""));
|
|
||||||
assertTrue(!content.contains("\"animation\": \"no\""));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
package vassistent.service;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
|
|
||||||
import static org.mockito.Mockito.never;
|
|
||||||
import static org.mockito.Mockito.times;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit tests for {@link BinaryEventService}.
|
|
||||||
*/
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class BinaryEventServiceTest {
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private DataPersistenceService persistenceService;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private EvaluationService evaluationService;
|
|
||||||
|
|
||||||
private BinaryEventService binaryEventService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the service under test with mocked dependencies.
|
|
||||||
*/
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
binaryEventService = new BinaryEventService(
|
|
||||||
persistenceService,
|
|
||||||
evaluationService
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies that a valid payload triggers persistence and evaluation.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void handlePayload_validPayload_persistsAndEvaluates() {
|
|
||||||
String payload = "{\"valid\":true,\"_id\":1,\"prediction\":1}";
|
|
||||||
|
|
||||||
binaryEventService.handlePayload(payload);
|
|
||||||
|
|
||||||
verify(persistenceService).store(1);
|
|
||||||
verify(evaluationService).evaluate();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies that an invalid "valid" flag prevents processing.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void handlePayload_invalidFlag_doesNotProcess() {
|
|
||||||
String payload = "{\"valid\":false,\"_id\":1,\"prediction\":1}";
|
|
||||||
|
|
||||||
binaryEventService.handlePayload(payload);
|
|
||||||
|
|
||||||
verify(persistenceService, never()).store(org.mockito.ArgumentMatchers.anyInt());
|
|
||||||
verify(evaluationService, never()).evaluate();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies that payloads missing required fields are ignored.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void handlePayload_missingPrediction_doesNotProcess() {
|
|
||||||
String payload = "{\"valid\":true,\"_id\":1}";
|
|
||||||
|
|
||||||
binaryEventService.handlePayload(payload);
|
|
||||||
|
|
||||||
verify(persistenceService, never()).store(org.mockito.ArgumentMatchers.anyInt());
|
|
||||||
verify(evaluationService, never()).evaluate();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies that predictions outside the binary domain are ignored.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void handlePayload_invalidPrediction_doesNotProcess() {
|
|
||||||
String payload = "{\"valid\":true,\"_id\":1,\"prediction\":3}";
|
|
||||||
|
|
||||||
binaryEventService.handlePayload(payload);
|
|
||||||
|
|
||||||
verify(persistenceService, never()).store(org.mockito.ArgumentMatchers.anyInt());
|
|
||||||
verify(evaluationService, never()).evaluate();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies that malformed JSON payloads do not trigger downstream processing.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void handlePayload_malformedJson_doesNotProcess() {
|
|
||||||
String payload = "not-json";
|
|
||||||
|
|
||||||
binaryEventService.handlePayload(payload);
|
|
||||||
|
|
||||||
verify(persistenceService, never()).store(org.mockito.ArgumentMatchers.anyInt());
|
|
||||||
verify(evaluationService, never()).evaluate();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies current behavior for duplicate IDs.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void handlePayload_duplicateId_isIgnoredAfterFirstMessage() {
|
|
||||||
String payload = "{\"valid\":true,\"_id\":7,\"prediction\":0}";
|
|
||||||
|
|
||||||
binaryEventService.handlePayload(payload);
|
|
||||||
binaryEventService.handlePayload(payload);
|
|
||||||
|
|
||||||
verify(persistenceService, times(1)).store(0);
|
|
||||||
verify(evaluationService, times(1)).evaluate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
package vassistent.service;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
|
||||||
import vassistent.model.DatabaseEntry;
|
|
||||||
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Integration tests for {@link DataPersistenceService} using an isolated temp SQLite database.
|
|
||||||
*/
|
|
||||||
class DataPersistenceServiceTest {
|
|
||||||
|
|
||||||
@TempDir
|
|
||||||
Path tempDir;
|
|
||||||
|
|
||||||
private DataPersistenceService service;
|
|
||||||
private String dbUrl;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an isolated test database for each test.
|
|
||||||
*/
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
dbUrl = "jdbc:sqlite:" + tempDir.resolve("health-test.db").toAbsolutePath();
|
|
||||||
service = new DataPersistenceService(dbUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies the configured JDBC URL is exposed unchanged.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void getDbUrl_returnsConfiguredUrl() {
|
|
||||||
assertEquals(dbUrl, service.getDbUrl());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies that stored entries can be read back in chronological order.
|
|
||||||
*
|
|
||||||
* @throws InterruptedException if the sleep used to separate timestamps is interrupted
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void store_andGetLastEntries_returnsChronologicalEntries() throws InterruptedException {
|
|
||||||
service.store(1);
|
|
||||||
Thread.sleep(5);
|
|
||||||
service.store(0);
|
|
||||||
|
|
||||||
List<DatabaseEntry> entries = service.getLastEntries(10);
|
|
||||||
|
|
||||||
assertEquals(2, entries.size());
|
|
||||||
assertEquals(1.0, entries.get(0).getValue());
|
|
||||||
assertEquals(0.0, entries.get(1).getValue());
|
|
||||||
assertNotNull(entries.get(0).getTimestamp());
|
|
||||||
assertNotNull(entries.get(1).getTimestamp());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies that entry retrieval respects the requested limit.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void getLastEntries_respectsLimit() throws InterruptedException {
|
|
||||||
service.store(1);
|
|
||||||
Thread.sleep(5);
|
|
||||||
service.store(0);
|
|
||||||
Thread.sleep(5);
|
|
||||||
service.store(1);
|
|
||||||
|
|
||||||
List<DatabaseEntry> entries = service.getLastEntries(2);
|
|
||||||
|
|
||||||
assertEquals(2, entries.size());
|
|
||||||
assertFalse(entries.isEmpty());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies that initializing the service ensures the data folder exists.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void constructor_createsDataFolderIfMissing() {
|
|
||||||
assertTrue(java.nio.file.Files.exists(Path.of("data")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
package vassistent.service;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
|
||||||
import org.junit.jupiter.params.provider.CsvSource;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
import vassistent.model.AppState;
|
|
||||||
import vassistent.model.ProblemLevel;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit tests for {@link EvaluationService}.
|
|
||||||
*/
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class EvaluationServiceTest {
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private StatisticsService statisticsService;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private AnimationFileService animationFileService;
|
|
||||||
|
|
||||||
private AppState appState;
|
|
||||||
private EvaluationService evaluationService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the service under test with mocked dependencies.
|
|
||||||
*/
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
appState = new AppState();
|
|
||||||
evaluationService = new EvaluationService(
|
|
||||||
statisticsService,
|
|
||||||
appState,
|
|
||||||
animationFileService
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies threshold mapping and animation side-effect for multiple ratio values.
|
|
||||||
*
|
|
||||||
* @param ratio ratio returned by statistics service
|
|
||||||
* @param expectedLevel expected mapped problem level name
|
|
||||||
*/
|
|
||||||
@ParameterizedTest
|
|
||||||
@CsvSource({
|
|
||||||
"0.00, NONE",
|
|
||||||
"0.49, NONE",
|
|
||||||
"0.50, WARNING",
|
|
||||||
"0.79, WARNING",
|
|
||||||
"0.80, HIGH",
|
|
||||||
"0.89, HIGH",
|
|
||||||
"0.90, DISASTER",
|
|
||||||
"1.00, DISASTER"
|
|
||||||
})
|
|
||||||
void evaluate_mapsThresholdsAndWritesAnimation(double ratio, String expectedLevel) {
|
|
||||||
ProblemLevel level = ProblemLevel.valueOf(expectedLevel);
|
|
||||||
when(statisticsService.getRatio(20)).thenReturn(ratio);
|
|
||||||
|
|
||||||
evaluationService.evaluate();
|
|
||||||
|
|
||||||
assertEquals(level, appState.getProblemLevel());
|
|
||||||
verify(animationFileService).wirteAnimationState(level);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies that evaluation always queries the ratio with a fixed window size of 20.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void evaluate_usesFixedRatioWindow() {
|
|
||||||
when(statisticsService.getRatio(20)).thenReturn(0.1);
|
|
||||||
|
|
||||||
evaluationService.evaluate();
|
|
||||||
|
|
||||||
verify(statisticsService).getRatio(20);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
package vassistent.service;
|
|
||||||
|
|
||||||
import org.eclipse.paho.client.mqttv3.MqttClient;
|
|
||||||
import org.eclipse.paho.client.mqttv3.MqttException;
|
|
||||||
import org.eclipse.paho.client.mqttv3.MqttMessage;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.mockito.ArgumentCaptor;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
import vassistent.model.AppState;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
|
||||||
import static org.mockito.Mockito.doThrow;
|
|
||||||
import static org.mockito.Mockito.never;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit tests for {@link MqttClientService}.
|
|
||||||
*/
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class MqttClientServiceTest {
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private MqttClient mqttClient;
|
|
||||||
|
|
||||||
private AppState appState;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a fresh app state per test.
|
|
||||||
*/
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
appState = new AppState();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies constructor behavior when auto-connect is enabled.
|
|
||||||
*
|
|
||||||
* @throws Exception if mock interaction setup fails
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void constructor_withConnectOnStart_connectsAndSetsState() throws Exception {
|
|
||||||
MqttClientService service = new MqttClientService(appState, mqttClient, true);
|
|
||||||
|
|
||||||
verify(mqttClient).setCallback(service);
|
|
||||||
verify(mqttClient).connect(any());
|
|
||||||
assertTrue(appState.isMqttConnected());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies constructor handles connection failures without throwing.
|
|
||||||
*
|
|
||||||
* @throws Exception if mock interaction setup fails
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void constructor_connectFailure_keepsStateDisconnected() throws Exception {
|
|
||||||
doThrow(new MqttException(0)).when(mqttClient).connect(any());
|
|
||||||
|
|
||||||
MqttClientService service = new MqttClientService(appState, mqttClient, true);
|
|
||||||
|
|
||||||
verify(mqttClient).setCallback(service);
|
|
||||||
verify(mqttClient).connect(any());
|
|
||||||
assertFalse(appState.isMqttConnected());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies subscribe registers the listener and routes arriving payloads.
|
|
||||||
*
|
|
||||||
* @throws Exception if callback invocation fails
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void subscribe_thenMessageArrived_routesPayloadToListener() throws Exception {
|
|
||||||
MqttClientService service = new MqttClientService(appState, mqttClient, false);
|
|
||||||
List<String> payloads = new ArrayList<>();
|
|
||||||
|
|
||||||
service.subscribe("PREDICTION", payloads::add);
|
|
||||||
service.messageArrived("PREDICTION", new MqttMessage("data".getBytes(StandardCharsets.UTF_8)));
|
|
||||||
|
|
||||||
verify(mqttClient).subscribe("PREDICTION");
|
|
||||||
assertEquals(List.of("data"), payloads);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies publish forwards a message with the configured QoS and payload.
|
|
||||||
*
|
|
||||||
* @throws Exception if mock interaction setup fails
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void publish_forwardsMessageWithQos() throws Exception {
|
|
||||||
MqttClientService service = new MqttClientService(appState, mqttClient, false);
|
|
||||||
ArgumentCaptor<MqttMessage> captor = ArgumentCaptor.forClass(MqttMessage.class);
|
|
||||||
|
|
||||||
service.publish("PREDICTION", "hello", 1);
|
|
||||||
|
|
||||||
verify(mqttClient).publish(org.mockito.ArgumentMatchers.eq("PREDICTION"), captor.capture());
|
|
||||||
MqttMessage published = captor.getValue();
|
|
||||||
assertEquals("hello", new String(published.getPayload(), StandardCharsets.UTF_8));
|
|
||||||
assertEquals(1, published.getQos());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies disconnect only calls client disconnect when connection is active.
|
|
||||||
*
|
|
||||||
* @throws Exception if mock interaction setup fails
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void disconnect_onlyWhenConnected() throws Exception {
|
|
||||||
MqttClientService service = new MqttClientService(appState, mqttClient, false);
|
|
||||||
|
|
||||||
when(mqttClient.isConnected()).thenReturn(false);
|
|
||||||
service.disconnect();
|
|
||||||
verify(mqttClient, never()).disconnect();
|
|
||||||
|
|
||||||
when(mqttClient.isConnected()).thenReturn(true);
|
|
||||||
service.disconnect();
|
|
||||||
verify(mqttClient).disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies connection loss updates app state to disconnected.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void connectionLost_setsMqttStateFalse() {
|
|
||||||
appState.setMqttConnected(true);
|
|
||||||
MqttClientService service = new MqttClientService(appState, mqttClient, false);
|
|
||||||
|
|
||||||
service.connectionLost(new RuntimeException("lost"));
|
|
||||||
|
|
||||||
assertFalse(appState.isMqttConnected());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies unknown topics do not fail when a message arrives.
|
|
||||||
*
|
|
||||||
* @throws Exception if callback invocation fails
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void messageArrived_unknownTopic_doesNotThrow() throws Exception {
|
|
||||||
MqttClientService service = new MqttClientService(appState, mqttClient, false);
|
|
||||||
|
|
||||||
service.messageArrived("UNKNOWN", new MqttMessage("x".getBytes(StandardCharsets.UTF_8)));
|
|
||||||
|
|
||||||
verify(mqttClient, never()).subscribe("UNKNOWN");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
package vassistent.service;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Properties;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit tests for {@link ProcessManagerService} using an injected process launcher.
|
|
||||||
*/
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class ProcessManagerServiceTest {
|
|
||||||
|
|
||||||
@TempDir
|
|
||||||
Path tempDir;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private Process process;
|
|
||||||
|
|
||||||
private Properties config;
|
|
||||||
private List<List<String>> launchedCommands;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes test configuration and command capture.
|
|
||||||
*
|
|
||||||
* @throws Exception if process mock setup fails
|
|
||||||
*/
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() throws Exception {
|
|
||||||
config = new Properties();
|
|
||||||
launchedCommands = new ArrayList<>();
|
|
||||||
config.setProperty("process.startup.healthcheck.enabled", "false");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a service instance with command capture and test PID files.
|
|
||||||
*
|
|
||||||
* @return configured process manager service
|
|
||||||
*/
|
|
||||||
private ProcessManagerService createService() {
|
|
||||||
ProcessManagerService.ProcessLauncher launcher = processBuilder -> {
|
|
||||||
launchedCommands.add(new ArrayList<>(processBuilder.command()));
|
|
||||||
return process;
|
|
||||||
};
|
|
||||||
|
|
||||||
return new ProcessManagerService(
|
|
||||||
config,
|
|
||||||
launcher,
|
|
||||||
tempDir.resolve("unreal.pid").toString(),
|
|
||||||
tempDir.resolve("signalling.pid").toString()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stubs process wait behavior for taskkill command execution.
|
|
||||||
*
|
|
||||||
* @throws Exception if mock stubbing fails
|
|
||||||
*/
|
|
||||||
private void stubProcessWaitFor() throws Exception {
|
|
||||||
when(process.waitFor()).thenReturn(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stubs process metadata needed by shutdown of tracked processes.
|
|
||||||
*
|
|
||||||
* @throws Exception if mock stubbing fails
|
|
||||||
*/
|
|
||||||
private void stubProcessForTrackedShutdown() throws Exception {
|
|
||||||
stubProcessWaitFor();
|
|
||||||
when(process.pid()).thenReturn(123L);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enables startup health checks with short polling and timeout for unit tests.
|
|
||||||
*/
|
|
||||||
private void enableFastStartupHealthcheck() {
|
|
||||||
config.setProperty("process.startup.healthcheck.enabled", "true");
|
|
||||||
config.setProperty("process.startup.healthcheck.timeout.millis", "20");
|
|
||||||
config.setProperty("process.startup.healthcheck.poll.millis", "1");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies that no processes are launched when all features are disabled.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void startProcesses_whenDisabled_launchesNothing() {
|
|
||||||
config.setProperty("mqtt_sim.enabled", "false");
|
|
||||||
config.setProperty("unreal.enabled", "false");
|
|
||||||
|
|
||||||
ProcessManagerService service = createService();
|
|
||||||
ProcessManagerService.StartupReport report = service.startProcesses();
|
|
||||||
|
|
||||||
assertTrue(launchedCommands.isEmpty());
|
|
||||||
assertNull(service.getPythonProcess());
|
|
||||||
assertNull(service.getUnrealProcess());
|
|
||||||
assertFalse(report.hasFailures());
|
|
||||||
assertFalse(report.getMqttSimulator().isEnabled());
|
|
||||||
assertFalse(report.getUnreal().isEnabled());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies MQTT simulator launch command when simulator is enabled.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void startProcesses_mqttEnabled_launchesPythonSimulator() {
|
|
||||||
config.setProperty("mqtt_sim.enabled", "true");
|
|
||||||
config.setProperty("mqtt_sim.script", "sim.py");
|
|
||||||
config.setProperty("python.path", "python");
|
|
||||||
config.setProperty("unreal.enabled", "false");
|
|
||||||
|
|
||||||
ProcessManagerService service = createService();
|
|
||||||
ProcessManagerService.StartupReport report = service.startProcesses();
|
|
||||||
|
|
||||||
assertEquals(1, launchedCommands.size());
|
|
||||||
assertEquals(List.of("python", "sim.py"), launchedCommands.get(0));
|
|
||||||
assertSame(process, service.getPythonProcess());
|
|
||||||
assertFalse(report.hasFailures());
|
|
||||||
assertTrue(report.getMqttSimulator().isStarted());
|
|
||||||
assertFalse(report.getUnreal().isEnabled());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies Unreal launch command when Unreal integration is enabled.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void startProcesses_unrealEnabled_launchesPowerShell() {
|
|
||||||
config.setProperty("mqtt_sim.enabled", "false");
|
|
||||||
config.setProperty("unreal.enabled", "true");
|
|
||||||
config.setProperty("unreal.executable", "avatar.ps1");
|
|
||||||
config.setProperty("unreal.signalling_server.script", "start_signal.bat");
|
|
||||||
|
|
||||||
ProcessManagerService service = createService();
|
|
||||||
ProcessManagerService.StartupReport report = service.startProcesses();
|
|
||||||
|
|
||||||
assertEquals(1, launchedCommands.size());
|
|
||||||
assertEquals(
|
|
||||||
List.of("powershell.exe", "-ExecutionPolicy", "Bypass", "-File", "avatar.ps1"),
|
|
||||||
launchedCommands.get(0)
|
|
||||||
);
|
|
||||||
assertTrue(
|
|
||||||
launchedCommands.stream()
|
|
||||||
.noneMatch(command -> !command.isEmpty() && "cmd".equalsIgnoreCase(command.get(0)))
|
|
||||||
);
|
|
||||||
assertSame(process, service.getUnrealProcess());
|
|
||||||
assertFalse(report.hasFailures());
|
|
||||||
assertTrue(report.getUnreal().isStarted());
|
|
||||||
assertFalse(report.getMqttSimulator().isEnabled());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies Unreal startup enables merged error stream before process launch.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void startProcesses_unrealEnabled_redirectsErrorBeforeLaunch() {
|
|
||||||
config.setProperty("mqtt_sim.enabled", "false");
|
|
||||||
config.setProperty("unreal.enabled", "true");
|
|
||||||
config.setProperty("unreal.executable", "avatar.ps1");
|
|
||||||
|
|
||||||
final boolean[] redirectEnabledAtLaunch = new boolean[1];
|
|
||||||
|
|
||||||
ProcessManagerService.ProcessLauncher launcher = processBuilder -> {
|
|
||||||
redirectEnabledAtLaunch[0] = processBuilder.redirectErrorStream();
|
|
||||||
return process;
|
|
||||||
};
|
|
||||||
|
|
||||||
ProcessManagerService service = new ProcessManagerService(
|
|
||||||
config,
|
|
||||||
launcher,
|
|
||||||
tempDir.resolve("unreal.pid").toString(),
|
|
||||||
tempDir.resolve("signalling.pid").toString()
|
|
||||||
);
|
|
||||||
|
|
||||||
ProcessManagerService.StartupReport report = service.startProcesses();
|
|
||||||
|
|
||||||
assertFalse(report.hasFailures());
|
|
||||||
assertTrue(redirectEnabledAtLaunch[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies MQTT startup health check reports failure when process is not alive.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void startProcesses_mqttHealthcheckFails_reportsFailure() {
|
|
||||||
enableFastStartupHealthcheck();
|
|
||||||
config.setProperty("mqtt_sim.enabled", "true");
|
|
||||||
config.setProperty("mqtt_sim.script", "sim.py");
|
|
||||||
config.setProperty("python.path", "python");
|
|
||||||
config.setProperty("unreal.enabled", "false");
|
|
||||||
when(process.isAlive()).thenReturn(false);
|
|
||||||
|
|
||||||
ProcessManagerService service = createService();
|
|
||||||
ProcessManagerService.StartupReport report = service.startProcesses();
|
|
||||||
|
|
||||||
assertTrue(report.hasFailures());
|
|
||||||
assertTrue(report.getMqttSimulator().hasFailed());
|
|
||||||
assertTrue(report.getMqttSimulator().getErrorMessage().contains("health check"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies Unreal startup health check fails when PID files are missing.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void startProcesses_unrealHealthcheckMissingPidFiles_reportsFailure() {
|
|
||||||
enableFastStartupHealthcheck();
|
|
||||||
config.setProperty("mqtt_sim.enabled", "false");
|
|
||||||
config.setProperty("unreal.enabled", "true");
|
|
||||||
config.setProperty("unreal.executable", "avatar.ps1");
|
|
||||||
|
|
||||||
ProcessManagerService service = createService();
|
|
||||||
ProcessManagerService.StartupReport report = service.startProcesses();
|
|
||||||
|
|
||||||
assertTrue(report.hasFailures());
|
|
||||||
assertTrue(report.getUnreal().hasFailed());
|
|
||||||
assertTrue(report.getUnreal().getErrorMessage().contains("health check"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies Unreal startup health check succeeds when PID files contain alive process IDs.
|
|
||||||
*
|
|
||||||
* @throws Exception if PID files cannot be created
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void startProcesses_unrealHealthcheckWithAlivePidFiles_startsSuccessfully() throws Exception {
|
|
||||||
enableFastStartupHealthcheck();
|
|
||||||
config.setProperty("mqtt_sim.enabled", "false");
|
|
||||||
config.setProperty("unreal.enabled", "true");
|
|
||||||
config.setProperty("unreal.executable", "avatar.ps1");
|
|
||||||
|
|
||||||
Path unrealPid = tempDir.resolve("unreal.pid");
|
|
||||||
Path signallingPid = tempDir.resolve("signalling.pid");
|
|
||||||
long currentPid = ProcessHandle.current().pid();
|
|
||||||
|
|
||||||
ProcessManagerService.ProcessLauncher launcher = processBuilder -> {
|
|
||||||
launchedCommands.add(new ArrayList<>(processBuilder.command()));
|
|
||||||
Files.writeString(unrealPid, String.valueOf(currentPid));
|
|
||||||
Files.writeString(signallingPid, String.valueOf(currentPid));
|
|
||||||
return process;
|
|
||||||
};
|
|
||||||
|
|
||||||
ProcessManagerService service = new ProcessManagerService(
|
|
||||||
config,
|
|
||||||
launcher,
|
|
||||||
unrealPid.toString(),
|
|
||||||
signallingPid.toString()
|
|
||||||
);
|
|
||||||
|
|
||||||
ProcessManagerService.StartupReport report = service.startProcesses();
|
|
||||||
|
|
||||||
assertFalse(report.hasFailures());
|
|
||||||
assertTrue(report.getUnreal().isStarted());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies shutdown issues taskkill commands for tracked process handles.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void shutdown_withTrackedProcesses_issuesTaskkillCommands() throws Exception {
|
|
||||||
stubProcessForTrackedShutdown();
|
|
||||||
|
|
||||||
config.setProperty("mqtt_sim.enabled", "true");
|
|
||||||
config.setProperty("mqtt_sim.script", "sim.py");
|
|
||||||
config.setProperty("python.path", "python");
|
|
||||||
config.setProperty("unreal.enabled", "true");
|
|
||||||
config.setProperty("unreal.executable", "avatar.ps1");
|
|
||||||
config.setProperty("unreal.signalling_server.script", "start_signal.bat");
|
|
||||||
|
|
||||||
ProcessManagerService service = createService();
|
|
||||||
ProcessManagerService.StartupReport report = service.startProcesses();
|
|
||||||
service.shutdown();
|
|
||||||
|
|
||||||
long taskkillCount = launchedCommands.stream()
|
|
||||||
.filter(command -> !command.isEmpty() && "taskkill".equals(command.get(0)))
|
|
||||||
.count();
|
|
||||||
|
|
||||||
assertEquals(2, taskkillCount);
|
|
||||||
assertFalse(report.hasFailures());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies shutdown uses PID files when present.
|
|
||||||
*
|
|
||||||
* @throws Exception if temporary PID files cannot be written
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void shutdown_withPidFiles_issuesPidTaskkillCommands() throws Exception {
|
|
||||||
stubProcessWaitFor();
|
|
||||||
|
|
||||||
Path unrealPid = tempDir.resolve("unreal.pid");
|
|
||||||
Path signallingPid = tempDir.resolve("signalling.pid");
|
|
||||||
Files.writeString(unrealPid, "777");
|
|
||||||
Files.writeString(signallingPid, "888");
|
|
||||||
|
|
||||||
ProcessManagerService.ProcessLauncher launcher = processBuilder -> {
|
|
||||||
launchedCommands.add(new ArrayList<>(processBuilder.command()));
|
|
||||||
return process;
|
|
||||||
};
|
|
||||||
|
|
||||||
ProcessManagerService service = new ProcessManagerService(
|
|
||||||
config,
|
|
||||||
launcher,
|
|
||||||
unrealPid.toString(),
|
|
||||||
signallingPid.toString()
|
|
||||||
);
|
|
||||||
|
|
||||||
service.shutdown();
|
|
||||||
|
|
||||||
assertTrue(launchedCommands.stream().anyMatch(command -> command.contains("777")));
|
|
||||||
assertTrue(launchedCommands.stream().anyMatch(command -> command.contains("888")));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies a failed simulator launch is reported as startup failure.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void startProcesses_mqttLaunchFails_reportsFailure() {
|
|
||||||
config.setProperty("mqtt_sim.enabled", "true");
|
|
||||||
config.setProperty("mqtt_sim.script", "sim.py");
|
|
||||||
config.setProperty("python.path", "python");
|
|
||||||
config.setProperty("unreal.enabled", "false");
|
|
||||||
|
|
||||||
ProcessManagerService.ProcessLauncher failingLauncher = processBuilder -> {
|
|
||||||
throw new java.io.IOException("python not found");
|
|
||||||
};
|
|
||||||
|
|
||||||
ProcessManagerService service = new ProcessManagerService(
|
|
||||||
config,
|
|
||||||
failingLauncher,
|
|
||||||
tempDir.resolve("unreal.pid").toString(),
|
|
||||||
tempDir.resolve("signalling.pid").toString()
|
|
||||||
);
|
|
||||||
|
|
||||||
ProcessManagerService.StartupReport report = service.startProcesses();
|
|
||||||
|
|
||||||
assertTrue(report.hasFailures());
|
|
||||||
assertTrue(report.getMqttSimulator().hasFailed());
|
|
||||||
assertTrue(report.buildFailureMessage().contains("mqtt_sim"));
|
|
||||||
assertTrue(report.buildFailureMessage().contains("python not found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies a failed Unreal launch is reported as startup failure.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void startProcesses_unrealLaunchFails_reportsFailure() {
|
|
||||||
config.setProperty("mqtt_sim.enabled", "false");
|
|
||||||
config.setProperty("unreal.enabled", "true");
|
|
||||||
config.setProperty("unreal.executable", "avatar.ps1");
|
|
||||||
|
|
||||||
ProcessManagerService.ProcessLauncher failingLauncher = processBuilder -> {
|
|
||||||
throw new java.io.IOException("powershell unavailable");
|
|
||||||
};
|
|
||||||
|
|
||||||
ProcessManagerService service = new ProcessManagerService(
|
|
||||||
config,
|
|
||||||
failingLauncher,
|
|
||||||
tempDir.resolve("unreal.pid").toString(),
|
|
||||||
tempDir.resolve("signalling.pid").toString()
|
|
||||||
);
|
|
||||||
|
|
||||||
ProcessManagerService.StartupReport report = service.startProcesses();
|
|
||||||
|
|
||||||
assertTrue(report.hasFailures());
|
|
||||||
assertTrue(report.getUnreal().hasFailed());
|
|
||||||
assertTrue(report.buildFailureMessage().contains("unreal"));
|
|
||||||
assertTrue(report.buildFailureMessage().contains("powershell unavailable"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
package vassistent.service;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
import vassistent.model.DatabaseEntry;
|
|
||||||
import vassistent.model.RatioPoint;
|
|
||||||
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit and integration tests for {@link StatisticsService}.
|
|
||||||
*/
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class StatisticsServiceTest {
|
|
||||||
|
|
||||||
@TempDir
|
|
||||||
Path tempDir;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private DataPersistenceService mockedPersistenceService;
|
|
||||||
|
|
||||||
private StatisticsService unitService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates unit-level service instance with mocked persistence dependency.
|
|
||||||
*/
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
unitService = new StatisticsService(mockedPersistenceService);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies rolling average output for a deterministic input sequence.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void getLastNAverages_computesExpectedRollingValues() {
|
|
||||||
LocalDateTime t0 = LocalDateTime.now();
|
|
||||||
List<DatabaseEntry> entries = List.of(
|
|
||||||
new DatabaseEntry(t0, 1),
|
|
||||||
new DatabaseEntry(t0.plusSeconds(1), 0),
|
|
||||||
new DatabaseEntry(t0.plusSeconds(2), 1)
|
|
||||||
);
|
|
||||||
when(mockedPersistenceService.getLastEntries(3)).thenReturn(entries);
|
|
||||||
|
|
||||||
List<RatioPoint> result = unitService.getLastNAverages(2);
|
|
||||||
|
|
||||||
assertEquals(3, result.size());
|
|
||||||
assertEquals(1.0, result.get(0).getRatio(), 0.0001);
|
|
||||||
assertEquals(0.5, result.get(1).getRatio(), 0.0001);
|
|
||||||
assertEquals(0.5, result.get(2).getRatio(), 0.0001);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies that empty persistence result yields an empty chart result.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void getLastNAverages_withNoEntries_returnsEmptyList() {
|
|
||||||
when(mockedPersistenceService.getLastEntries(6)).thenReturn(List.of());
|
|
||||||
|
|
||||||
List<RatioPoint> result = unitService.getLastNAverages(5);
|
|
||||||
|
|
||||||
assertTrue(result.isEmpty());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies that non-positive window sizes yield an empty result.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void getLastNAverages_withNonPositiveWindow_returnsEmptyList() {
|
|
||||||
List<RatioPoint> result = unitService.getLastNAverages(0);
|
|
||||||
|
|
||||||
assertTrue(result.isEmpty());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies average ratio over newest values using real SQLite persistence.
|
|
||||||
*
|
|
||||||
* @throws InterruptedException if sleeps used to separate timestamps are interrupted
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void getRatio_returnsAverageOfLastN() throws InterruptedException {
|
|
||||||
String dbUrl = "jdbc:sqlite:" + tempDir.resolve("ratio-test.db").toAbsolutePath();
|
|
||||||
DataPersistenceService integrationPersistence = new DataPersistenceService(dbUrl);
|
|
||||||
StatisticsService integrationService = new StatisticsService(integrationPersistence);
|
|
||||||
|
|
||||||
integrationPersistence.store(1);
|
|
||||||
Thread.sleep(5);
|
|
||||||
integrationPersistence.store(0);
|
|
||||||
Thread.sleep(5);
|
|
||||||
integrationPersistence.store(1);
|
|
||||||
|
|
||||||
double ratio = integrationService.getRatio(3);
|
|
||||||
|
|
||||||
assertEquals(2.0 / 3.0, ratio, 0.0001);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies that requesting a ratio on an empty dataset returns zero.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void getRatio_withNoData_returnsZero() {
|
|
||||||
String dbUrl = "jdbc:sqlite:" + tempDir.resolve("ratio-empty.db").toAbsolutePath();
|
|
||||||
DataPersistenceService integrationPersistence = new DataPersistenceService(dbUrl);
|
|
||||||
StatisticsService integrationService = new StatisticsService(integrationPersistence);
|
|
||||||
|
|
||||||
double ratio = integrationService.getRatio(20);
|
|
||||||
|
|
||||||
assertEquals(0.0, ratio, 0.0001);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies that the newest rolling average matches {@link StatisticsService#getRatio(int)}
|
|
||||||
* for the same window size.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void getLastNAverages_lastPointMatchesGetRatio() {
|
|
||||||
String dbUrl = "jdbc:sqlite:" + tempDir.resolve("ratio-rolling.db").toAbsolutePath();
|
|
||||||
DataPersistenceService integrationPersistence = new DataPersistenceService(dbUrl);
|
|
||||||
StatisticsService integrationService = new StatisticsService(integrationPersistence);
|
|
||||||
|
|
||||||
integrationPersistence.store(1);
|
|
||||||
integrationPersistence.store(0);
|
|
||||||
integrationPersistence.store(1);
|
|
||||||
integrationPersistence.store(1);
|
|
||||||
|
|
||||||
int window = 3;
|
|
||||||
|
|
||||||
List<RatioPoint> rolling = integrationService.getLastNAverages(window);
|
|
||||||
double ratio = integrationService.getRatio(window);
|
|
||||||
|
|
||||||
assertFalse(rolling.isEmpty());
|
|
||||||
assertEquals(ratio, rolling.get(rolling.size() - 1).getRatio(), 0.0001);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
package vassistent.util;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
|
||||||
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.Properties;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit tests for {@link AppConfigValidator}.
|
|
||||||
*/
|
|
||||||
class AppConfigValidatorTest {
|
|
||||||
|
|
||||||
@TempDir
|
|
||||||
Path tempDir;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies that a minimal core configuration passes validation.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void validate_withCoreSettingsOnly_doesNotThrow() {
|
|
||||||
Properties config = baseConfig();
|
|
||||||
|
|
||||||
assertDoesNotThrow(() -> AppConfigValidator.validate(config));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies strict boolean validation for feature toggles.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void validate_withInvalidBooleanToggle_throws() {
|
|
||||||
Properties config = baseConfig();
|
|
||||||
config.setProperty("mqtt_sim.enabled", "yes");
|
|
||||||
|
|
||||||
IllegalStateException exception =
|
|
||||||
assertThrows(
|
|
||||||
IllegalStateException.class,
|
|
||||||
() -> AppConfigValidator.validate(config)
|
|
||||||
);
|
|
||||||
|
|
||||||
assertTrue(exception.getMessage().contains("mqtt_sim.enabled"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies strict boolean validation for startup health-check toggles.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void validate_withInvalidStartupHealthcheckBoolean_throws() {
|
|
||||||
Properties config = baseConfig();
|
|
||||||
config.setProperty("process.startup.healthcheck.enabled", "enabled");
|
|
||||||
|
|
||||||
IllegalStateException exception =
|
|
||||||
assertThrows(
|
|
||||||
IllegalStateException.class,
|
|
||||||
() -> AppConfigValidator.validate(config)
|
|
||||||
);
|
|
||||||
|
|
||||||
assertTrue(
|
|
||||||
exception.getMessage().contains("process.startup.healthcheck.enabled")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies startup health-check timeout must be a positive integer.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void validate_withNonPositiveStartupHealthcheckTimeout_throws() {
|
|
||||||
Properties config = baseConfig();
|
|
||||||
config.setProperty("process.startup.healthcheck.timeout.millis", "0");
|
|
||||||
|
|
||||||
IllegalStateException exception =
|
|
||||||
assertThrows(
|
|
||||||
IllegalStateException.class,
|
|
||||||
() -> AppConfigValidator.validate(config)
|
|
||||||
);
|
|
||||||
|
|
||||||
assertTrue(
|
|
||||||
exception.getMessage().contains("process.startup.healthcheck.timeout.millis")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies simulator-specific numeric validation when simulator is enabled.
|
|
||||||
*
|
|
||||||
* @throws Exception if test files cannot be created
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void validate_withMqttSimulatorInvalidPort_throws() throws Exception {
|
|
||||||
Properties config = baseConfig();
|
|
||||||
Path pythonExe = Files.createFile(tempDir.resolve("python.exe"));
|
|
||||||
Path simulatorScript = Files.createFile(tempDir.resolve("mqtt_simulator.py"));
|
|
||||||
|
|
||||||
config.setProperty("mqtt_sim.enabled", "true");
|
|
||||||
config.setProperty("python.path", pythonExe.toString());
|
|
||||||
config.setProperty("mqtt_sim.script", simulatorScript.toString());
|
|
||||||
config.setProperty("mqtt_sim.broker", "localhost");
|
|
||||||
config.setProperty("mqtt_sim.port", "99999");
|
|
||||||
|
|
||||||
IllegalStateException exception =
|
|
||||||
assertThrows(
|
|
||||||
IllegalStateException.class,
|
|
||||||
() -> AppConfigValidator.validate(config)
|
|
||||||
);
|
|
||||||
|
|
||||||
assertTrue(exception.getMessage().contains("mqtt_sim.port"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies Unreal validation detects paths with mismatched quote wrappers.
|
|
||||||
*
|
|
||||||
* @throws Exception if test files cannot be created
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void validate_withUnrealSignallingPathTrailingQuote_throws() throws Exception {
|
|
||||||
Properties config = baseConfig();
|
|
||||||
Path launcherScript = Files.createFile(tempDir.resolve("start_avatar.ps1"));
|
|
||||||
Path signallingScript = Files.createFile(tempDir.resolve("start_with_stun.bat"));
|
|
||||||
Path unrealExe = Files.createFile(tempDir.resolve("Prototyp1.exe"));
|
|
||||||
Path unrealWorkDir = Files.createDirectory(tempDir.resolve("Windows"));
|
|
||||||
Path pidDir = Files.createDirectory(tempDir.resolve("pids"));
|
|
||||||
|
|
||||||
config.setProperty("unreal.enabled", "true");
|
|
||||||
config.setProperty("unreal.executable", launcherScript.toString());
|
|
||||||
config.setProperty(
|
|
||||||
"unreal.signalling_server.script",
|
|
||||||
signallingScript.toString() + "\""
|
|
||||||
);
|
|
||||||
config.setProperty("unreal.target.executable", unrealExe.toString());
|
|
||||||
config.setProperty("unreal.target.working_dir", unrealWorkDir.toString());
|
|
||||||
config.setProperty("unreal.pid.dir", pidDir.toString());
|
|
||||||
|
|
||||||
IllegalStateException exception =
|
|
||||||
assertThrows(
|
|
||||||
IllegalStateException.class,
|
|
||||||
() -> AppConfigValidator.validate(config)
|
|
||||||
);
|
|
||||||
|
|
||||||
assertTrue(exception.getMessage().contains("unreal.signalling_server.script"));
|
|
||||||
assertTrue(exception.getMessage().toLowerCase().contains("quote"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies Unreal validation accepts a complete and valid local setup.
|
|
||||||
*
|
|
||||||
* @throws Exception if test files cannot be created
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void validate_withUnrealEnabledAndValidPaths_doesNotThrow() throws Exception {
|
|
||||||
Properties config = baseConfig();
|
|
||||||
Path launcherScript = Files.createFile(tempDir.resolve("start_avatar.ps1"));
|
|
||||||
Path signallingScript = Files.createFile(tempDir.resolve("start_with_stun.bat"));
|
|
||||||
Path unrealExe = Files.createFile(tempDir.resolve("Prototyp1.exe"));
|
|
||||||
Path unrealWorkDir = Files.createDirectory(tempDir.resolve("Windows"));
|
|
||||||
Path pidDir = Files.createDirectory(tempDir.resolve("pids"));
|
|
||||||
|
|
||||||
config.setProperty("unreal.enabled", "true");
|
|
||||||
config.setProperty("unreal.executable", launcherScript.toString());
|
|
||||||
config.setProperty("unreal.signalling_server.script", signallingScript.toString());
|
|
||||||
config.setProperty("unreal.target.executable", unrealExe.toString());
|
|
||||||
config.setProperty("unreal.target.working_dir", unrealWorkDir.toString());
|
|
||||||
config.setProperty("unreal.pid.dir", pidDir.toString());
|
|
||||||
config.setProperty("unreal.startup.delay.seconds", "5");
|
|
||||||
config.setProperty("unreal.pid.file", pidDir.resolve("unreal.pid").toString());
|
|
||||||
config.setProperty(
|
|
||||||
"unreal.signalling.pid.file",
|
|
||||||
pidDir.resolve("signalling.pid").toString()
|
|
||||||
);
|
|
||||||
|
|
||||||
assertDoesNotThrow(() -> AppConfigValidator.validate(config));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a baseline config with optional integrations disabled.
|
|
||||||
*
|
|
||||||
* @return baseline application configuration
|
|
||||||
*/
|
|
||||||
private Properties baseConfig() {
|
|
||||||
Properties config = new Properties();
|
|
||||||
config.setProperty("mqtt.topic", "PREDICTION");
|
|
||||||
config.setProperty("mqtt.broker.url", "tcp://localhost:1883");
|
|
||||||
config.setProperty("mqtt.client.id", "JavaClientPublisherSubscriber");
|
|
||||||
config.setProperty("streaming.url", "http://localhost");
|
|
||||||
config.setProperty("animation.output.path", "data/animation.json");
|
|
||||||
config.setProperty("mqtt_sim.enabled", "false");
|
|
||||||
config.setProperty("unreal.enabled", "false");
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user