Home Assistant Announcements Through Juke Audio's Hidden PA InputHome Automation

Home Assistant Announcements Through Juke Audio's Hidden PA Input

by Joule P. Kraft · November 3, 2026

As an Amazon Associate, and through other affiliate programs (including SONOFF), I earn from qualifying purchases. No affiliate relationship influences my recommendations.

Juke Audio shipped firmware 6.2.0 in October with a feature I had wanted for years: a PA System input. You open the phone app, hold a button, and your voice comes out of whatever rooms you picked, on top of the music that is already playing. The release notes describe it in one sentence. The API docs do not mention it at all.

I run Juke at home, and I wanted Home Assistant to use that PA for announcements. “Dryer is done” in the laundry room and the kitchen, “dinner is ready” everywhere, the doorbell on the back porch. So I opened the hood. The PA turned out to be one of the simplest audio protocols I have ever reverse-engineered: plain RTP over UDP, no auth, no handshake. This post is the build log, plus enough of the protocol and code for you to build your own.

Why bother when Music Assistant can already do TTS?

I already had TTS working through Music Assistant. Each Juke zone shows up as an AirPlay player, and Music Assistant can play an announcement on it. It works, but it is the wrong tool for this job:

  • It hijacks the source. An AirPlay announcement switches the zone to the AirPlay input. If the kitchen was playing the turntable through the analog input, the record keeps spinning and nobody hears it.
  • It is slow. AirPlay has around two seconds of buffer before the first word plays. That makes a doorbell announcement feel late.
  • Each room is its own stream. Announcing to eight rooms means eight AirPlay sessions that drift slightly against each other.

The PA input fixes all three. It ducks in over whatever is playing, starts almost instantly, and one stream fans out to every mapped zone on the amp’s side. That is worth a weekend.

Step 1: Find the PA input in the API

Juke publishes a Swagger spec at http://<juke-ip>/api/v3/apidocs/. On my 6.2 units the spec had no PA endpoints, no PA input type, and nothing in the enums. The public simulator spec is even older. If you stop there, you conclude the feature is app-only.

The live data tells a different story. Ask the amp for its inputs with the default credentials (Admin / Admin unless you changed them):

curl -s -u Admin:Admin http://<juke-ip>/api/v3/inputs/info | jq '.[] | select(.input_type=="PA")'

On 6.2 that returns a new input nobody documented:

{
  "input_id": "<device-id>-I22",
  "input_type": "PA",
  "input_class": 3,
  "name": "PA System",
  "control_url": "<juke-ip>:6699",
  "zones": []
}

Two fields matter. zones is the list of zones the PA is mapped to, and you change it with the same endpoints you already use for every other input:

curl -s -u Admin:Admin -X PUT -H 'Content-Type: application/json' \
  -d '{"zone_id": "<zone-id>"}' \
  http://<juke-ip>/api/v3/inputs/<device-id>-I22/zone/add
# and /zone/remove to take it back off

control_url is where the audio goes. Port 6699 answers HTTP, but every path I guessed returned a JSON 404. That was a hint that HTTP was not the real interface. One more find along the way: the phone app talks to an unlisted /api/v4. It has the same shape as v3 with a few extra fields, such as operation_mode: "low-latency" for the new low latency mode. You do not need it for anything in this post, but it is worth knowing it exists.

Step 2: Capture what the app actually sends

Guessing was going nowhere, so I captured a real PA session. My first two attempts are worth a warning:

  1. A capture on the router saw nothing. My phone and the Juke are on the same subnet, so their traffic goes through the switch and never touches the gateway. Router-side captures are useless for same-LAN device chatter.
  2. A switch port mirror works, but do not mirror onto your own capture machine’s only uplink. I did, and it knocked that box off the network until I removed the mirror.

What worked was boring and reliable: install the Juke Android app in the Android Emulator on a desktop, then run tcpdump on the desktop’s own network interface while pressing the PA button in the emulator. The emulated phone’s traffic leaves through the host’s NIC, so you capture it like any local process. One gotcha: the emulator’s built-in -tcpdump flag only sees its virtual network, not the Wi-Fi side, so capture on the host instead.

sudo tcpdump -U -i en0 -w pa.pcap host <juke-ip>

Hold the PA button, say a few words, let go, and stop the capture.

Step 3: Read the protocol

The capture was refreshingly small. Before streaming, the app sends one GET /api/v4/inputs/<pa-input-id> to read the input, including its control_url and zone mapping. Then a flood of UDP to port 6699 starts. There is no login, no start call, and no session setup. Open the pcap in Wireshark, use “Decode As” to mark UDP 6699 as RTP, and everything lines up:

Field Value
Transport UDP to the input’s control_url (port 6699)
RTP version 2, no padding, no extensions, no CSRCs
Payload type 11 (L16)
Audio 16 bit signed PCM, big endian, mono, 44,100 Hz
Packet size 128 samples (256 byte payload), about 345 packets per second
Marker bit Set on the first packet only
SSRC, sequence, timestamp Random start values; sequence +1 and timestamp +128 per packet
End of stream RTCP compound packet: empty receiver report (PT 201) plus BYE (PT 203), sent 3 times

That is textbook RFC 3551 audio. Payload type 11 is a static RTP type that already means “L16, mono, 44.1 kHz,” so the Juke does not even need SDP to know what it is getting. Audio plays in whatever zones the PA input is mapped to at that moment. It overlays the current source instead of replacing it, and when the BYE arrives the zones go back to normal.

Two practical consequences:

  • Room selection is a REST concern, not a stream concern. Map the zones first, stream, then unmap them.
  • There is no auth on the audio port. Anything on your LAN that can send UDP can talk through your ceiling. Treat this like any other unauthenticated IoT port and keep the amp on a trusted VLAN.

Step 4: Send a test tone

Before touching Home Assistant, prove the protocol with a tone. Map the PA input to one quiet zone with the zone/add call above, then run this:

import math, random, socket, struct, time

HOST, PORT = "<juke-ip>", 6699
RATE, SPP = 44100, 128          # sample rate, samples per packet
secs, freq, amp = 1.0, 880.0, 0.2

ssrc, seq, ts = (random.getrandbits(n) for n in (32, 16, 32))
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
total, t0 = int(RATE * secs), time.monotonic()

for pkt, start in enumerate(range(0, total, SPP)):
    samples = [int(32767 * amp * math.sin(2 * math.pi * freq * (start + k) / RATE))
               for k in range(SPP)]
    marker = 0x80 if pkt == 0 else 0
    header = struct.pack("!BBHII", 0x80, 11 | marker,
                         (seq + pkt) & 0xFFFF, (ts + pkt * SPP) & 0xFFFFFFFF, ssrc)
    sock.sendto(header + struct.pack(f"!{SPP}h", *samples), (HOST, PORT))
    # pace in real time; the amp has very little buffer
    delay = t0 + (pkt + 1) * SPP / RATE - time.monotonic()
    if delay > 0:
        time.sleep(delay)

bye = struct.pack("!BBHI", 0x80, 201, 1, ssrc) + struct.pack("!BBHI", 0x81, 203, 1, ssrc)
for _ in range(3):
    sock.sendto(bye, (HOST, PORT))
    time.sleep(0.01)

The ! in every struct format is the important part. It means network byte order, which gives you big endian samples. Send little endian PCM and you get loud static instead of a beep. Pacing matters too. If you blast the whole buffer at once, the amp drops most of it. Sleeping against a monotonic clock, as above, keeps the stream on schedule without drifting.

If you hear an 880 Hz beep in that one zone, you own the PA.

Step 5: The announcer service

A test tone is not an announcement. The finished version is a small Python service, about 160 lines, running in Docker on my home server. Home Assistant never touches UDP itself. The flow is:

  1. A Home Assistant script fires an event, juke_pa_announce, with message, rooms, and volume.
  2. The service, subscribed to that event over the Home Assistant WebSocket API, picks it up.
  3. It asks Home Assistant to render the TTS with POST /api/tts_get_url and downloads the audio file.
  4. ffmpeg converts the audio to exactly what the PA wants.
  5. It saves the PA input’s current zone mapping, maps the requested rooms, streams the RTP, and restores the old mapping in a finally block.

I split it this way on purpose. Home Assistant OS does not make it easy to run raw-socket Python with ffmpeg next to the core, and an event keeps the HA side pure YAML. The service can run on anything that is always on: a NAS, an Intel N100 mini PC, or a Raspberry Pi 5. It is idle almost all the time and uses a few megabytes of memory.

The WebSocket subscription is the standard Home Assistant pattern. Create a long-lived access token for a dedicated user and pass it in through the environment:

async with websockets.connect(f"{ws_base}/api/websocket", max_size=None) as ws:
    await ws.recv()                                   # auth_required
    await ws.send(json.dumps({"type": "auth", "access_token": os.environ["HA_TOKEN"]}))
    assert json.loads(await ws.recv())["type"] == "auth_ok"
    await ws.send(json.dumps({"id": 1, "type": "subscribe_events",
                              "event_type": "juke_pa_announce"}))
    async for raw in ws:
        msg = json.loads(raw)
        if msg.get("type") == "event":
            asyncio.create_task(handle(msg["event"]["data"]))

Wrap that in a reconnect loop with a five second sleep, because Home Assistant restarts happen. Put handle() behind an asyncio.Lock so two announcements fired back to back play one after the other instead of interleaving packets on the same port.

Getting the TTS audio and converting it takes two calls:

body = {"engine_id": "tts.home_assistant_cloud", "message": message}
url = post_json(f"{ha_url}/api/tts_get_url", body)["url"]
audio = http_get(url)
pcm = subprocess.run(
    ["ffmpeg", "-loglevel", "error", "-i", "pipe:0",
     "-ac", "1", "-ar", "44100", "-f", "s16be", "pipe:1"],
    input=audio, capture_output=True, check=True).stdout

-f s16be is ffmpeg’s name for signed 16 bit big endian raw PCM, so the output drops straight into the packet loop from Step 4. Replace the sine generator with 256 byte slices of pcm, padding the last slice with zeros. Two details made a real difference:

  • Pad 400 ms of silence at the front. Zones take a moment to un-mute after the mapping change, and without padding the first syllable gets clipped. Another 300 ms at the end keeps the last word from being cut off.
  • Rewrite the TTS URL to your internal Home Assistant address. tts_get_url returns your external URL. If the service runs on the LAN, swap the host so it never goes out and back through your reverse proxy.

Volume is a simple scale applied to each sample, clamped to the 16 bit range. That keeps the setting per announcement, without touching zone volumes that someone set for music.

Room names come from GET /api/v3/zones/info. I normalize both sides to lowercase alphanumerics, so “Kid’s Bath” from a dashboard matches “Kids Bath” in the Juke app, and “All” maps to every enabled zone. If you run two or more Jukes, you only need one PA input. The PA on one amp can be mapped to zones on the other amps in the same system, and the audio reaches all of them from a single stream.

The Dockerfile is three lines:

FROM python:3.13-alpine
RUN apk add --no-cache ffmpeg && pip install --no-cache-dir websockets
COPY juke_pa.py /app/juke_pa.py
CMD ["python", "-u", "/app/juke_pa.py"]

Run it with --network host and --restart unless-stopped, and pass the Home Assistant URL, token, Juke IP, and PA input ID as environment variables.

Step 6: The Home Assistant side

The script does nothing but fire the event, which keeps it easy to call from automations, dashboards, and voice assistants:

juke_announce:
  alias: Juke Announce
  mode: queued
  fields:
    message:
      selector:
        text:
    rooms:
      example: "[Kitchen, Laundry Room]"
      selector:
        object:
    volume:
      default: 0.8
      selector:
        number:
          min: 0.1
          max: 1
          step: 0.05
  sequence:
    - event: juke_pa_announce
      event_data:
        message: "{{ message }}"
        rooms: "{{ rooms }}"
        volume: "{{ volume | default(0.8) }}"

Then an automation is a one-liner wherever you need it:

- action: script.juke_announce
  data:
    message: "The dryer is done."
    rooms: [Laundry Room, Kitchen]

I also built a small Announce dashboard for the family: one input_boolean toggle per room, an input_text for the message, an input_number slider for volume, and a button that runs a wrapper script. The wrapper collects every toggle that is on and calls script.juke_announce. It takes ten minutes to build and gets more use than I expected. Kids discover very quickly that they can announce from the couch.

Which TTS engine?

Two good options, and the announcer does not care which one you use:

  • Home Assistant Cloud (Nabu Casa). This is what I run. The voices are the most natural I have heard in Home Assistant, rendering is fast, and nothing runs on your hardware. The subscription also funds Home Assistant development, and it bundles remote access and Alexa or Google integration. Set engine_id to tts.home_assistant_cloud.
  • Piper. Fully local and free, via the Piper add-on or a Wyoming container. The voices are a step behind cloud TTS but perfectly clear for “garage door is open.” If you would rather nothing leave the house, use Piper and point engine_id at its TTS entity. On an N100 box, a sentence renders in well under a second.

The hardware behind it

This only works if you have a Juke system, and Juke is not sold through Amazon. You buy it direct from Juke Audio. I covered why I chose it in my best whole house audio systems guide. The short version: one centralized amp, passive speakers in every room, and almost nothing to maintain.

Everything else in my setup is easy to find:

  • Speakers. I have Sonance in-ceiling speakers throughout the house. Voice is mostly midrange, so announcements sound clear on almost any decent in-ceiling speaker. Sonance’s narrow-bezel grilles are what make them disappear into a ceiling. If you are still choosing, I compared the main options in my in-ceiling speaker roundup.
  • Wire. 16 AWG CL2-rated speaker wire for every home run, and keystone speaker wall plates where runs terminate at a rack or closet.
  • Network. The Jukes are wired into a UniFi USW Pro Max 16 PoE. Any managed switch works. The useful part is VLAN support, so an unauthenticated audio port is not on your guest network.
  • Something always on for the service. A NAS you already own, an N100 mini PC, or a Raspberry Pi 5. If you plan to run Piper on the same box, choose the N100. It has more headroom for TTS.

Caveats

This is undocumented behavior, so be honest with yourself about the risk:

  • Firmware can break it. Juke could add auth to port 6699 or change the stream format in a future update. The fix would probably be a new capture, not a new architecture, but plan for an afternoon of rework at some point.
  • The PA mapping is shared state. If someone uses the PA from the phone app while an automation is announcing, both write to the same input. The save-and-restore logic handles the common case, but it is not a mutex across devices.
  • Keep it on your LAN. No auth on the audio port means you should never forward 6699 or expose the amp outside your network.

The Bottom Line

Juke’s PA input is the best announcement path I have found for a wired whole house system. It overlays music instead of hijacking the source, starts nearly instantly, and one stream covers every room on every amp. The protocol is plain RTP: L16 big endian, mono, 44.1 kHz, payload type 11, to port 6699, with zones chosen through the normal REST API. Put a small event-driven service between Home Assistant and the amp, pick Nabu Casa or Piper for TTS, and you have whole-house announcements in an evening. If Juke ever documents this officially, I will be happy to delete my packet loop. Until then, it works.

Frequently Asked Questions

Does the Juke Audio API support the PA System feature?+
Not officially. Firmware 6.2 adds a PA input that shows up in the authenticated /api/v3/inputs/info response with input_type PA, but no published endpoint sends audio to it. The audio path is a separate RTP over UDP stream to the control_url the input reports, usually port 6699 on the amp.
What audio format does the Juke PA input expect?+
RTP payload type 11: 16 bit signed big endian PCM, mono, 44.1 kHz. The Juke app sends 128 samples per packet, about 345 packets per second, sets the marker bit on the first packet, and closes with an RTCP receiver report plus BYE.
Will a Juke PA announcement stop the music that is playing?+
No. The PA input overlays the current source in the zones it is mapped to. That is the main reason to use it instead of pushing TTS through AirPlay or Music Assistant, which switches the zone's source and leaves you to restore it.
Can one Juke announce to zones on a second Juke?+
Yes. In a multi-amp Juke system, the PA input on one unit can be mapped to zones on any other unit in the same system, so a single RTP stream reaches the whole house.
Do I need Nabu Casa for the text to speech part?+
No. Home Assistant Cloud gives the most natural voices with no local compute, but Piper runs fully local and free. The announcer only asks Home Assistant for a TTS URL, so any engine Home Assistant supports works.