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. ↩︎