Controlling Steam Link Servers via HomeKit

Turning on servers via turned out to be a little more complicated than I expected, but eminently doable.

We stream games from a Ryzen server in the closet, and even though Steam Link clients seem to be able to wake up the servers they’re bound to via Wake-on-LAN, that hasn’t been as consistent as I would like, so I wanted to make it possible to wake them up via (i.e., create a switch that would send a Wake-on-LAN packet).

To do that, however, I needed to first figure out what the protocol looked like, since my usual staples (finding the machines via /) or pinging them (which might require a fixed IP address, something I don’t like) weren’t good enough.

If you’ve missed previous installments, well, we can even change the logged in user via HomeKit

A few cursory web searches led me to sniff out UDP port 27036, so after a little while I decided to see how I could send out a discovery packet.

And since most of our “special” home automation lives inside and I’ve had far too much experience with multicast/broadcast UDP in it over the years, I decided to prototype things there.

After capturing a few real packets from my Mac’s Steam client, looking over steam-discover and brutally simplifying the code, I got to this minimal hack:

// Build discovery packet matching captures, with configurable varint
function buildDiscoveryPacket(finalVarint = 1, tokenNumber) {
  const out = [];
  const pushUint32LE = n => {
    n = (n >>> 0);
    out.push(n & 0xFF, (n >>> 8) & 0xFF, (n >>> 16) & 0xFF, (n >>> 24) & 0xFF);
  };

  // 1) Magic header
  out.push(0xFF, 0xFF, 0xFF, 0xFF);

  // 2) Token (use provided number or default bytes [33,76,95,160])
  if (typeof tokenNumber === 'number') pushUint32LE(tokenNumber);
  else out.push(33, 76, 95, 160);

  // 3) Length/marker = 12
  pushUint32LE(12);

  // 4) Payload prefix (exact bytes from your valid packet up to the 4-byte block)
  const payloadPrefix = [
      0x08, 0x9D, 0x89, 0x95, 0x82, 0xB6, 0xD3, 0x8A, 0xDA, 0x73,
      0x10, 0x00, 0x02, 0x00, 0x00, 0x00
  ];

  out.push(...payloadPrefix);

  // 5) Final tag 0x08 (field 1, varint) and the varint value (0..255)
  out.push(0x08, finalVarint & 0xFF);
  return Buffer.from(out);
}

return {
  // I never needed to increment the attempt counter
  payload: buildDiscoveryPacket(1) 
}

Wire this up to a UDP node set to use 192.168.1.255:27036, and Steam servers started sending back replies.

This is where it got hairy–all I needed was the hostname. Since my new ISP’s router does not provide reverse DNS on .lan anymore (nor does it expose any useful local API) and might not be installed on all Steam servers, I had to wrestle that out of the response packet.

After wrangling with it for a couple of hours, I decided I had to do it the “right” way, so I grabbed a few links to the protobuf definitions, fired up , and got GPT-5.1 to hack out a minimalist parser that would work without protobuf even if it does read somewhat like post-Modernist assembly language:

// Find field tag 0x22 (field 4, wire type 2) and extract ASCII string

// Normalize input to Buffer
function toBuffer(input) {
  if (Buffer.isBuffer(input)) return input;
  if (Array.isArray(input) && input.length && input.every(n => Number.isInteger(n) && n >= 0 && n <= 255)) {
    return Buffer.from(input);
  }
  if (input && Array.isArray(input.payload)) return Buffer.from(input.payload);
  if (input && Buffer.isBuffer(input.payload)) return input.payload;

  // try common nested shapes
  for (const k of Object.keys(input || {})) {
    const v = input[k];
    if (Buffer.isBuffer(v)) return v;
    if (Array.isArray(v) && v.length && v.every(n => Number.isInteger(n) && n >= 0 && n <= 255)) return Buffer.from(v);
  }
  return null;
}

// Read varint at pos; returns { value, next } or { value: null, next: pos } on failure
function readVarint(buf, pos) {
  let val = 0, shift = 0, start = pos;
  while (pos < buf.length) {
    const b = buf[pos++];
    val |= (b & 0x7F) << shift;
    if ((b & 0x80) === 0) return { value: val >>> 0, next: pos };
    shift += 7;
    if (shift > 35) break;
  }
  return { value: null, next: start };
}

// Extract first length-delimited field with tagByte (0x22)
function extractFieldByTag(buf, tagByte) {
  for (let i = 0; i < buf.length; i++) {
    if (buf[i] !== tagByte) continue;

    // candidate found at i; next bytes should be varint length
    const lenRes = readVarint(buf, i + 1);
    if (lenRes.value === null) continue;
    const len = lenRes.value;
    const start = lenRes.next;
    const end = start + len;
    if (end > buf.length) continue;
    const slice = buf.slice(start, end);

    // check printable ASCII
    let printable = true;
    for (let j = 0; j < slice.length; j++) {
      const c = slice[j];
      if (c < 0x20 || c > 0x7E) { printable = false; break; }
    }

    if (printable && slice.length > 0) return slice.toString('ascii');

    // if not fully printable, still return any printable substring >=3
    const s = slice.toString('ascii').match(/[ -~]{3,}/g);
    if (s && s.length) return s[0];
  }

  return null;
}

const buf = toBuffer(msg.payload);
if (!buf) {
  node.warn('No byte array found in incoming message');
  return { payload: null, error: 'no byte array found' };
}

// Try direct tag 0x22 (34 decimal)
const serverName = extractFieldByTag(buf, 0x22);
if (serverName) {
  return { payload: serverName, ip: msg.ip }
} else {
  return { payload: null }
}

This, when plugged in after a UDP receive node on port 27036, worked very well indeed, although I did have to filter out the discovery packets I was sending out.

Add a little more state tracking, and I was set–all I needed to do afterwards was expose the known state of the servers via switch entities and send out Wake-on-LAN packets when the switches are toggled to On:

This sends out a discovery packet every 30 seconds and updates HomeKit every 60 seconds
This sends out a discovery packet every 30 seconds and updates HomeKit every 60 seconds

And yes, this is a bit of a hack and will eventually be ported 1 to , but it was a fun one, and I can now say “Siri, turn on the Steam Machine!”2.


  1. That will happen as soon as HAP-Python is fully compatible with 3.14’s asyncio – that is currently broken, which I experienced yesterday and was another reason why I did this in ↩︎

  2. Siri, bless its little intent engine, just doesn’t understand the word “Bazzite”, so I just renamed the switch to “Steam Machine” in the Home app. ↩︎

The Maclock

If you’re anywhere near the intersection of Apple nostalgia, retro-computing and DIY electronics, This Does Not Compute’s latest video was probably on your radar. If not, the gist is simple: there is an 11 cm tall alarm clock shaped like a Classic Mac, and I grabbed one off AliExpress for roughly EUR 20.1

Isn't it just cute as a button?
Isn't it just cute as a button?

Even though I paid for it myself, I couldn’t help filing it as a because, well… It’s just great.

Why I Got It

I enjoy retro-computing but lack the time (and space) to restore vintage hardware, so I usually settle for emulation, and even then I find there is very little I actually want to re-live from those days.

Early Mac OS is the exception–it reminds me that user experience used to be much simpler and intuitive, which is why I still use the and now and then.

During college I worked daily on Mac IIs, and I was eventually given three broken Mac Classics, from which I scavenged parts to build a working one. That machine is long gone but I loved it and have always wanted to have something like it around, so earlier this year I built a (a full-sized replica just wouldn’t be a good fit for my cramped office).

It runs After Dark screensavers, Prince of Persia and plenty of old software, while moonlighting as a exit node. Still, it lacks audio and the exposed USB ports annoy me, so I’ve been and accessories at a leisurely pace:

The original STL and my recreation
The original STL and my recreation

I even modelled a matching mouse shell around a two-euro USB rodent, that I am still trying to get right:

The matching mouse design
The matching mouse design

The plan was to print both in the perfect vintage-colored PLA (which took me quite a while to track down), but the Maclock is such a good replica that it felt like the ideal shortcut.

The Hardware

It’s noticeably smaller than my printed case:

The Maclock alongside my mini Mac
The Maclock alongside my mini Mac

My guess is that the form factor is heavily inspired by the Pi zero version from 2022, which was of a comparable size, but the Maclock’s level of polish is just amazing.

I haven’t opened mine yet since the video goes into more than enough detail into the internals (one PCB stuck to the display, a battery fixed to the bottom, and buttons).

Also, it clearly wasn’t designed to be opened–an initial attempt with plastic wedges showed the plastic will score easily, so I decided to hold back until I get my second unit. But the details are lovely:

  • Powering it on requires inserting the tiny plastic floppy disk
  • Toggle the clock mode and the classic smiling Mac greets you
  • A small acrylic dome gives the LCD a CRT-style curve
  • There’s a fake power switch above the USB-C port
  • Side grilles run along the base just like the original vents
  • The back panel has insets for the logo, keyboard jack and other period details
  • Cosmetic screws sit exactly where you expect (in the exact same infuriating locations, even though they don’t actually hold anything in place)
  • A brightness knob is just under the screen (which I am planning to repurpose as a volume knob)

So much love for detail in this thing
So much love for detail in this thing

Even the sticker sheet feels premium:

Even the little floppy it comes with deserves its own stickers
Even the little floppy it comes with deserves its own stickers

The only thing slightly off is the stencilled grille on top, but overall this is a loving recreation. Apple should probably sell it as a licensed product instead of releasing Borat’s loincloth as an iPhone sock.

The Software

It’s an alarm clock. You can set the time, set an alarm, and it has an invisible snooze button on top that seems to be based on a capacitive touch sensor.

It’s fine. That’s most definitely not the point of it for me.

Next Steps

By my measurements the case should fit the original Waveshare 2.8-inch LCD used in the Pi Zero build–the visible diagonal inside the curved lens is roughly 7 cm, and my only concern is that mounting it might be tricky because of an asymmetrical bezel and relatively tight tolerances.

But my ideal upgrade would be a high-resolution monochrome backlit panel, which I would love to see as a product. If anyone knows of such a display around 2.5 to 3 inches with 384x512 resolution, please drop me a line, I will write a driver for it.

All the other parts I need have been sitting in a project box for a couple of years: audio amp, speakers, USB sound card and a Pi Zero 2W. I just need a way to lay them out internally before I crack the shell open and route at least one external USB port.

My plan is to 3D print an internal frame for the electronics and either just go fully wireless (which would require me to gut a Bluetooth mouse) or carefully cut openings for USB-A, audio and maybe a power button (or re-use the existing clock buttons).

A Dremel feels risky, so I’ll probably resort to an ultrasonic cutter (which I don’t have) or a heated blade–and that bottom panel with the fake ports seems like a good place to start.

But until the second unit and the display arrive, I’m going to be measuring this thing with calipers–there simply isn’t a freely available 3D model at this scale with comparable fidelity.

If Wonderboy Innovation Design Co., Ltd. is reading this: you’ve nailed it and made a lot of people very happy–but can we please have the STEP files?


  1. I actually ordered two, just in case these get nuked from orbit. Three would have felt excessive, but who knows? ↩︎

Notes for November 17-22

It’s now cold enough for me to enjoy being inside, but sometimes I would prefer to have a bit more variety and not be stuck in front of a screen all the time. Still, there’s been some progress on various fronts this week…

Read More...

Notes for November 1-16

This turned out to be a much more exhausting couple of weeks than I expected, in various ways.

Read More...

The LattePanda IOTA

A couple of months ago, DFRobot reached out to see if I wanted to take a look at the new LattePanda IOTA single-board computer–successor to the original LattePanda V1. I never owned a V1 myself, but I have found quite a few in the wild; they were popular with integrators for kiosk and digital signage applications. I was curious to see how the new model stacked up.

Read More...

Reviving a MacBook Air with Fedora Silverblue

Like many people, I have a few older Mac laptops around, and with Apple discontinuing support for older versions of macOS (and, eventually, for Intel machines altogether), it’s long been in my backlog to re-purpose them.

Read More...

The Week I Built Half a TOTEM

During a few random lulls in work I finally went down the rabbit hole of building myself a custom keyboard. I had ordered most of the parts months ago, but only now got the time to clear out my electronics desk, dust off my hotplate, do a little soldering and begin 3D printing the case and keycaps.

Read More...

26.1

Our worldwide usability nightmare continues, and I have thoughts:

Read More...

Notes for October 13-31

Well, this was a “fun” two weeks. In short, I got blindsided by a large project and a bunch of other work things, including an unusual amount of office visits (almost one a week, which is certainly not the norm these days) and a lot of calls.

Read More...

A Minor Rant About Cloudflare UX

I have, , spent far too much time wandering the chaotic wilds of Cloudflare’s web UI to set up a new tunnelled web application (a trivial proxy to be able to use my as a whiteboard from the Azure Virtual Desktop I live inside of), and to avoid having to go through the whole thing again, I decided to take some notes.

Read More...

Ten Years at Microsoft

It’s been , and I’ve made it a point to mark the occasion (almost) every year, so why stop now?

Read More...

Archives3D Site Map