Compare commits

...
4 Commits
Author SHA1 Message Date
bhethermanandClaude Sonnet 5 d1b27ab7b8 Restore correct room ID mapping, fix select_source race in wait guard
Validate / Validate with HACS (push) Canceled after 0s
Validate / Validate with Hassfest (push) Canceled after 0s
Confirmed by direct hardware check: a334c8ba...=Bedroom, d0166d6f...=
Living Room -- the swap from two commits ago was right, my revert of
it was wrong.

The actual bug behind the last "Living Room played in Bedroom" report
was that the wait loop before play_media only checked supported_features
(is play_media possible at all), not whether select_source's target had
actually taken effect -- so if some other device was already active
from earlier testing, the feature-gate could pass immediately while
Spotify was still routing to that old device. Now the loop also waits
for the source attribute to match the requested room's ID before
proceeding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:47:36 -04:00
bhethermanandClaude Sonnet 5 428579244a Revert room ID mapping swap -- original direction was correct
The prior swap (a334...=Bedroom, d0166...=Living Room) was based on a
misdiagnosis: pre-fix, the cold-start race meant play_media could fire
before select_source's device transfer had actually completed, so
playback landed on whatever device was already active rather than the
requested room -- that looked like "backwards IDs" but wasn't. Now
that the race is fixed, select_source reliably lands wherever it's
told, which is exactly what exposed the swap as wrong: a live test
asking for Living Room played in the Bedroom. Reverting to the
original mapping (a334...=Living Room, d0166...=Bedroom).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:33:51 -04:00
bhethermanandClaude Sonnet 5 291f526a84 Disambiguate play_music vs queue_music by verb, not content type
queue_music's tool description explains at length that playlists/
albums/artists auto-play instead of queueing, and the small model was
using that as a signal to route ANY playlist/genre request through
queue_music -- even ones that said "play". Verified: "play jazz in
the bedroom" called queue_music 0/5 reps before this change (and in
the opaque-ID room variant), 5/5 play_music after, with no
regressions across the rest of the suite (199/200, 39/40 reliable).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:01:44 -04:00
bhethermanandClaude Sonnet 5 2ed1ca1e99 Fix cold-start play_media failure and swap reversed room IDs
The Spotify integration only exposes the media_player.play_media
service once it has an active playback context, which doesn't exist
yet on a cold start; the fixed 2s delay after select_source wasn't
long enough in that case even though it always worked once something
had already played. Poll supported_features for the PLAY_MEDIA bit
(up to 8s) before calling play_media instead of guessing a fixed delay.

Also swap the Living Room/Bedroom source_list ID mapping in both the
prompt (for display) and the functions (for select_source) -- it was
backwards.

Adds a test-harness regression case exercising the opaque Spotify
Connect device IDs the source_list actually reports.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 13:40:40 -04:00
3 changed files with 75 additions and 22 deletions
+26 -4
View File
@@ -94,8 +94,19 @@
target:
entity_id: media_player.spotify_bhetherman
data:
source: "{{ room }}"
- delay: "00:00:02"
source: "{{ room | replace('Living Room', 'd0166d6f83796b0f9059b0adf0f6cb0d81fa6376') | replace('Bedroom', 'a334c8ba989367b52303eaa9dbc424cdd83012b5') }}"
- repeat:
while:
- condition: template
value_template: >-
{{ repeat.index <= 8 and (
state_attr('media_player.spotify_bhetherman', 'source') !=
(room | replace('Living Room', 'd0166d6f83796b0f9059b0adf0f6cb0d81fa6376') | replace('Bedroom', 'a334c8ba989367b52303eaa9dbc424cdd83012b5'))
or (state_attr('media_player.spotify_bhetherman', 'supported_features') | int(0)
| bitwise_and(512)) != 512
) }}
sequence:
- delay: "00:00:01"
- service: media_player.play_media
target:
entity_id: media_player.spotify_bhetherman
@@ -137,8 +148,19 @@
target:
entity_id: media_player.spotify_bhetherman
data:
source: "{{ room }}"
- delay: "00:00:02"
source: "{{ room | replace('Living Room', 'd0166d6f83796b0f9059b0adf0f6cb0d81fa6376') | replace('Bedroom', 'a334c8ba989367b52303eaa9dbc424cdd83012b5') }}"
- repeat:
while:
- condition: template
value_template: >-
{{ repeat.index <= 8 and (
state_attr('media_player.spotify_bhetherman', 'source') !=
(room | replace('Living Room', 'd0166d6f83796b0f9059b0adf0f6cb0d81fa6376') | replace('Bedroom', 'a334c8ba989367b52303eaa9dbc424cdd83012b5'))
or (state_attr('media_player.spotify_bhetherman', 'supported_features') | int(0)
| bitwise_and(512)) != 512
) }}
sequence:
- delay: "00:00:01"
- service: media_player.play_media
target:
entity_id: media_player.spotify_bhetherman
+20 -15
View File
@@ -1,18 +1,6 @@
I want you to act as smart home manager of Home Assistant.
I will provide information of smart home along with a question, you will truthfully make correction or answer using information provided in one sentence in everyday language.
Current Time: {{now()}}
Available Devices:
```csv
entity_id,name,state,aliases
{% for entity in exposed_entities -%}
{{ entity.entity_id }},{{ entity.name }},{{ entity.state }},{{entity.aliases | join('/')}}
{% endfor -%}
```
Available Spotify Connect rooms (valid values for the "room" parameter): {{ state_attr('media_player.spotify_bhetherman', 'source_list') | join(', ') }}
The current state of devices is provided in available devices.
Use execute_services function only for requested action, not for current states.
Do not execute service without user's confirmation. Exception: music playback commands (search_spotify, play_music, queue_music, control_playback) never require confirmation — execute immediately per the Music Playback Rules below.
@@ -28,6 +16,10 @@ For any request to play or queue music, always call TWO functions in order, with
When calling play_music or queue_music, always set type to the exact "type" value that search_spotify's result returned — never guess or hardcode it. Passing the wrong type silently breaks playback.
This choice depends ONLY on the verb the user used ("play" vs "queue"/"add to queue"), never on the content type. A playlist or genre request that says "play" still calls play_music, not queue_music — queue_music's playlist/album/artist auto-play behavior only applies when the user actually said "queue"/"add to queue".
- "Play jazz in the bedroom" → play_music (jazz resolves to a playlist, but the user said "play")
- "Queue jazz in the bedroom" → queue_music
RULE: if the request has the form "[title] by [artist]" (a specific song/album title followed by "by" and an artist name — no matter what words come before it, e.g. "play", "queue", "throw on", "put on", "the album", "the song"), the query is ONLY the title — always strip " by [artist]" from the end. Never include the word "by" or the artist name in the query for this pattern.
- "Play Yellow by Coldplay" → query="Yellow"
- "Throw on the album Parachutes by Coldplay" → query="Parachutes"
@@ -36,8 +28,8 @@ RULE: if the request has the form "[title] by [artist]" (a specific song/album t
How to fill in search_spotify(query, type) — match these examples:
- "Play Coldplay" → query="Coldplay", type="artist"
- "Play songs by Taylor Swift on the kitchen speaker" → query="Taylor Swift", type="artist"
- "Play some music by Jungle in the living room" → query="Jungle", type="artist"
- "Play back on 74 in the living room" → query="Back on 74", type="track" (this is a song title, not a volume command)
- "Play some music by Jungle" → query="Jungle", type="artist"
- "Play back on 74" → query="Back on 74", type="track" (this is a song title, not a volume command)
- "Play the song Yellow" → query="Yellow", type="track"
- "Play Yellow by Coldplay" → query="Yellow", type="track"
- "Play 1979 by the Smashing Pumpkins" → query="1979", type="track" (a bare number here is a song title, not an album — default to "track" unless the user explicitly says "album")
@@ -54,9 +46,22 @@ Volume/playback control examples (use control_playback, NOT search_spotify):
- "Skip this song" → action="next_track"
Room:
- The "room" parameter for play_music/queue_music must exactly match one of the Available Spotify Connect rooms listed above.
- The "room" parameter for play_music/queue_music must exactly match one of the Available Spotify Connect rooms listed below.
- If no room is specified, default room="Living Room"
- control_playback never takes a room parameter — it acts on whatever is currently playing
After playing an artist (type="artist" search), also call control_playback with action="shuffle_on".
Playlist searches check the user's own library first, then fall back to public Spotify.
Current Date: {{ now().strftime('%B %d, %Y') }}
Current Time: {{ now().strftime('%I:%M %p') }}
Available Devices:
```csv
entity_id,name,state,aliases
{% for entity in exposed_entities -%}
{{ entity.entity_id }},{{ entity.name }},{{ entity.state }},{{entity.aliases | join('/')}}
{% endfor -%}
```
Available Spotify Connect rooms (valid values for the "room" parameter): {{ state_attr('media_player.spotify_bhetherman', 'source_list') | join(', ') | replace('d0166d6f83796b0f9059b0adf0f6cb0d81fa6376', 'Living Room') | replace('a334c8ba989367b52303eaa9dbc424cdd83012b5', 'Bedroom') }}
+28 -2
View File
@@ -26,14 +26,28 @@ MODEL = os.environ.get("OLLAMA_MODEL") # set this to the exact `ollama list` ta
MOCK_SOURCE_LIST = ["Living Room", "Web Player (Firefox)", "Bedroom"]
# Mirrors the real (currently broken) source_list this entity actually reports —
# Spotify Connect device IDs instead of friendly names — to exercise the
# opaque-ID fallback mapping added to current-prompt.txt's Room section.
MOCK_SOURCE_LIST_IDS = [
"a334c8ba989367b52303eaa9dbc424cdd83012b5",
"d0166d6f83796b0f9059b0adf0f6cb0d81fa6376",
]
ROOM_NAME_TO_ID = {
"Living Room": "a334c8ba989367b52303eaa9dbc424cdd83012b5",
"Bedroom": "d0166d6f83796b0f9059b0adf0f6cb0d81fa6376",
}
def render_prompt():
def render_prompt(source_list=None):
if source_list is None:
source_list = MOCK_SOURCE_LIST
with open(os.path.join(HERE, "current-prompt.txt")) as f:
raw = f.read()
def state_attr(entity_id, attr):
if entity_id == "media_player.spotify_bhetherman" and attr == "source_list":
return MOCK_SOURCE_LIST
return source_list
return None
template = Template(raw)
@@ -447,6 +461,18 @@ def main():
for label, user_message, expect_step2_name, expect_room, expect_type in TESTS_FLOW:
results.append((label, check_flow(label, user_message, expect_step2_name, expect_room, expect_type, system_prompt, tools)))
print(f"\n{'#' * 40}\n# Same flow tests, but source_list reports opaque IDs (regression test for\n"
f"# current-prompt.txt's ID-to-name normalization in the Room section — the\n"
f"# model should still see and use plain names; it never has to know IDs exist)\n{'#' * 40}")
system_prompt_ids = render_prompt(MOCK_SOURCE_LIST_IDS)
for label, user_message, expect_step2_name, expect_room, expect_type in TESTS_FLOW:
if expect_room not in ROOM_NAME_TO_ID:
continue
results.append((
f"{label} [opaque-ID rooms]",
check_flow(label, user_message, expect_step2_name, expect_room, expect_type, system_prompt_ids, tools),
))
total_passed = sum(passed for _, (passed, n) in results)
total_attempts = sum(n for _, (passed, n) in results)
reliable = [label for label, (passed, n) in results if passed == n]