commit 0c333fcf0cccd7787662e4d2c97a249272b4e05b Author: MK13 Date: Sun Aug 7 22:49:09 2022 +0200 Initial diff --git a/README.md b/README.md new file mode 100644 index 0000000..7dae58a --- /dev/null +++ b/README.md @@ -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) \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..572ae44 --- /dev/null +++ b/pom.xml @@ -0,0 +1,151 @@ + + + 4.0.0 + + de.77zzcx7 + cloud-flight-tray + 1-SNAPSHOT + jar + Tray icon for HyperX Cloud Flight Wireless headset that shows e.g. the battery state + cloud-flight-tray + + + UTF-8 + 18 + + + + + + 77zzcx7-snapshots + http://192.168.10.4:8100/snapshots/ + + + 77zzcx7-releases + http://192.168.10.4:8100/releases/ + + + + + scm:git:https://77zzcx7.de/gitea/MK13/CloudFlightTray.git + ${scmDeveloperConnectionProp} + https://77zzcx7.de/gitea/MK13/CloudFlightTray + v1 + + + + + ch.qos.logback + logback-core + 1.2.11 + + + ch.qos.logback + logback-classic + 1.2.11 + + + org.slf4j + slf4j-api + 1.7.36 + + + com.github.taksan + native-tray-adapter + 1.1 + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 18 + 18 + + + + fr.jcgay.maven.plugins + buildplan-maven-plugin + 1.5 + + + build-plan + initialize + + list + + + + + + + + + + fr.jcgay.maven.plugins + buildplan-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + 16 + 16 + + + + org.apache.maven.plugins + maven-release-plugin + 3.0.0-M5 + + v@{project.version} + true + true + + + + maven-jar-plugin + + + default-jar + none + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.4.2 + + + package + + single + + + + + + de.cloud.flight.tray.CloudFlightTray + + + + + jar-with-dependencies + + cloud-flight-tray-${project.version} + false + + + + + + + + \ No newline at end of file diff --git a/src/main/java/de/cloud/flight/tray/CloudFlightTray.java b/src/main/java/de/cloud/flight/tray/CloudFlightTray.java new file mode 100644 index 0000000..b904fd2 --- /dev/null +++ b/src/main/java/de/cloud/flight/tray/CloudFlightTray.java @@ -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 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(); + } +} diff --git a/src/main/java/de/cloud/flight/tray/Images.java b/src/main/java/de/cloud/flight/tray/Images.java new file mode 100644 index 0000000..73bfbfa --- /dev/null +++ b/src/main/java/de/cloud/flight/tray/Images.java @@ -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); + } +} diff --git a/src/main/java/de/cloud/flight/tray/Packet.java b/src/main/java/de/cloud/flight/tray/Packet.java new file mode 100644 index 0000000..04179ce --- /dev/null +++ b/src/main/java/de/cloud/flight/tray/Packet.java @@ -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 null + */ + public static Packet getPacket(ByteBuffer bb, int length) { + final int bbLength = bb.hasArray() ? bb.array().length : -1; + final Optional 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 ordered() { + List 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; + } +} diff --git a/src/main/java/de/cloud/flight/tray/PacketMatcher.java b/src/main/java/de/cloud/flight/tray/PacketMatcher.java new file mode 100644 index 0000000..f016efd --- /dev/null +++ b/src/main/java/de/cloud/flight/tray/PacketMatcher.java @@ -0,0 +1,5 @@ +package de.cloud.flight.tray; + +public interface PacketMatcher { + boolean match(byte[] buffer); +} diff --git a/src/main/java/de/cloud/flight/tray/Reader.java b/src/main/java/de/cloud/flight/tray/Reader.java new file mode 100644 index 0000000..4b96bf6 --- /dev/null +++ b/src/main/java/de/cloud/flight/tray/Reader.java @@ -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 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 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()); + } +} \ No newline at end of file diff --git a/src/main/java/de/cloud/flight/tray/Sender.java b/src/main/java/de/cloud/flight/tray/Sender.java new file mode 100644 index 0000000..8a34c45 --- /dev/null +++ b/src/main/java/de/cloud/flight/tray/Sender.java @@ -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); + } +} diff --git a/src/main/java/de/cloud/flight/tray/TrayUpdater.java b/src/main/java/de/cloud/flight/tray/TrayUpdater.java new file mode 100644 index 0000000..be7c7f5 --- /dev/null +++ b/src/main/java/de/cloud/flight/tray/TrayUpdater.java @@ -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 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()); + } + } +} diff --git a/src/main/resources/images/charging.png b/src/main/resources/images/charging.png new file mode 100644 index 0000000..0974640 Binary files /dev/null and b/src/main/resources/images/charging.png differ diff --git a/src/main/resources/images/default.png b/src/main/resources/images/default.png new file mode 100644 index 0000000..9ff10b5 Binary files /dev/null and b/src/main/resources/images/default.png differ diff --git a/src/main/resources/images/low.png b/src/main/resources/images/low.png new file mode 100644 index 0000000..ae06a2a Binary files /dev/null and b/src/main/resources/images/low.png differ diff --git a/src/main/resources/images/muted.png b/src/main/resources/images/muted.png new file mode 100644 index 0000000..13c7445 Binary files /dev/null and b/src/main/resources/images/muted.png differ diff --git a/src/main/resources/images/off.png b/src/main/resources/images/off.png new file mode 100644 index 0000000..8feb27a Binary files /dev/null and b/src/main/resources/images/off.png differ diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..6eea430 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,11 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + \ No newline at end of file diff --git a/tools/install.sh b/tools/install.sh new file mode 100644 index 0000000..29c8a2f --- /dev/null +++ b/tools/install.sh @@ -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 \ No newline at end of file diff --git a/tools/template-cloud-flight-tray.service b/tools/template-cloud-flight-tray.service new file mode 100644 index 0000000..8ed38b2 --- /dev/null +++ b/tools/template-cloud-flight-tray.service @@ -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 \ No newline at end of file