Disclosure: I work for Apollo Automation. The MTR-1 in this post is one of our devices and this is firmware I work on.
A customer linked me the new smlight.play_rtttl action from Home Assistant 2026.6 and asked if we had plans to add it to the MTR-1. Which was confusing, because the MTR-1 has had an RTTTL buzzer action for years. Then I looked at what our action actually looks like in Developer tools:

That’s it. No description, a field called song_str with no explanation, and an auto-generated sentence saying the action performs the action. The customer wasn’t asking for a feature. He was telling me our feature was invisible, and he was right.
The blank form wasn’t Home Assistant’s fault
My first assumption was that Home Assistant just renders ESPHome actions lazily. Wrong. I read the integration code: it attaches a selector per argument type, generates a description, fills in type-based examples. It renders everything it receives. The problem is what it receives. The ESPHome native API sends exactly two things per action argument: a name and a type. There is nowhere in the protocol to put “this is an RTTTL string, here’s what one looks like.”
SMLIGHT’s action looks nice because it’s a real integration-owned service with hand-written descriptions in a strings file. An ESPHome device can’t do that. No ESPHome device can, from any vendor.
I said as much on Discord and one of the ESPHome maintainers confirmed it: the device needs to send the description, then Home Assistant needs to show it. Both sides. His advice was to keep going until something works end to end.
The protocol needed two new fields
The fix touches three repos in a chain: ESPHome firmware sends the metadata, the aioesphomeapi client library parses it, the Home Assistant integration displays it. There was a merged PR from December that added a field through this exact path, so I mostly copied its structure.
The YAML side gained a mapping form for variables, next to the existing shorthand:
api:
actions:
- action: play_buzzer
description: Play a melody on the buzzer
variables:
song_str:
type: string
description: Melody in RTTTL format
example: "two_short:d=4,o=5,b=100:16e6,16e6"
ESPHome is strict about flash and RAM, so the protocol fields are compiled out entirely unless a config actually declares metadata, and the strings are flash literals. I built my real MTR-1 firmware both ways to get numbers instead of claims: an unchanged config is size-identical to before, and with two described actions the cost was 526 bytes of flash and 24 bytes of RAM. The 24 matched the pointer-array math exactly, which was satisfying.

One action, not two
I’d been thinking about SMLIGHT parity as a second action that takes notes, octave, duration and bpm as separate fields. The maintainer’s reaction: what’s the difference? Don’t add a new action, make one action accept the whole string or the individual parts.
The reason that wasn’t possible is another protocol gap: ESPHome action arguments are all mandatory. There’s no optional concept on the wire, so Home Assistant renders every variable as required. One action with two input styles needs fields you can leave empty.
So that became feature two: variables can declare required: false and a default, the device sends both over the API, and Home Assistant fills in the defaults before calling. The firmware’s execute path doesn’t change at all, which means every existing client keeps working untouched. The end result on my flashed MTR-1 is the cover image: one play_buzzer with five described fields, octave, duration and bpm prefilled with 5, 4 and 100 straight from the device. Type four comma-separated notes and it plays them. Paste a full RTTTL ringtone and that wins instead.
What wasted my time
Testing the Home Assistant side needs Linux, and my hand-built WSL test environment caused more problems than the feature itself:
- Home Assistant’s test suite failed with 143 errors that all said
Translation not found. Not my bug: a source checkout needspython -m script.translations develop --allrun once before service descriptions load. After that, 143 passed. - My locally-built aioesphomeapi got silently replaced by the released version twice, because later dependency installs re-resolved its pin. The symptom was an
AttributeErroron a field I knew existed. Verify the import right before you trust a test run. - WSL shuts its VM down when the last Windows-side process exits. That killed my background Home Assistant repeatedly, and once it discarded a finished pip install because the writes hadn’t synced yet. The install printed success, then evaporated with the VM.
- localhost:8123 served the Apollo installer instead of Home Assistant. That one was self-inflicted: other Claude sessions of mine had static file servers parked on the port for screenshot work. Check who owns a port before blaming the thing you just deployed.
The code itself went through three rounds of adversarial review agents before filing, and the sharpest catch was none of the C++: my custom YAML validator would have silently broken ESPHome’s autocomplete schema for editors, because the schema extractor only understands registered validator shapes. Swapped it for the stock one and deleted code in the process.
Six PRs, one still waiting
Six PRs went up yesterday: two to ESPHome (the metadata, then optional variables stacked on top), two to aioesphomeapi, two to the docs. A seventh to Home Assistant core is written and tested but waits for the client library release it depends on. If it all merges, every ESPHome device from any vendor gets to describe its actions, and the customer’s question becomes a config change we roll out across the fleet.
The buzzer part is silly. That’s sort of the point. The gap only became visible because someone compared our silly buzzer form to a nicer silly buzzer form, and the fix turned out to be protocol work that’s been missing for everyone.