1
0
This commit is contained in:
2022-08-07 22:49:09 +02:00
commit 0c333fcf0c
17 changed files with 729 additions and 0 deletions

14
README.md Normal file
View 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
View 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>

View 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();
}
}

View 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);
}
}

View 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;
}
}

View File

@@ -0,0 +1,5 @@
package de.cloud.flight.tray;
public interface PacketMatcher {
boolean match(byte[] buffer);
}

View 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());
}
}

View 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);
}
}

View 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());
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View 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
View 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

View 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