
Table of Contents
TLDR; This is a multi part series of using your Elgato Streamdeck with Java. This is the third part, implementing DND, Elgato Airlight handling and managing blinds.
You can look a the first part dealing with USB communiation or the second part dealing with event handling and state
Controlling DND mode
DND under osx is a tricky thing. There used to be CLI tools like do-not-disturb for node (which included a small binary), or desktop tools like Muzzle or AutoDND. At some point each tool worked or stopped working, depending on the osx version. So just running one of these binaries was not an option.
While googling for this I found out about mac os built-in Shortcuts app. With this it is easy to enable, disable, toggle or retrieve the current focus status.


The other neat part is the ability to easily trigger shortcuts workflows/programs on the console
❯ /usr/bin/shortcuts run 'DND: Get Status' \
--output-type public.text --output-path - | cat
on
❯ /usr/bin/shortcuts run 'DND: Off'
❯ /usr/bin/shortcuts run 'DND: Get Status' \
--output-type public.text --output-path - | cat
off
❯ /usr/bin/shortcuts run 'DND: On'
❯ /usr/bin/shortcuts run 'DND: Toggle'
So, this can now be called from within a Java application. Let’s create a method to retrieve the current status.
private enum DndStatus { ON, OFF, UNKNOWN }
private static DndStatus getStatus() {
try {
String[] command = new String[] {
"/usr/bin/shortcuts", "run", "DND: Get Status",
"--output-type" ,"public.text", "--output-path", "-"
};
Process process = new ProcessBuilder(command).start();
try (InputStream is = process.getInputStream();
OutputStream os = process.getOutputStream();
InputStream err = process.getErrorStream()) {
os.close();
process.waitFor();
if (is.available() > 0) {
String output = new String(is.readAllBytes(), StandardCharsets.UTF_8);
return switch (output) {
case "on" -> DndStatus.ON;
case "off" -> DndStatus.OFF;
default -> DndStatus.UNKNOWN;
};
}
if (err.available() > 0) {
System.out.println("Error: " + new String(err.readAllBytes()));
}
}
} catch (IOException | InterruptedException e) {
return DndStatus.UNKNOWN;
}
return DndStatus.UNKNOWN;
}
Setting the status is very similar
private static void setDndStatus(DndStatus status) {
String[] command = switch (status) {
case ON -> new String[] {
"/usr/bin/shortcuts", "run", "DND: On"
};
default -> new String[] {
"/usr/bin/shortcuts", "run", "DND: Off"
};
};
try {
ProcessBuilder processBuilder = new ProcessBuilder();
processBuilder.command(command);
Process process = processBuilder.start();
try (InputStream is = process.getInputStream();
OutputStream os = process.getOutputStream();
InputStream err = process.getErrorStream()) {
// output stream must be closed early for waitFor to finish...
os.close();
process.waitFor();
if (is.available() > 0) {
System.out.println("Output: " + new String(is.readAllBytes()));
}
if (err.available() > 0) {
System.out.println("Error: " + new String(err.readAllBytes()));
}
}
} catch (InterruptedException | IOException e) {
throw new RuntimeException(e);
}
}
Handling the event button press
@Override
public void onStreamDeckEvent(StreamDeck.StreamDeckEvent e) {
if (e instanceof StreamDeck.ButtonPressed buttonPressed) {
if (buttonPressed.pos() == 3) {
if (dndStatus == DndStatus.ON) {
dndStatus = DndStatus.OFF;
setDndStatus(dndStatus);
this.streamDeck.setButtonText(3, "DND", "Roboto", 24, Color.WHITE);
} else {
dndStatus = DndStatus.ON;
setDndStatus(dndStatus);
this.streamDeck.setButtonText(3, "DND", "Roboto", 24, Color.PINK);
}
}
}
}
Now with everything up and running, let’s try it out. I am creating a new notification every five seconds in the background. Pressing the button stops showing new notifications.
Controlling Elgato Key Lights
Next up, are my to Elgato lights. I’ve been working fully remote since 2013 and because of that I prefer having video meetings, whenever there is something to talk about. I also prefer good lighting and good cameras, cause I think it is important when you are fully remote. I am using Hammerspoon to automatically enable my lights, whenever one of my cameras is active, so I don’t have to think about this anymore. I would like to use the Streamdeck to primarily change brightness, but also enable/disable if needed.
So, how are Elgato keylights controlled? They are basically sitting in the same network and can retrieve unencrypted messages how to behave. This means you should only use keylights in secure networks. Also I think the do not support 5GHz networks (that may have changed with newer versions).
Just as a quick side step, if you want to do multicast based discovery of
your key lights, this is how it works in Java using the jmdns
library:
private static class SampleListener implements ServiceListener {
@Override
public void serviceAdded(ServiceEvent event) {
}
@Override
public void serviceRemoved(ServiceEvent event) {
}
@Override
public void serviceResolved(ServiceEvent event) {
System.out.println("Service resolved: " + event.getInfo());
}
}
public static void main(String[] args) throws InterruptedException {
try {
JmDNS jmdns = JmDNS.create(InetAddress.getLocalHost());
jmdns.addServiceListener("_elg._tcp.local.", new SampleListener());
// Give it 10s
Thread.sleep(10000);
} catch (IOException e) {
System.out.println(e.getMessage());
}
}
In my case this will list my two elgato key lights. You could also use the
dns-sd
command line tool like this:
dns-sd -B _elg._tcp
Having the IP addresses of the keylights, you can sent HTTP requests to with certain JSON bodies to enable/disable the light or change the brightness.
There is an endpoint to retrieve generic info at /elgato/accessory-info
.
The other endpoint is the /elgato/lights
- that is able to receive HTTP
`PUT requests. You can do the following actions
- Switch the light on or off
- Change the brightness
- Change the temperature
The JSON looks like this
{
"numberOfLights":1,
"lights":[
{
"on":1,
"brightness" : "100",
"temperature" : "200"
}
]
}
Temperature is a value between 150 and 400, brightness a percentage between
0 and 100. The on
field can be 0
or 1
. You can also only supply parts
of the JSON in the lights
array. Equipped with this, you can come up with
an ElgatoKeyLight
class with a few signatures like this:
public class ElgatoKeyLight implements AutoCloseable {
public ElgatoKeyLight(String endpoint) {
// ...
}
public String info() {
}
public Status status() {
}
public void off() {
}
public void on() {
}
public void brightness(int percent) {
}
public void temperature(int temperature) {
}
}
Adding some simple JSON parsing of the response and the client is done. Now I have one client for each lamp. And can control both at the same time.
What controls do we need? On, off via a button, setting temperature and brightness via an encoder/knob. Let’s go with a custom screen.
So let’s create a new screen:
public static class ElgatoScreen implements StreamDeck.StreamDeckEventListener {
public ElgatoScreen(StreamDeck streamDeck) {
this.streamDeck = streamDeck;
lights[0] = new ElgatoKeyLight("http://10.1.1.12:9123");
lights[1] = new ElgatoKeyLight("http://10.1.1.13:9123");
status = lights[0].status();
}
Now, the main work, is done in the event handling, let’s start with the actions for pressed buttons
@Override
public void onStreamDeckEvent(StreamDeck.StreamDeckEvent e) {
if (e instanceof StreamDeck.ButtonPressed buttonPressed) {
if (buttonPressed.pos() == 0) {
streamDeck.setListener(mainScreen);
mainScreen.render();
return;
} else if (buttonPressed.pos() == 1) {
if (status.on()) {
Arrays.stream(lights).forEach(ElgatoKeyLight::off);
} else {
Arrays.stream(lights).forEach(ElgatoKeyLight::on);
}
}
}
}
And now the actions for the knobs
} else if (e instanceof StreamDeck.EncoderRotated rotated) {
if (rotated.pos() == 0) {
// brightness
short brightness = (short) Math.min(100, Math.max(0, status.brightness() + rotated.value()*2));
Arrays.stream(lights).forEach(l -> l.brightness(brightness));
} else if (rotated.pos() == 1) {
// temperature
int temperature = Math.min(400, Math.max(150, status.temperature() + rotated.value()*4));
Arrays.stream(lights).forEach(l -> l.temperature(temperature));
}
}
// reload status after changes
status = lights[0].status();
this.render();
}
One of the weaknesses is, that a knob turning fast can lead to a lot of requests and may block the reading loop. While this is not so much a problem for a local service, it’s much worse for a remote service, like the one to control my blinds, where I don’t want to run into any rate limiting services.
Finally, this is what it looks like
On to the last step!
Controlling my blinds
I am using homematic as a gateway to control my blinds. I will not go into any details of how to control homematic devices. At the end this is just another custom HTTP client with some headers set and we are good to go. I basically have a class like this:
public class HomeMaticShutterService implements AutoCloseable {
private final Deque<HttpRequest> queue = new ArrayDeque<>();
public HomeMaticShutterService(String authToken,
String accessPointId,
String[] deviceIds) {
this.timer = new Timer("homematic");
}
public void setShutterLevel(String deviceId, double level) {
if (level < 0.0 || level > 1.0) {
throw new RuntimeException("level must be between 0.0 and 1.0");
}
HttpRequest request = HttpRequest.newBuilder()
.POST(HttpRequest.BodyPublishers.ofString(JsonWriter.string(json)))
.uri(URI.create(endpoint))
.build();
queue.push(request);
final HttpClient client = this.client;
TimerTask timerTask = new TimerTask() {
@Override
public void run() {
HttpRequest req = queue.pollLast();
// only execute request of no other request is lined up!
if (queue.isEmpty() && req != null) {
try {
HttpResponse<String> response = client.send(req, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
System.err.println("Error setting shutter level, response: "+ response.body());
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
};
// run in 500ms
timer.schedule(timerTask, 500);
}
}
The tricks with the queue and the timer task is to prevent sending too many
requests to the API endpoint. Instead of sending every single button press
or knob action, this piece of code enqueues a new request, but gives it
500ms before it gets send. If within that 500ms window, another request has
arrived, this one is not send, as I expect the other one is newer. This way
I prevent sending a useless amount of requests to that API. Thanks to a
TimerTask
and the ArrayDeque
this is pretty easy to implement.
Packaging with the application plugin
Thanks to the application plugin in gradle, packaging is rather simple. My configuration looks like this:
plugins {
application
id("com.gradleup.shadow") version "8.3.6"
}
application {
mainClass = "de.spinscale.streamdeck.StreamDeck4j"
}
If you add the shadow plugin, you end up with a single far jar and a shell script. That’s all you need plus a Java runtime environment on the system. Configuring heap size might make sense, or using ZGC, but in general I expect this to be a low memory application.
Using graalVM to built a binary
To reduce memory consumption even more I tried graalvm, but after using a
lot --initialize-at-build-time=
on different java.awt
I realized, that
apparently graal and java.awt and osx are not working yet (see
here,
here and
here). One could hack around
that, by using imagemagick on the command line when drawing images, but I
opted not to for now.
Future ideas
So, my streamdeck has been running for some time on my desktop now, completely programmed by myself, and I really enjoyed that using Java, even though I probably would have been faster using the existing python or node libraries.
What else could I implement
- A visual notification when a meeting is about to start.
- A “join meeting now” button instead of needing to click on it
- Checking current power consumption of my PV setup. I try not to have too many current status displays there, as I don’t want to use this as a dashboard though
Conclusion
This concludes my experiment using Java with a StreamDeck. I’d consider this a full success, cause I got everything I need up and running and now understood that controlling USB devices via Java is not much of a problem. Let’s see which other devices I’ll play aroud with next.
Of course, quite a few things may have been overengineered here, but that’s the fun of doing it, amirite? Also, you could just try out the streamdeck hammerspoon extensions that will probably give you a good mileage as well, but my Java skills are much better than my Lua skills.
Resources
- hid4java
- lsusb
- python-elgato-streamdeck
- node-elgato-stream-deck: interesting implementation as you can control your Streamdeck from within the browser via webHID.
- hammerspoon hs.streamdeck docs
Final remarks
If you made it down here, wooow! Thanks for sticking with me. You can follow or contact me on Bluesky, Mastodon, GitHub, LinkedIn or reach me via Email (just to tell me, you read this whole thing :-).
If there is anything to correct, drop me a note, and I am happy to fix and append a note to this post!
Same applies for questions. If you have question, go ahead and ask!