init.py
"""JamSpy Music Recognition Integration."""
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.const import Platform
_LOGGER = logging.getLogger(__name__)
DOMAIN = "jamspy"
PLATFORMS = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up JamSpy from a config entry."""
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {}
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Register service
async def handle_recognize(call):
"""Handle the recognize service call."""
# Get all jamspy sensors
sensors = [
entity for entity in hass.data["entity_registry"].entities.values()
if entity.platform == DOMAIN and entity.domain == "sensor"
]
for sensor_entry in sensors:
sensor = hass.data["entity_registry"].entities[sensor_entry.entity_id]
if sensor:
await hass.services.async_call(
"homeassistant", "update_entity",
{"entity_id": sensor_entry.entity_id},
blocking=True
)
hass.services.async_register(DOMAIN, "recognize", handle_recognize)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
config_flow.py
"""Config flow for JamSpy."""
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
import homeassistant.helpers.config_validation as cv
DOMAIN = "jamspy"
class JamSpyConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for JamSpy."""
VERSION = 1
async def async_step_user(self, user_input=None) -> FlowResult:
"""Handle the initial step."""
errors = {}
if user_input is not None:
# Check if already configured
await self.async_set_unique_id("jamspy_main")
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="JamSpy Music Recognition",
data=user_input
)
schema = vol.Schema({
vol.Optional("name", default="JamSpy"): str,
})
return self.async_show_form(
step_id="user",
data_schema=schema,
errors=errors
)
manifest.json
{
"domain": "jamspy",
"name": "JamSpy Music Recognition",
"version": "1.0.0",
"documentation": "https://github.com/yourname/jamspy",
"dependencies": [],
"codeowners": ["@yourgithub"],
"requirements": ["aiohttp"],
"config_flow": false
}
sensor.py
"""JamSpy Music Recognition Sensor using FFmpeg + PulseAudio."""
import logging
import asyncio
import aiohttp
import tempfile
import os
from datetime import datetime
from typing import Optional, Dict, Any
from homeassistant.components.sensor import SensorEntity
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity_platform import AddEntitiesCallback
_LOGGER = logging.getLogger(__name__)
AUDD_API_TOKEN = "9ca714cbb140117074722ff227ebb537"
DOMAIN = "jamspy"
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
"""Set up JamSpy sensor from a config entry."""
sensor = JamSpySensor()
async_add_entities([sensor], True)
hass.data.setdefault(DOMAIN, {})["sensor"] = sensor
class JamSpySensor(SensorEntity):
"""JamSpy Music Recognition Sensor."""
def __init__(self):
self._attr_name = "JamSpy Music Recognition"
self._attr_unique_id = "jamspy_music_recognition"
self._attr_icon = "mdi:music-note"
self._attr_native_value = "Ready"
self._attr_extra_state_attributes = {
"title": None,
"artist": None,
"album": None,
"last_recognition": None,
"recording_method": "ffmpeg-pulse"
}
self._added_to_hass = False
async def async_added_to_hass(self):
"""Mark that the entity has been added and is ready to write state."""
self._added_to_hass = True
async def async_update(self):
await self.recognize_music()
async def recognize_music(self, duration: int = 8):
try:
_LOGGER.info(f"JamSpy: Starting {duration}s recording")
self._attr_native_value = "Recording..."
await self._safe_write_ha_state()
audio_data = await self._record_with_ffmpeg(duration)
if audio_data:
self._attr_native_value = "Recognizing..."
await self._safe_write_ha_state()
result = await self._recognize_audio(audio_data)
await self._update_state(result)
else:
self._attr_native_value = "Recording failed"
await self._safe_write_ha_state()
except Exception as e:
_LOGGER.error(f"JamSpy: Recognition failed: {e}")
self._attr_native_value = f"Error: {str(e)}"
await self._safe_write_ha_state()
async def _safe_write_ha_state(self):
"""Safely write state only after being added to Home Assistant."""
if self._added_to_hass:
self.async_write_ha_state()
async def _record_with_ffmpeg(self, duration: int) -> Optional[bytes]:
try:
temp_file = f"/tmp/jamspy_{datetime.now().timestamp()}.wav"
cmd = [
"ffmpeg",
"-f", "pulse",
"-i", "alsa_input.usb-C-Media_Electronics_Inc._USB_PnP_Sound_Device-00.mono-fallback",
"-t", str(duration),
"-acodec", "pcm_s16le",
"-ar", "44100",
"-ac", "1",
temp_file,
"-y"
]
_LOGGER.info(f"JamSpy: Recording with: {' '.join(cmd)}")
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
_, stderr = await process.communicate()
if process.returncode == 0 and os.path.exists(temp_file):
with open(temp_file, 'rb') as f:
audio_data = f.read()
os.unlink(temp_file)
return audio_data
else:
_LOGGER.error(f"JamSpy: FFmpeg failed: {stderr.decode()}")
return None
except Exception as e:
_LOGGER.error(f"JamSpy: Recording error: {e}")
return None
async def _recognize_audio(self, audio_data: bytes) -> Optional[Dict[str, Any]]:
try:
_LOGGER.info("JamSpy: Sending to Audd.io for recognition")
async with aiohttp.ClientSession() as session:
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_file:
temp_file.write(audio_data)
temp_file.flush()
data = aiohttp.FormData()
data.add_field('api_token', AUDD_API_TOKEN)
data.add_field('return', 'spotify,apple_music')
with open(temp_file.name, 'rb') as f:
data.add_field('file', f, filename='audio.wav', content_type='audio/wav')
async with session.post("https://api.audd.io/", data=data) as response:
os.unlink(temp_file.name)
if response.status == 200:
return await response.json()
_LOGGER.error(f"JamSpy: Audd.io error: {response.status}")
return None
except Exception as e:
_LOGGER.error(f"JamSpy: Recognition error: {e}")
return None
async def _update_state(self, result: Optional[Dict[str, Any]]):
if result and result.get("status") == "success" and result.get("result"):
song = result["result"]
self._attr_native_value = f"{song.get('title', 'Unknown')} - {song.get('artist', 'Unknown')}"
self._attr_extra_state_attributes.update({
"title": song.get("title"),
"artist": song.get("artist"),
"album": song.get("album"),
"last_recognition": datetime.now().isoformat()
})
else:
self._attr_native_value = "No music detected"
self._attr_extra_state_attributes.update({
"title": None,
"artist": None,
"album": None,
"last_recognition": datetime.now().isoformat()
})
await self._safe_write_ha_state()
services.yaml
recognize:
name: Recognize Music
description: Trigger music recognition
strings.json
{
"config": {
"step": {
"user": {
"title": "JamSpy Setup",
"description": "Set up JamSpy music recognition",
"data": {
"name": "Name"
}
}
}
}
}
configuration.yaml
template:
- sensor:
- name: "JamSpy Last Song"
state: "{{ states('sensor.jamspy_music_recognition') }}"
attributes:
album_art: "{{ state_attr('sensor.jamspy_music_recognition', 'album_art') }}"
availability: >
{{ states('sensor.jamspy_music_recognition') not in ['unknown', '', None] }}:
- sensor:
- name: "JamSpy Active"
state: >
{% if now().timestamp() - as_timestamp(states.sensor.jamspy_last_song.last_changed) < 900 %}
active
{% else %}
idle
{% endif %}