Initial
This commit is contained in:
14
README.md
Normal file
14
README.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# cloud-flight-tray
|
||||||
|
A tray service for the HyperX Cloud Flight headset
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
Requires at least Java `>=18`.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
First, build the project by invoking `mvn package`. Then copy the result artifact `target/cloud-flight-tray-X.jar` to
|
||||||
|
`~/.local/share/java/cloud_flight_tray/cloud-flight-tray.jar`. After that copy the systemd template service from
|
||||||
|
`tools/template-cloud-flight-tray.service` to `~/.config/systemd/user/cloud-flight-tray.service`. Finally, reload the
|
||||||
|
systemd services via `systemctl --user daemon-reload` and start the service via `systemctl --user enable --now cloud-flight-tray`
|
||||||
|
|
||||||
|
## Links
|
||||||
|
This project was heavily inspired by [kondinskis/hyperx-cloud-flight](https://github.com/kondinskis/hyperx-cloud-flight)
|
||||||
151
pom.xml
Normal file
151
pom.xml
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<?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"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<groupId>de.77zzcx7</groupId>
|
||||||
|
<artifactId>cloud-flight-tray</artifactId>
|
||||||
|
<version>1-SNAPSHOT</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
<description>Tray icon for HyperX Cloud Flight Wireless headset that shows e.g. the battery state</description>
|
||||||
|
<name>cloud-flight-tray</name>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<java.version>18</java.version>
|
||||||
|
<scmDeveloperConnectionProp/>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<distributionManagement>
|
||||||
|
<snapshotRepository>
|
||||||
|
<id>77zzcx7-snapshots</id>
|
||||||
|
<url>http://192.168.10.4:8100/snapshots/</url>
|
||||||
|
</snapshotRepository>
|
||||||
|
<repository>
|
||||||
|
<id>77zzcx7-releases</id>
|
||||||
|
<url>http://192.168.10.4:8100/releases/</url>
|
||||||
|
</repository>
|
||||||
|
</distributionManagement>
|
||||||
|
|
||||||
|
<scm>
|
||||||
|
<connection>scm:git:https://77zzcx7.de/gitea/MK13/CloudFlightTray.git</connection>
|
||||||
|
<developerConnection>${scmDeveloperConnectionProp}</developerConnection>
|
||||||
|
<url>https://77zzcx7.de/gitea/MK13/CloudFlightTray</url>
|
||||||
|
<tag>v1</tag>
|
||||||
|
</scm>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>ch.qos.logback</groupId>
|
||||||
|
<artifactId>logback-core</artifactId>
|
||||||
|
<version>1.2.11</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>ch.qos.logback</groupId>
|
||||||
|
<artifactId>logback-classic</artifactId>
|
||||||
|
<version>1.2.11</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.slf4j</groupId>
|
||||||
|
<artifactId>slf4j-api</artifactId>
|
||||||
|
<version>1.7.36</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.taksan</groupId>
|
||||||
|
<artifactId>native-tray-adapter</artifactId>
|
||||||
|
<version>1.1</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<pluginManagement>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<source>18</source>
|
||||||
|
<target>18</target>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>fr.jcgay.maven.plugins</groupId>
|
||||||
|
<artifactId>buildplan-maven-plugin</artifactId>
|
||||||
|
<version>1.5</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>build-plan</id>
|
||||||
|
<phase>initialize</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>list</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</pluginManagement>
|
||||||
|
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>fr.jcgay.maven.plugins</groupId>
|
||||||
|
<artifactId>buildplan-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<source>16</source>
|
||||||
|
<target>16</target>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-release-plugin</artifactId>
|
||||||
|
<version>3.0.0-M5</version>
|
||||||
|
<configuration>
|
||||||
|
<tagNameFormat>v@{project.version}</tagNameFormat>
|
||||||
|
<autoVersionSubmodules>true</autoVersionSubmodules>
|
||||||
|
<signTag>true</signTag>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-jar-plugin</artifactId>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>default-jar</id>
|
||||||
|
<phase>none</phase>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-assembly-plugin</artifactId>
|
||||||
|
<version>3.4.2</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>single</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<archive>
|
||||||
|
<manifest>
|
||||||
|
<mainClass>
|
||||||
|
de.cloud.flight.tray.CloudFlightTray
|
||||||
|
</mainClass>
|
||||||
|
</manifest>
|
||||||
|
</archive>
|
||||||
|
<descriptorRefs>
|
||||||
|
<descriptorRef>jar-with-dependencies</descriptorRef>
|
||||||
|
</descriptorRefs>
|
||||||
|
<finalName>cloud-flight-tray-${project.version}</finalName>
|
||||||
|
<appendAssemblyId>false</appendAssemblyId>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
</project>
|
||||||
121
src/main/java/de/cloud/flight/tray/CloudFlightTray.java
Normal file
121
src/main/java/de/cloud/flight/tray/CloudFlightTray.java
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package de.cloud.flight.tray;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import tray.SystemTrayAdapter;
|
||||||
|
import tray.SystemTrayProvider;
|
||||||
|
import tray.TrayIconAdapter;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.*;
|
||||||
|
import java.io.*;
|
||||||
|
import java.net.MalformedURLException;
|
||||||
|
import java.net.URISyntaxException;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.nio.channels.AsynchronousFileChannel;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
public class CloudFlightTray {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(CloudFlightTray.class);
|
||||||
|
private static final String VENDOR_ID = "0951"; // Kingston HyperX
|
||||||
|
private static final String PRODUCT_ID = "16C4"; // Cloud Flight Wireless
|
||||||
|
private static final String HIDRAW_DEVICE_NAME_PATTERN = "hidraw\\d";
|
||||||
|
|
||||||
|
public static void main(String[] args) throws MalformedURLException {
|
||||||
|
logger.info("Starting CloudFlightTray");
|
||||||
|
|
||||||
|
final String hidrawDeviceName;
|
||||||
|
|
||||||
|
try {
|
||||||
|
hidrawDeviceName = findHidrawDeviceName();
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Could not get hidraw device name", e);
|
||||||
|
|
||||||
|
System.exit(-55);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Path hidrawPath = Paths.get("/dev/" + hidrawDeviceName);
|
||||||
|
final ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1);
|
||||||
|
final BlockingQueue<Packet> queue = new ArrayBlockingQueue<>(1024);
|
||||||
|
final SystemTrayAdapter tray = new SystemTrayProvider().getSystemTray();
|
||||||
|
final TrayIconAdapter trayIcon;
|
||||||
|
|
||||||
|
trayIcon = tray.createAndAddTrayIcon(Images.OFF.getUrl(), "", new PopupMenu());
|
||||||
|
|
||||||
|
// We need to use an Async channel, so we can read and send at the same time.
|
||||||
|
// ByteChannel, for example, blocks if an operation is ongoing but reading
|
||||||
|
// may block indefinitely
|
||||||
|
try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(hidrawPath, StandardOpenOption.READ, StandardOpenOption.WRITE)) {
|
||||||
|
executorService.schedule(new Sender(channel, executorService), 0, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
Thread reader = new Thread(new Reader(channel, queue));
|
||||||
|
Thread trayUpdater = new Thread(new TrayUpdater(queue, trayIcon));
|
||||||
|
|
||||||
|
trayUpdater.start();
|
||||||
|
reader.start();
|
||||||
|
|
||||||
|
trayUpdater.join();
|
||||||
|
reader.join();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
logger.error("Unknown error while reading/sending", ex);
|
||||||
|
|
||||||
|
System.exit(-98);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method finds the hidraw device name
|
||||||
|
*
|
||||||
|
* @return The device name of the hidraw device to use
|
||||||
|
*
|
||||||
|
* @throws Exception if the device name is not in the expected format or the runtime cannot be executed
|
||||||
|
*/
|
||||||
|
private static String findHidrawDeviceName() throws Exception {
|
||||||
|
final String hidrawGlob = buildHidrawGlob();
|
||||||
|
|
||||||
|
logger.info("Query {}", hidrawGlob);
|
||||||
|
|
||||||
|
// We use shell globbing functionality
|
||||||
|
final Process process = Runtime.getRuntime()
|
||||||
|
.exec(new String[]{"sh", "-c", "ls " + hidrawGlob});
|
||||||
|
final String hidrawDevice = new BufferedReader(new InputStreamReader(process.getInputStream())).readLine();
|
||||||
|
|
||||||
|
process.destroy();
|
||||||
|
|
||||||
|
if (Pattern.compile(HIDRAW_DEVICE_NAME_PATTERN).matcher(hidrawDevice).matches()) {
|
||||||
|
logger.info("Found device {}", hidrawDevice);
|
||||||
|
|
||||||
|
return hidrawDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Exception("Unknown device name: " + hidrawDevice);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return a glob path identifying the hid raw device to use
|
||||||
|
*/
|
||||||
|
private static final String buildHidrawGlob() {
|
||||||
|
final StringBuilder sb = new StringBuilder();
|
||||||
|
|
||||||
|
// The important part is the vendor + product ID in the path
|
||||||
|
// that makes the result unique.
|
||||||
|
// But if, e.g. the wireless dongle is inserted to another port on the host,
|
||||||
|
// the PCI IDs are different, so we have to use globs.
|
||||||
|
sb.append("/sys/devices/pci*/*/*/*/*/*");
|
||||||
|
sb.append(VENDOR_ID);
|
||||||
|
sb.append("\\:");
|
||||||
|
sb.append(PRODUCT_ID);
|
||||||
|
// This is actually a folder containing the real name of the device
|
||||||
|
sb.append("*/hidraw");
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/main/java/de/cloud/flight/tray/Images.java
Normal file
21
src/main/java/de/cloud/flight/tray/Images.java
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package de.cloud.flight.tray;
|
||||||
|
|
||||||
|
import java.net.URL;
|
||||||
|
|
||||||
|
public enum Images {
|
||||||
|
DEFAULT("/images/default.png"),
|
||||||
|
CHARGING("/images/charging.png"),
|
||||||
|
LOW("/images/low.png"),
|
||||||
|
MUTED("/images/muted.png"),
|
||||||
|
OFF("/images/off.png");
|
||||||
|
|
||||||
|
private final String name;
|
||||||
|
|
||||||
|
Images(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public URL getUrl() {
|
||||||
|
return this.getClass().getResource(this.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
220
src/main/java/de/cloud/flight/tray/Packet.java
Normal file
220
src/main/java/de/cloud/flight/tray/Packet.java
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
package de.cloud.flight.tray;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public enum Packet {
|
||||||
|
/**
|
||||||
|
* Packet that triggers a response from the device that contains the battery percentage info
|
||||||
|
*/
|
||||||
|
TRIGGER_PACKET(999, -1, new byte[]{0x21, (byte) 0xff, 0x05}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sent by the device if it is powered on
|
||||||
|
*/
|
||||||
|
POWER_ON(1, 2, (bb) -> bb[0] == 0x64 && bb[1] == 0x01),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sent by the device if it is powered off
|
||||||
|
*/
|
||||||
|
POWER_OFF(2, 2, (bb) -> bb[0] == 0x64 && bb[1] == 0x03),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sent if the mute button has been pressed
|
||||||
|
*/
|
||||||
|
MUTED(3, 2, (bb) -> bb[0] == 0x65 && bb[1] == 0x04),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sent if the mute button has been pressed
|
||||||
|
*/
|
||||||
|
UNMUTED(4, 2, (bb) -> bb[0] == 0x65),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sent if the volume control wheel has been used to increase the volume
|
||||||
|
*/
|
||||||
|
VOLUME_UP(5, 5, (bb) -> bb[1] == 0x01),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sent if the volume control wheel has been used to decrease the volume
|
||||||
|
*/
|
||||||
|
VOLUME_DOWN(6, 5, (bb) -> bb[1] == 0x02),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sent if the device has been connected to the charging cable
|
||||||
|
*/
|
||||||
|
BATTERY_CHARGING(7, 20, (bb) -> (bb[3] == 0x10 || bb[3] == 0x11) && bb[4] >= 20),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sent by the device in response to a trigger packet
|
||||||
|
*/
|
||||||
|
BATTERY_STATE(8, 20, (bb) -> !BATTERY_CHARGING.matcher.match(bb)),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If a packet has been received that does not match any other packet
|
||||||
|
*/
|
||||||
|
UNDEFINED(1000, -1);
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(Packet.class);
|
||||||
|
|
||||||
|
private int length;
|
||||||
|
private PacketMatcher matcher;
|
||||||
|
private byte[] proto;
|
||||||
|
private int order;
|
||||||
|
private int batteryPercent;
|
||||||
|
|
||||||
|
Packet(int order, int length) {
|
||||||
|
this.length = length;
|
||||||
|
this.matcher = null;
|
||||||
|
this.proto = null;
|
||||||
|
this.order = order;
|
||||||
|
}
|
||||||
|
|
||||||
|
Packet(int order, int length, PacketMatcher matcher) {
|
||||||
|
this.length = length;
|
||||||
|
this.matcher = matcher;
|
||||||
|
this.proto = null;
|
||||||
|
this.order = order;
|
||||||
|
}
|
||||||
|
|
||||||
|
Packet(int order, int length, byte[] proto) {
|
||||||
|
this.length = length;
|
||||||
|
this.matcher = null;
|
||||||
|
this.proto = proto;
|
||||||
|
this.order = order;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method gets the packed matching the given buffer
|
||||||
|
*
|
||||||
|
* @param bb to get the packet for
|
||||||
|
* @param length the amount of bytes read
|
||||||
|
* @return the packet, never <code>null</code>
|
||||||
|
*/
|
||||||
|
public static Packet getPacket(ByteBuffer bb, int length) {
|
||||||
|
final int bbLength = bb.hasArray() ? bb.array().length : -1;
|
||||||
|
final Optional<byte[]> optBuffer = bb.hasArray() ? Optional.of(bb.array()) : Optional.empty();
|
||||||
|
Packet retPack = UNDEFINED;
|
||||||
|
|
||||||
|
if(bbLength != -1 && optBuffer.isPresent()) {
|
||||||
|
for(Packet p : ordered()) { // ordering required for UNMUTED
|
||||||
|
// The packages have different lengths, so it is the first criteria to filter for
|
||||||
|
if(length == p.length && p.matcher.match(optBuffer.get())) {
|
||||||
|
retPack = p;
|
||||||
|
|
||||||
|
if(retPack == BATTERY_STATE) {
|
||||||
|
retPack.batteryPercent = retPack.parse(bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return retPack;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int getOrder() {
|
||||||
|
return this.order;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the current battery percent, only applicable to {@link #BATTERY_STATE} packet
|
||||||
|
*/
|
||||||
|
public int getBatteryPercent() {
|
||||||
|
return batteryPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Packet> ordered() {
|
||||||
|
List<Packet> retList = Arrays.asList(values());
|
||||||
|
|
||||||
|
retList.sort(Comparator.comparing(Packet::getOrder));
|
||||||
|
|
||||||
|
return retList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the prototype for this packet
|
||||||
|
*/
|
||||||
|
public ByteBuffer getProto() {
|
||||||
|
return ByteBuffer.wrap(this.proto);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int parse(ByteBuffer bb) {
|
||||||
|
if(this != BATTERY_STATE) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte chargeState = bb.get(3);
|
||||||
|
int value = Byte.toUnsignedInt(bb.get(4));
|
||||||
|
|
||||||
|
if(chargeState == 0x0E) {
|
||||||
|
if(value >= 0 && value < 89) {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
if(value >= 90 && value < 119) {
|
||||||
|
return 15;
|
||||||
|
}
|
||||||
|
if(value >= 120 && value < 148) {
|
||||||
|
return 20;
|
||||||
|
}
|
||||||
|
if(value >= 149 && value < 159) {
|
||||||
|
return 25;
|
||||||
|
}
|
||||||
|
if(value >= 160 && value < 169) {
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
if(value >= 170 && value < 179) {
|
||||||
|
return 35;
|
||||||
|
}
|
||||||
|
if(value >= 180 && value < 189) {
|
||||||
|
return 40;
|
||||||
|
}
|
||||||
|
if(value >= 190 && value < 199) {
|
||||||
|
return 45;
|
||||||
|
}
|
||||||
|
if(value >= 200 && value < 209) {
|
||||||
|
return 50;
|
||||||
|
}
|
||||||
|
if(value >= 210 && value < 219) {
|
||||||
|
return 55;
|
||||||
|
}
|
||||||
|
if(value >= 220 && value < 239) {
|
||||||
|
return 60;
|
||||||
|
}
|
||||||
|
if(value >= 240 && value < 255) {
|
||||||
|
return 65;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if(chargeState == 0x0F) {
|
||||||
|
if(value >= 0 && value < 19) {
|
||||||
|
return 70;
|
||||||
|
}
|
||||||
|
if(value >= 20 && value < 49) {
|
||||||
|
return 75;
|
||||||
|
}
|
||||||
|
if(value >= 50 && value < 69) {
|
||||||
|
return 80;
|
||||||
|
}
|
||||||
|
if(value >= 70 && value < 99) {
|
||||||
|
return 85;
|
||||||
|
}
|
||||||
|
if(value >= 100 && value < 119) {
|
||||||
|
return 90;
|
||||||
|
}
|
||||||
|
if(value >= 120 && value < 129) {
|
||||||
|
return 95;
|
||||||
|
}
|
||||||
|
if(value >= 130 && value < 255) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/main/java/de/cloud/flight/tray/PacketMatcher.java
Normal file
5
src/main/java/de/cloud/flight/tray/PacketMatcher.java
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
package de.cloud.flight.tray;
|
||||||
|
|
||||||
|
public interface PacketMatcher {
|
||||||
|
boolean match(byte[] buffer);
|
||||||
|
}
|
||||||
61
src/main/java/de/cloud/flight/tray/Reader.java
Normal file
61
src/main/java/de/cloud/flight/tray/Reader.java
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package de.cloud.flight.tray;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.channels.AsynchronousFileChannel;
|
||||||
|
import java.util.concurrent.BlockingQueue;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reader that reads from the given channel, waiting until data arrives
|
||||||
|
*/
|
||||||
|
record Reader(AsynchronousFileChannel channel, BlockingQueue<Packet> queue) implements Runnable {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(Reader.class);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
while (true) {
|
||||||
|
ByteBuffer bb = ByteBuffer.allocate(32);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final Future<Integer> read = channel.read(bb, 0);
|
||||||
|
final Packet packet = Packet.getPacket(bb, read.get());
|
||||||
|
|
||||||
|
if(packet != Packet.UNDEFINED) {
|
||||||
|
dumpBuffer(bb, read.get());
|
||||||
|
|
||||||
|
queue.offer(packet);
|
||||||
|
}
|
||||||
|
} catch (ExecutionException e) {
|
||||||
|
logger.error("Error while reading", e);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
logger.error("Interrupted while reading", e);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method logs the given buffer.
|
||||||
|
*
|
||||||
|
* @param bb to log
|
||||||
|
* @param length to log
|
||||||
|
*/
|
||||||
|
private static void dumpBuffer(ByteBuffer bb, int length) {
|
||||||
|
final StringBuilder sb = new StringBuilder();
|
||||||
|
|
||||||
|
sb.append(String.format("%d: ", length));
|
||||||
|
|
||||||
|
for (byte b : bb.array()) {
|
||||||
|
sb.append(String.format("%02X ", b));
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append("\n");
|
||||||
|
|
||||||
|
logger.debug(sb.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/main/java/de/cloud/flight/tray/Sender.java
Normal file
27
src/main/java/de/cloud/flight/tray/Sender.java
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package de.cloud.flight.tray;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.nio.channels.AsynchronousFileChannel;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sender that writes data to the given channel, running every
|
||||||
|
*/
|
||||||
|
record Sender(AsynchronousFileChannel channel,
|
||||||
|
ScheduledExecutorService executor) implements Runnable {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(Sender.class);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
logger.debug("Write trigger packet...");
|
||||||
|
|
||||||
|
channel.write(Packet.TRIGGER_PACKET.getProto(), 0);
|
||||||
|
|
||||||
|
logger.debug("Schedule next trigger...");
|
||||||
|
|
||||||
|
executor.schedule(new Sender(channel, executor), 180, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/main/java/de/cloud/flight/tray/TrayUpdater.java
Normal file
73
src/main/java/de/cloud/flight/tray/TrayUpdater.java
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package de.cloud.flight.tray;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import tray.TrayIconAdapter;
|
||||||
|
|
||||||
|
import java.util.concurrent.BlockingQueue;
|
||||||
|
|
||||||
|
record TrayUpdater(BlockingQueue<Packet> queue, TrayIconAdapter trayIcon) implements Runnable {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(TrayUpdater.class);
|
||||||
|
|
||||||
|
private static boolean muted;
|
||||||
|
private static boolean powered = true;
|
||||||
|
private static boolean charging;
|
||||||
|
private static int batteryPercent;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
while (true) {
|
||||||
|
final Packet packet;
|
||||||
|
|
||||||
|
try {
|
||||||
|
packet = queue.take();
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
logger.error("Tray updater was interrupted", e);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (packet) {
|
||||||
|
case MUTED -> muted = true;
|
||||||
|
case UNMUTED -> muted = false;
|
||||||
|
case POWER_ON -> powered = true;
|
||||||
|
case POWER_OFF -> powered = false;
|
||||||
|
case BATTERY_CHARGING -> charging = true;
|
||||||
|
case BATTERY_STATE -> batteryPercent = packet.getBatteryPercent();
|
||||||
|
}
|
||||||
|
|
||||||
|
final StringBuilder sb = new StringBuilder();
|
||||||
|
|
||||||
|
sb.append("Power: ");
|
||||||
|
sb.append(powered ? "on" : "off");
|
||||||
|
sb.append(" | ");
|
||||||
|
sb.append("Charging: ");
|
||||||
|
sb.append(charging ? "yes" : "no");
|
||||||
|
sb.append(" | ");
|
||||||
|
sb.append("Battery: ");
|
||||||
|
sb.append(batteryPercent);
|
||||||
|
sb.append("% | ");
|
||||||
|
sb.append("Muted: ");
|
||||||
|
sb.append(muted ? "yes" : "no");
|
||||||
|
|
||||||
|
logger.info(sb.toString());
|
||||||
|
|
||||||
|
Images image = Images.DEFAULT;
|
||||||
|
|
||||||
|
if(!powered) {
|
||||||
|
image = Images.OFF;
|
||||||
|
}
|
||||||
|
else if(charging) {
|
||||||
|
image = Images.CHARGING;
|
||||||
|
}
|
||||||
|
else if(batteryPercent < 30) {
|
||||||
|
image = Images.LOW;
|
||||||
|
}
|
||||||
|
else if(muted) {
|
||||||
|
image = Images.MUTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
trayIcon.setImage(image.getUrl());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
src/main/resources/images/charging.png
Normal file
BIN
src/main/resources/images/charging.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
src/main/resources/images/default.png
Normal file
BIN
src/main/resources/images/default.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
BIN
src/main/resources/images/low.png
Normal file
BIN
src/main/resources/images/low.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
src/main/resources/images/muted.png
Normal file
BIN
src/main/resources/images/muted.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
src/main/resources/images/off.png
Normal file
BIN
src/main/resources/images/off.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
11
src/main/resources/logback.xml
Normal file
11
src/main/resources/logback.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<configuration>
|
||||||
|
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||||
|
<encoder>
|
||||||
|
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<root level="debug">
|
||||||
|
<appender-ref ref="STDOUT" />
|
||||||
|
</root>
|
||||||
|
</configuration>
|
||||||
16
tools/install.sh
Normal file
16
tools/install.sh
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
version=$1
|
||||||
|
|
||||||
|
echo "Create required directories"
|
||||||
|
mkdir -p ~/.local/share/java/cloud_flight_tray/
|
||||||
|
|
||||||
|
echo "Download cloud-flight-tray version $version to ~/.local/share/java/cloud_flight_tray/cloud-flight-tray.jar"
|
||||||
|
wget -qO ~/.local/share/java/cloud_flight_tray/cloud-flight-tray.jar http://192.168.10.4:8100/releases/de/77zzcx7/cloud-flight-tray/"$version"/cloud-flight-tray-"$version".jar
|
||||||
|
|
||||||
|
echo "Install service to ~/.config/systemd/user/cloud-flight-tray.service"
|
||||||
|
cp "template-cloud-flight-tray.service" ~/.config/systemd/user/cloud-flight-tray.service
|
||||||
|
|
||||||
|
echo "Reload and restart service"
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user restart cloud-flight-tray
|
||||||
9
tools/template-cloud-flight-tray.service
Normal file
9
tools/template-cloud-flight-tray.service
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Cloud Flight tray - A tray service for the HyperX Cloud Flight headset
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=java -jar %h/.local/share/java/cloud_flight_tray/cloud-flight-tray.jar
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Reference in New Issue
Block a user