Giving My AI a Voice

How I freed a WiZ bulb from the cloud and turned a Nest Mini into my AI's speaker โ€” using an old phone, a VPS, and stubbornness.

Updated October 21, 2026

Smart HomePythonChromecastTailscaleOpenWrt
โ† Back to home
๐Ÿ“– Chapters The original story Chapter 1 ยท Oct 18 โ€” the phone retired Chapter 2 ยท Oct 18 โ€” review pass Chapter 3 ยท Oct 20 โ€” the voice gets a job

The Problem

I run a small smart home: a Philips WiZ bulb, a Google Nest Mini, some smart plugs. Everything worked โ€” through their respective clouds. Then one day the cloud token died, and I realized how fragile that dependency was.

I wanted direct, local control. No cloud. No tokens that expire. Just my devices, on my network, responding to me.

What followed was an evening of protocol reverse-engineering, network debugging, and one very loud Nest Mini.

Part 1: Freeing the WiZ Bulb

Discovery

Philips WiZ bulbs speak a simple protocol: JSON over UDP on port 38899. No authentication. No cloud. Just datagrams on the local network.

The basic operations:

// Get state
{"method": "getPilot", "params": {}}

// Set brightness and color temperature
{"method": "setPilot",
 "params": {"state": true, "dimming": 100, "temp": 6500}}

// Turn off
{"method": "setPilot", "params": {"state": false}}

I wrote a small Python script using raw UDP sockets. The first getPilot returned the bulb's real state. The first setPilot blasted it to full brightness at 6500K โ€” which I physically verified by walking into the room.

Lesson learned: warn the household before testing at 100%.

The Catch: Network Topology

My VPS couldn't reach the bulb directly. The bulb was on a different subnet behind a Huawei router that blocked inter-subnet traffic. The fix was replacing the Huawei's WiFi with a TP-Link access point in bridge mode, putting everything on a single flat network.

I also set up Tailscale subnet routing: my OpenWrt router advertises the LAN subnet, and the VPS accepts those routes. Now the VPS can reach any device on the home LAN as if it were local.

Key takeaway: Most "smart" devices have perfectly good local APIs. The cloud is a convenience layer, not a requirement. A flat network topology makes local control dramatically simpler.

Part 2: Heisting the Nest Mini

The Goal

I wanted my AI assistant to speak through the Nest Mini. Not via Google's APIs (which require cloud setup and have usage limits), but by casting audio directly to it as the Chromecast device it is.

Attempt 1: Direct Casting (Failed)

The Nest Mini is a Chromecast. In theory, I could connect from the VPS directly:

  1. Tailscale gives the VPS a path to the LAN.
  2. Port 8009 (Chromecast) was reachable.
  3. pychromecast connected and returned device info.

But actual playback kept failing. The TLS handshake succeeded, but the Cast protocol handshake stalled indefinitely. I verified this wasn't a network issue โ€” raw TLS worked fine. The problem was in the protocol layer.

I tried two versions of pychromecast, different connection methods, mDNS discovery vs. direct IP. Same result: connection starts, never completes.

The Insight: Use a LAN-Side Bridge

Instead of fighting the protocol from across a VPN, I used an old phone (a Redmi Note phone, running a custom ROM) as a bridge:

[VPS] --(Tailscale)--> [Redmi Note phone] --(LAN)--> [Nest Mini]
         queue                 catt

The architecture:

  1. VPS runs a lightweight HTTP queue server. I POST cast commands to it.
  2. Redmi runs a Python script that polls the queue every 5 seconds.
  3. When a command arrives, the Redmi uses catt (Cast All The Things) to play it on the Nest Mini.

Why catt instead of pychromecast directly? catt handles the connection lifecycle differently and โ€” critically โ€” it worked where raw pychromecast didn't. Sometimes the pragmatic wrapper beats the "proper" library.

The First Test

The first successful cast played a test MP3 at 100% volume. It was... loud. Very loud. The household noticed.

Volume management became a first-class feature: 30% for music, 70% for voice. The script now accepts a volume parameter with every cast command.

Text-to-Speech Integration

For the AI to "speak," I generate TTS audio, upload it to the VPS, and cast the URL. The pipeline:

  1. Generate TTS with a preferred voice (slower pace for the small speaker).
  2. Upload to VPS via base64-encoded transfer (more reliable than raw SCP on flaky connections).
  3. POST a cast command with the audio URL and volume.
  4. Redmi picks it up within 5 seconds and plays it.

The total latency is about 10โ€“15 seconds from text to speech. Not real-time, but perfectly usable for announcements, responses, and surprises.

Making It Permanent

For this to survive reboots and power outages:

Part 3: Lessons

1. The Wrapper Beats the Library

I spent significant time debugging pychromecast's handshake. The fix was using catt, a higher-level tool built on the same library. Don't let pride keep you at the wrong abstraction layer.

2. Local-First Is Worth the Effort

Every cloud dependency is a future outage. The WiZ bulb works without Philips' servers. The Nest Mini plays audio without Google's cast cloud. The initial setup took an evening; the reliability payoff is permanent.

3. Old Hardware Has Value

The Redmi Note phone was gathering dust. Now it's a dedicated home automation bridge with a charge limiter, running 24/7. Before buying new hardware, check the drawer.

4. Network Topology Matters

A flat subnet with no client isolation, no inter-VLAN blocking, and static leases for infrastructure devices eliminates an entire class of "it works sometimes" bugs.

5. Volume Is a Feature

The difference between a cool demo and a usable system is often unglamorous: volume levels, retry logic, boot persistence. I learned this when the first test nearly deafened the household.

What's Next

The system works. Current capabilities:

Possible extensions: scheduled announcements, sensor-triggered audio, multi-room sync. But for now, I'm enjoying having built it.


Built for myself, by myself, in one evening. No cloud required.

Chapter 1 ยท October 18, 2026: the phone retired

โ—€ new here? read from the start

The article above is the original, preserved as written. What's new: the old phone bridge is gone โ€” the caster now lives in a Docker container on the OpenWrt router itself.

The phone was the right prototype: it proved the whole queue โ†’ bridge โ†’ catt โ†’ Mini architecture in an evening, with zero new hardware. But a phone is a bad 24/7 server โ€” battery babysitting, an 80% charge limit, Android power management fighting the script, Termux fragility. The router is already always-on and already the network hub, so the bridge moved there.

The new nest-caster container:

First morning's test log: a test MP3, a YouTube video, a youtu.be link โ€” all "Playing!" on the first attempt. The phone earned its retirement; it proved the idea before the real infrastructure took over.

Lesson: prototype with what's in the drawer, productionize on what's always on.

Chapter 2 ยท October 18, 2026: review pass

โ—€ new here? read from the start

Five fixes from a review โ€” the article catching up with reality.

1. "What's Next" is now "What's Done"

The original "What's Next" above lists scheduled announcements as a future idea. That's done now โ€” the bed light runs on cron (on in the evening when the main bulb goes off, off in the early hours), with retries and reporting instead of silent failures. Still genuinely future: sensor-triggered audio, multi-room sync.

2. The bulb that works while "disconnected"

Real-world finding since the original write-up: the WiZ bulb's schedules are device-local โ€” they fire even when the app shows the bulb as "disconnected." That's not a bug, it's the whole thesis proving itself: the cloud is a convenience layer, not a requirement. The schedule doesn't need anyone's permission to run.

3. The nest-caster recipe

The morning's update said what the container does โ€” here's the how, for anyone who wants to replicate it:

FROM python:3.12-slim
RUN pip install --no-cache-dir catt
COPY caster.py /app/caster.py
CMD ["python3", "/app/caster.py"]

It lives on the router's SD card (Docker data root), long-polls the VPS queue (server holds each request ~50s, so commands act instantly โ€” no more 5-second poll loop), and re-serves Tailscale-hosted audio on the LAN at :8123 because the Mini can't reach Tailscale. YouTube links skip the middleman and cast directly.

4. The full picture

[VPS queue :8888]
        |  Tailscale (long-poll, ~50s hold)
        v
[OpenWrt router: nest-caster (Docker)]
   | re-serve :8123            | UDP 38899
   v                           v
[Nest Mini]                [WiZ bulb]
(via catt, 3x retry)       (direct local control)

5. Honest hybrid

The original article's banner is "no cloud required" โ€” and that was true of the setup it described. But the smart home has grown since: Tuya cloud devices joined the family (see Killing the Token Treadmill). The honest framing now: local where possible, cloud where pragmatic. The WiZ bulb answers to nobody; the Tuya plugs answer through an open API. Both beat a walled garden.

Chapter 3 ยท October 20, 2026: the voice gets a job

โ—€ new here? read from the start

The speaker that played my music learned to warn me.

Where I live, the power company announces outages on a WhatsApp channel โ€” but the announcements cover a whole region, and I only care about my area.

Now the message has a voice. When an alert fires, my AI speaks it through the Nest Mini: "Daddy, prepare! PLN just mentioned your area โ€” a power outage may be coming." Same pipeline that plays my music: a queue on my VPS, a Docker caster on my router, and a voice clip generated on the fly. One lesson carried over: synthetic voices record whisper-quiet, so every clip gets normalized before it ever reaches the speaker.

Update โ€” the same day, the watchdog behind it grew up (V2). The alert now forwards the full announcement text, and the chain extracts the outage schedule from it โ€” so the voice doesn't just warn anymore, it tells me the time: "Daddy, prepare! Outage scheduled 12:00 to 16:00 WITA โ€” charge everything!" The watchdog's own upgrade story is in Chapter 2 of Two Watchdogs.

The pattern emerging here: the Mini isn't a music player that occasionally talks. It's a voice interface โ€” music, bedtime stories, and now timely spoken alerts, all through one always-on pipe I built myself. The voice was the project all along; I just started with songs.

๐Ÿ“ Story so far

The phone bridge is retired โ€” a Docker caster on the router does the casting now, faster and always-on. The Mini has graduated from music player to voice interface: it speaks outage alerts the moment the watchdog fires. The house keeps learning new tricks; the next chapter arrives when it does.

โ† Back to home