Paul's Programming Notes PostsRSSGithub

A wired Ethernet speaker for Home Assistant announcements

I wanted a speaker that Home Assistant can use for announcements like “Front door opened”. The easy mode is a Home Assistant Voice PE on Wi-Fi. I prefer wired when a spot has an Ethernet port, and the Voice PE is Wi-Fi only. My spot is next to one of my access points, which doubles as a small network switch.

Wired options

Wired ones are kind of rare. All I needed was an Ethernet jack, and I prefer open-source software. These are the options I found:

  • The WiiM Sound Lite ($229) has a 10/100 Ethernet port and an official Home Assistant integration (since 2026.4) with TTS duck-and-resume. It sets up through the WiiM Home app, runs closed-source firmware, and has no microphones. It’s the easier pick if you want a music speaker.
  • Sonocotta’s Esparagus Echo Duo ($45) is basically an ESP32-S3 with a pair of MAX98357 amps, an Ethernet jack and two microphones. It takes a passive speaker on its screw terminals, and Sonocotta publishes ESPHome configs for it.
  • Sonocotta’s Louder ESParagus ($69) has a much bigger amp, enough for a pair of passive bookshelf speakers like Micca’s MB42s ($80), which makes it the open-source music option. It’s a classic ESP32, so wake word won’t run on it.
  • A Waveshare ESP32-S3-ETH ($26-35) plus a MAX98357A breakout is the Echo Duo assembled by hand.

I went with the Echo Duo, which came to about $100 with the speaker below, against $59 for a Voice PE at launch, so I’m mostly paying extra for the wired connection. Sonocotta’s Tindie listing won’t ship to the US, and Elecrow was the only option I found that does, at $11.27 shipping.

The passive speaker

The Echo Duo’s outputs are already amplified, so it needs a passive speaker. Its amps are small, 1.8 W into 8 ohms at most per the MAX98357A datasheet, which is plenty for speech but won’t drive a pair of bookshelf speakers at music volume.

Mine is a Dayton Audio PS95-8 driver in geroulas’s printed desktop enclosure on MakerWorld, about $40 with the heat-set inserts. It reaches about 79 dB at three metres, slightly quieter than a smoke alarm, which has to hit 85 dB at that distance.

Board revisions and config

Rev A runs the microphones and amps on one I2S bus, which ESPHome can’t handle, so wake word only works from Rev B onward. Sonocotta ships a voice-assistant config too, so a newer board can become a wired voice satellite later, though wake word currently reads only one of its two microphones.

Sonocotta’s ESPHome configs have a folder per board revision. Mine was sold as Rev C and the repo only had Rev A and Rev B folders, so I used the Rev B media-player example, which works for audio out. Switching it to Ethernet means swapping monitoring-wifi.yaml for the commented-out ethernet-w5500.yaml in its packages list and deleting its wifi: block. The example pulls its packages from ref: main, so I pinned both refs to a commit to keep an upstream push from changing how the board behaves.

Flashing it

  • Skip Sonocotta’s web-installer onboarding, which assumes the board joins Wi-Fi first. Build the Ethernet config in ESPHome, flash over USB-C, then add the device in Home Assistant by IP.
  • The ESP32-S3 has built-in USB instead of a separate serial chip. A flash that fails with “Resource busy” and then “No such file or directory” is that USB port re-enumerating, not a bad cable. Hold IO0, tap RST, release IO0, and re-run. Both buttons are on the board, so the case has to come off.
  • Reserve the IP in DHCP against its MAC, since the config sets no static IP.
  • It needs USB-C power as well as Ethernet, since there’s no PoE.

The announce script

Sonocotta’s media-player package waits 0.75 seconds into an announcement before switching to the announce volume, so the start of “Front door opened” played at the quieter 45% media volume and I mostly heard “opened”. Announcements also cut each other off, since tts.speak returns as soon as the URL is handed over and the ESPHome integration doesn’t pass an enqueue flag.

My script forces the volume up front, plays a chime ahead of the speech, and waits for the player to go back to idle instead of sleeping:

announce:
  mode: queued
  fields:
    message:
      required: true
  variables:
    player: media_player.echo_duo
  sequence:
  - action: number.set_value
    target:
      entity_id: number.echo_duo_announce_volume
    data:
      value: 100
  - action: media_player.volume_set
    target:
      entity_id: '{{ player }}'
    data:
      volume_level: 1.0
  - action: media_player.play_media
    target:
      entity_id: '{{ player }}'
    data:
      media_content_id: /local/chime.wav
      media_content_type: music
      announce: true
  - wait_template: "{{ is_state(player, 'playing') }}"
    timeout:
      seconds: 3
  - wait_template: "{{ is_state(player, 'idle') }}"
    timeout:
      seconds: 5
  - action: tts.speak
    target:
      entity_id: tts.piper
    data:
      media_player_entity_id: '{{ player }}'
      message: '{{ message }}'
  - wait_template: "{{ is_state(player, 'playing') }}"
    timeout:
      seconds: 8
  - wait_template: "{{ is_state(player, 'idle') }}"
    timeout:
      seconds: 20

The waits work because the ESPHome API reports an announcement as playing. The media player switches to playing when the chime or the speech starts and back to idle when it finishes, so each step waits for that switch instead of guessing how long the audio runs. mode: queued keeps two events seconds apart in order, instead of the second cutting off the first.

The chime can be any short sound file in Home Assistant’s www folder, which serves it at /local/. I almost never hear mine, so the start of each announcement still seems to get cut off, and the chime is what gets cut off instead of the first word. Home Assistant’s own pre-announce sound only works with voice satellites.

I only use the speaker for alerts I want to hear right away, like a water leak, and everything else stays on my phone. Automations pass the entity’s friendly name, so renaming an entity in Home Assistant changes what it says:

- action: script.announce
  data:
    message: '{{ trigger.to_state.attributes.friendly_name }} opened.'

Home Assistant on a separate network

None of this matters if the speaker and Home Assistant share a network. Mine don’t.

The IoT network

By default my IoT devices can’t start a connection to anything. Their VLAN has no internet and is blocked from my other networks, and my OpenWrt access points run client isolation (option isolate '1'), so Wi-Fi devices on the same access point can’t reach each other either. Home Assistant reaches in to them, and the few devices that need to reach out get a firewall rule each.

Traffic to another network goes through the router, where the firewall rules apply. Traffic between two devices on the same VLAN never reaches the router, and that’s the path isolation blocks:

HA --> router --> AP --> bulb
        ^
  firewall rules

bulb --> AP --X--> camera
              ^
       client isolation

The firewall rule

The speaker is one of those devices. Home Assistant doesn’t stream audio to it. It sends a URL, and the speaker opens its own connection back to fetch the file, chime included, so it needs a rule to reach Home Assistant’s web port, whether it’s wired or on Wi-Fi.

Putting the speaker on Home Assistant’s VLAN would skip the rule, but it would also put the speaker next to Home Assistant, Z-Wave JS and Frigate. My rule only lets the speaker’s address through.

A speaker that shows as connected in Home Assistant and plays nothing usually means one of these:

  • The rule sits below the “block all other private networks” rule. OPNsense stops at the first match, so it has to go above.
  • Home Assistant’s internal_url (Settings -> System -> Network, labeled “Local network”) isn’t the IP and port the rule allows. With a hostname there, the speaker’s log shows a getaddrinfo() error, because the IoT network has no DNS.