[
  {
    "path": "README.md",
    "content": "# TTS Bluetooth Speaker for Home Assistant\n\nThis project provides a media player (custom component) for Home Assistant that plays TTS (text-to-speech) via a Bluetooth speaker.\n\nIf you're using HA's Bluetooth device tracker (for presence detection), this project also provides a replacement Bluetooth tracker that allows both components to play nicely together.\n\nSince the Bluetooth tracker constantly scans for devices, playback of audio on the Bluetooth speaker may be disrupted / become choppy while scanning. These custom components work together to ensure only one of them is accessing Bluetooth at any given time.\n\nThe flow is something like this:\n\n- Bluetooth tracker component continually scans for devices (presence detection)\n- TTS service gets called to play something on the Bluetooth speaker\n- TTS Bluetooth speaker component disables Bluetooth tracker component\n- Bluetooth tracker component terminates any running Bluetooth scans\n- TTS Bluetooth speaker component plays the TTS MP3 file\n- TTS Bluetooth speaker component enables Bluetooth tracker component\n- Bluetooth tracker component continues scanning for devices (presence detection)\n\n## Getting Started\n\n### 1) Install Pulse Audio (with Bluetooth support), MPlayer and SoX (with MP3 support)\n\n```\nsudo apt-get install pulseaudio pulseaudio-module-bluetooth bluez mplayer sox libsox-fmt-mp3\n```\n\n### 2) Add HA and pi user to 'pulse-access' group (pi user for testing, homeassistant for the service)\n\n```\nsudo adduser pi pulse-access\nsudo adduser homeassistant pulse-access\n```\n\n### 3) Add Bluetooth discovery to Pulse Audio\n\nIn `/etc/pulse/system.pa`, add the following to the bottom of the file:\n\n```\n### Bluetooth Support\n.ifexists module-bluetooth-discover.so\nload-module module-bluetooth-discover\n.endif\n\n#set-card-profile bluez_card.00_2F_AD_12_0D_42 a2dp_sink\n```\n\nThe last part is to persist the setting for a2dp, in case your bluetooth seems to default to a different profile.  I have commented it out because it seems to be flakey.\n\nYou may want to uncomment this line if your audio is getting cut off:\n```\n### Automatically suspend sinks/sources that become idle for too long\n#load-module module-suspend-on-idle\n```\n\n### 4) Create a service to run Pulse Audio at startup\nCreate the file `/etc/systemd/system/pulseaudio.service` and add the following to it:\n\n```\n[Unit]\nDescription=Pulse Audio\n\n[Service]\nType=simple\nEnvironment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket\nExecStart=/usr/bin/pulseaudio --system --disallow-exit --disable-shm --exit-idle-time=-1\n\n[Install]\nWantedBy=multi-user.target\n```\n\nEnable the service to start at boot time.\n\n```\nsudo systemctl daemon-reload\nsudo systemctl enable pulseaudio.service\n```\n\nGive pulse user access to bluetooth interfaces\n\nedit `/etc/dbus-1/system.d/bluetooth.conf`\n\nadd the following lines:\n```\n  <policy user=\"pulse\">\n    <allow send_destination=\"org.bluez\"/>\n    <allow send_interface=\"org.bluez.MediaEndpoint1\"/>\n  </policy>\n```\n### 5) Create a script to pair the Bluetooth speaker at startup\n\n```\nsudo bluetoothctl\nscan on\npair 00:2F:AD:12:0D:42\ntrust 00:2F:AD:12:0D:42\nconnect 00:2F:AD:12:0D:42\nquit\n```\n\nCreate the file `[PATH_TO_YOUR_HOME_ASSSISTANT]/scripts/pair_bluetooth.sh` and add the following to it. Make sure to replace the Bluetooth address with that of your Bluetooth speaker.\n\n```\n#!/bin/bash\n\nbluetoothctl << EOF\nconnect 00:2F:AD:12:0D:42\nEOF\n```\nMake sure to grant execute permissions for the script.\n\n```\nsudo chmod a+x [PATH_TO_YOUR_HOME_ASSSISTANT]/scripts/pair_bluetooth.sh\n```\n\nIn `/etc/rc.local`, add the following to the end of the file to run the script at startup:\n\n```\n# Pair Bluetooth devices\n[PATH_TO_YOUR_HOME_ASSSISTANT]/scripts/pair_bluetooth.sh\n\nexit 0\n```\n\n### 6) Add the TTS Bluetooth Speaker to HA\n\nCopy the TTS Bluetooth Speaker component (from this GitHub repo) and save it to your Home Assistant config directory.\n\n```\ncustom_components/tts_bluetooth_speaker/media_player.py\n```\n\n### 7) Optional - Add the (new) Bluetooth Tracker to HA\n\nThis step only applies if you're using the Bluetooth tracker.\n\nCopy the Bluetooth Tracker component and save it to your Home Assistant config directory.\n\n```\ncustom_components/bluetooth_tracker/device_tracker.py\n```\n\n### 8) Validate audio sink is available\n\n`pactl list sinks`\n\nYou should see something like:\n\n```\nSink #1\n        State: SUSPENDED\n        Name: bluez_sink.00_2F_AD_12_0D_42.a2dp_sink\n```\n\nIf it instead says headset_head_unit, you can switch to a2dp profile as follows:\n\n```\npactl set-card-profile bluez_card.00_2F_AD_12_0D_42 a2dp_sink\n```\n\nCheck again and validate it is using a2dp.\n\nTest using command line if mplayer can stream to a2dp\n\n```\nmplayer -ao pulse::bluez_sink.00_2F_AD_12_0D_42.a2dp_sink -channels 2 -volume 100 /some/mp3file.mp3\n```\n\n\n### 9) Start using it in HA\n\nBy this stage (after a reboot), you should be able to start using the TTS Bluetooth speaker in HA.\n\nBelow is an example of how the component is configured. You need to specify the Bluetooth address of your speaker, and optionally set the `volume` level (must be between 0 and 1). If you find your speaker is not playing the first part of the audio (i.e. first second is missing when played back), then you can optionally add some silence before and/or after the original TTS audio hsing the `pre_silence_duration` and `post_silence_duration` options (must be between 0 and 60 seconds). If you've change your TTS cache directory (in your TTS config), then you should set the `cache_dir` here to match.\n\n```\nmedia_player:\n  - platform: tts_bluetooth_speaker\n    address: [BLUETOOTH_ADDRESS]   # Required - for example, 00:2F:AD:12:0D:42\n    volume: 0.45                   # Optional - default is 0.5\n#    pre_silence_duration: 1       # Optional - No. of seconds silence before the TTS (default is 0)\n#    post_silence_duration: 0.5    # Optional - No. of seconds silence after the TTS (default is 0)\n#    cache_dir: /tmp/tts           # Optional - make sure it matches the same setting in TTS config\n```\n\nIf you're using the Bluetooth tracker, you probably already have this in your config:\n\n```\ndevice_tracker:\n  - platform: bluetooth_tracker\n```\n\nTo test that it's all working, you can use **Developer Tools > Services** in the HA frontend to play a TTS message through your Bluetooth speaker:\n\n![image](https://user-images.githubusercontent.com/8870047/57437834-b773ef00-7296-11e9-891e-9a181ebb6520.png)\n\n`{ \"entity_id\": \"media_player.tts_bluetooth_speaker\", \"message\": \"Hello\" }`\n\nAnother way to test it is to add an automation that plays a TTS message whenever HA is started:\n\n```\nautomation: \n  - alias: Home Assistant Start\n    trigger:\n      platform: homeassistant\n      event: start\n    action:\n      - delay: '00:00:10'\n      - service: tts.google_translate_say\n        data:\n          entity_id: media_player.tts_bluetooth_speaker\n          message: 'Home Assistant has started'\n```\n"
  },
  {
    "path": "configuration.yaml",
    "content": "device_tracker:\n  - platform: bluetooth_tracker\n\nmedia_player:\n  - platform: tts_bluetooth_speaker\n    address: 00:2F:AD:12:0D:42\n    volume: 0.45\n#    cache_dir: /tmp/tts    # Optional - make sure it matches the same setting in TTS config\n"
  },
  {
    "path": "custom_components/bluetooth_tracker/__init__.py",
    "content": ""
  },
  {
    "path": "custom_components/bluetooth_tracker/device_tracker.py",
    "content": "\"\"\"\nTracking for bluetooth devices.\n\nFor more details about this platform, please refer to the documentation at\nhttps://home-assistant.io/components/device_tracker.bluetooth_tracker/\n\"\"\"\nimport logging\nimport os\nimport subprocess\n\nimport voluptuous as vol\n\nimport homeassistant.helpers.config_validation as cv\nfrom homeassistant.core import callback\nfrom homeassistant.helpers.event import track_point_in_utc_time\nfrom homeassistant.const import STATE_OFF, STATE_STANDBY, STATE_ON\nfrom homeassistant.components.device_tracker import (\n    YAML_DEVICES, CONF_TRACK_NEW, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL,\n    load_config, PLATFORM_SCHEMA, DEFAULT_TRACK_NEW)\nfrom homeassistant.const import (\n    ATTR_ENTITY_ID)\nimport homeassistant.util.dt as dt_util\n\n_LOGGER = logging.getLogger(__name__)\n\nBT_PREFIX = 'BT_'\n\nDOMAIN = 'device_tracker'\nENTITY_ID = 'bluetooth_tracker'\n\nPLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({\n    vol.Optional(CONF_TRACK_NEW): cv.boolean\n})\n\nBLUETOOTH_TRACKER_SERVICE_SCHEMA = vol.Schema({\n    vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,\n})\n\nBLUETOOTH_TRACKER_SERVICE_TURN_ON = 'bluetooth_tracker_turn_on'\nBLUETOOTH_TRACKER_SERVICE_TURN_OFF = 'bluetooth_tracker_turn_off'\n\ndef setup_scanner(hass, config, see, discovery_info=None):\n    \"\"\"Set up the Bluetooth Scanner.\"\"\"\n    # pylint: disable=import-error\n    import bluetooth\n    import bluetooth._bluetooth as bluez\n\n    hass.states.set(DOMAIN + '.' + ENTITY_ID, STATE_ON)\n\n    def turn_on(call):\n        \"\"\"Turn Bluetooth tracker on.\"\"\"\n        _LOGGER.info(\"Turning on Bluetooth\")\n        hass.states.set(DOMAIN + '.' + ENTITY_ID, STATE_ON)\n\n    def turn_off(call):\n        \"\"\"Turn Bluetooth tracker off.\"\"\"\n        _LOGGER.info(\"Turning off Bluetooth\")\n\n        try:\n            sock = bluez.hci_open_dev(0)\n            bluez.hci_send_cmd(sock, bluez.OGF_LINK_CTL, bluez.OCF_INQUIRY_CANCEL)\n            sock.close()\n\n            _LOGGER.info(\"Turned off Bluetooth\")\n            hass.states.set(DOMAIN + '.' + ENTITY_ID, STATE_OFF)\n\n        except Exception as err:\n            _LOGGER.error(\"Error turning off Bluetooth: %s\", err)\n            sock.close()\n\n    def see_device(device):\n        \"\"\"Mark a device as seen.\"\"\"\n        see(mac=BT_PREFIX + device[0], host_name=device[1])\n\n    def discover_devices():\n        if hass.states.get(DOMAIN + '.' + ENTITY_ID).state != STATE_ON:\n            return []\n\n        _LOGGER.debug(\"Discovering Bluetooth devices\")\n\n        \"\"\"Discover Bluetooth devices.\"\"\"\n        result = bluetooth.discover_devices(\n            duration=8, lookup_names=True, flush_cache=True,\n            lookup_class=False)\n        _LOGGER.debug(\"Bluetooth devices discovered = \" + str(len(result)))\n        return result\n\n    hass.services.register(\n        DOMAIN, BLUETOOTH_TRACKER_SERVICE_TURN_ON, turn_on, schema=BLUETOOTH_TRACKER_SERVICE_SCHEMA)\n\n    hass.services.register(\n        DOMAIN, BLUETOOTH_TRACKER_SERVICE_TURN_OFF, turn_off, schema=BLUETOOTH_TRACKER_SERVICE_SCHEMA)\n\n    # Ensure the Bluetooth tracker is on (if that state has been set)\n    if hass.states.get(DOMAIN + '.' + ENTITY_ID).state == STATE_ON:\n        turn_on(None)\n\n    yaml_path = hass.config.path(YAML_DEVICES)\n    devs_to_track = []\n    devs_donot_track = []\n\n    # Load all known devices.\n    # We just need the devices so set consider_home and home range\n    # to 0\n    for device in load_config(yaml_path, hass, 0):\n        # Check if device is a valid bluetooth device\n        if device.mac and device.mac[:3].upper() == BT_PREFIX:\n            if device.track:\n                devs_to_track.append(device.mac[3:])\n            else:\n                devs_donot_track.append(device.mac[3:])\n\n    # If track new devices is true discover new devices on startup.\n    track_new = config.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW)\n    if track_new:\n        for dev in discover_devices():\n            if dev[0] not in devs_to_track and \\\n               dev[0] not in devs_donot_track:\n                devs_to_track.append(dev[0])\n                see_device(dev)\n\n    interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)\n\n    def update_bluetooth(now):\n        \"\"\"Lookup Bluetooth device and update status.\"\"\"\n        try:\n            if track_new:\n                for dev in discover_devices():\n                    if dev[0] not in devs_to_track and \\\n                       dev[0] not in devs_donot_track:\n                        devs_to_track.append(dev[0])\n            for mac in devs_to_track:\n                if hass.states.get(DOMAIN + '.' + ENTITY_ID).state != STATE_ON:\n                    continue\n\n                _LOGGER.debug(\"Scanning %s\", mac)\n\n                result = bluetooth.lookup_name(mac, timeout=5)\n                if not result:\n                    # Could not lookup device name\n                    continue\n                see_device((mac, result))\n        except bluetooth.BluetoothError:\n            _LOGGER.exception(\"Error looking up Bluetooth device\")\n        track_point_in_utc_time(\n            hass, update_bluetooth, dt_util.utcnow() + interval)\n\n    update_bluetooth(dt_util.utcnow())\n\n    return True\n"
  },
  {
    "path": "custom_components/bluetooth_tracker/manifest.json",
    "content": "{\n    \"domain\": \"bluetooth_tracker\",\n    \"name\": \"BT Tracker\",\n    \"documentation\": \"https://github.com/pkozul/ha-tts-bluetooth-speaker\",\n    \"dependencies\": [],\n    \"codeowners\": [\"@pkozul\"],\n    \"requirements\": [\"pybluez==0.22\"]\n}"
  },
  {
    "path": "custom_components/tts_bluetooth_speaker/__init__.py",
    "content": ""
  },
  {
    "path": "custom_components/tts_bluetooth_speaker/manifest.json",
    "content": "{\n    \"domain\": \"tts_bluetooth_speaker\",\n    \"name\": \"BT Speaker\",\n    \"documentation\": \"https://github.com/pkozul/ha-tts-bluetooth-speaker\",\n    \"dependencies\": [],\n    \"codeowners\": [\"@pkozul\"],\n    \"requirements\": []\n}\n"
  },
  {
    "path": "custom_components/tts_bluetooth_speaker/media_player.py",
    "content": "\"\"\"\nSupport for TTS on a Bluetooth Speaker\n\n\"\"\"\nimport voluptuous as vol\n\nfrom homeassistant.components.media_player import (\n    SUPPORT_PLAY_MEDIA,\n    SUPPORT_VOLUME_SET,\n    PLATFORM_SCHEMA,\n    MediaPlayerDevice)\nfrom homeassistant.const import (\n    CONF_NAME, STATE_OFF, STATE_PLAYING)\nimport homeassistant.helpers.config_validation as cv\n\ntry:\n   from ..device_tracker import bluetooth_tracker\nexcept ImportError:\n   pass\nimport subprocess\n\nimport logging\n\nimport os\nimport re\nimport sys\nimport time\n\nDEFAULT_NAME = 'TTS Bluetooth Speaker'\nDEFAULT_VOLUME = 0.5\nDEFAULT_CACHE_DIR = \"tts\"\nDEFAULT_SILENCE_DURATION = 0.0\nDEFAULT_ADDRESS = ''\n\nSUPPORT_BLU_SPEAKER = SUPPORT_PLAY_MEDIA | SUPPORT_VOLUME_SET\n\nCONF_ADDRESS = 'address'\nCONF_VOLUME = 'volume'\nCONF_CACHE_DIR = 'cache_dir'\nCONF_PRE_SILENCE_DURATION = 'pre_silence_duration'\nCONF_POST_SILENCE_DURATION = 'post_silence_duration'\n\nPLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({\n    vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,\n    vol.Optional(CONF_ADDRESS, default=DEFAULT_ADDRESS): cv.string,\n    vol.Optional(CONF_VOLUME, default=DEFAULT_VOLUME):\n        vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),\n    vol.Optional(CONF_PRE_SILENCE_DURATION, default=DEFAULT_SILENCE_DURATION):\n        vol.All(vol.Coerce(float), vol.Range(min=0, max=60)),\n    vol.Optional(CONF_POST_SILENCE_DURATION, default=DEFAULT_SILENCE_DURATION):\n        vol.All(vol.Coerce(float), vol.Range(min=0, max=60)),\n    vol.Optional(CONF_CACHE_DIR, default=DEFAULT_CACHE_DIR): cv.string,\n})\n\n_LOGGER = logging.getLogger(__name__)\n\ndef setup_platform(hass, config, add_devices, discovery_info=None):\n    \"\"\"Setup the Bluetooth Speaker platform.\"\"\"\n    name = config.get(CONF_NAME)\n    address = config.get(CONF_ADDRESS)\n    volume = float(config.get(CONF_VOLUME))\n    pre_silence_duration = float(config.get(CONF_PRE_SILENCE_DURATION))\n    post_silence_duration = float(config.get(CONF_POST_SILENCE_DURATION))\n    cache_dir = get_tts_cache_dir(hass, config.get(CONF_CACHE_DIR))\n\n    add_devices([BluetoothSpeakerDevice(hass, name, address, volume, pre_silence_duration, post_silence_duration, cache_dir)])\n    return True\n\ndef get_tts_cache_dir(hass, cache_dir):\n    \"\"\"Get cache folder.\"\"\"\n    if not os.path.isabs(cache_dir):\n        cache_dir = hass.config.path(cache_dir)\n    return cache_dir\n\nclass BluetoothSpeakerDevice(MediaPlayerDevice):\n    \"\"\"Representation of a Bluetooth Speaker on the network.\"\"\"\n\n    def __init__(self, hass, name, address, volume, pre_silence_duration, post_silence_duration, cache_dir):\n        \"\"\"Initialize the device.\"\"\"\n        self._hass = hass\n        self._name = name\n        self._is_standby = True\n        self._current = None\n        self._address = address\n        self._volume = volume\n        self._pre_silence_duration = pre_silence_duration\n        self._post_silence_duration = post_silence_duration\n        self._cache_dir = self.get_tts_cache_dir(cache_dir)\n        self._tracker = 'custom_components.device_tracker.bluetooth_tracker' in sys.modules\n        _LOGGER.debug('Bluetooth tracker integration:  {}'.format(str(self._tracker)))\n\n    def get_tts_cache_dir(self, cache_dir):\n        \"\"\"Get cache folder.\"\"\"\n        if not os.path.isabs(cache_dir):\n            cache_dir = hass.config.path(cache_dir)\n        return cache_dir\n\n    def update(self):\n        \"\"\"Retrieve latest state.\"\"\"\n        if self._is_standby:\n            self._current = None\n        else:\n            self._current = True\n\n    @property\n    def name(self):\n        \"\"\"Return the name of the device.\"\"\"\n        return self._name\n\n    # MediaPlayerDevice properties and methods\n    @property\n    def state(self):\n        \"\"\"Return the state of the device.\"\"\"\n        if self._is_standby:\n            return STATE_OFF\n        else:\n            return STATE_PLAYING\n\n    @property\n    def supported_features(self):\n        \"\"\"Flag media player features that are supported.\"\"\"\n        return SUPPORT_BLU_SPEAKER\n\n    @property\n    def volume_level(self):\n        \"\"\"Volume level of the media player (0..1).\"\"\"\n        return self._volume\n\n    def set_volume_level(self, volume):\n        \"\"\"Set volume level, range 0..1.\"\"\"\n        # self._vlc.audio_set_volume(int(volume * 100))\n        self._volume = volume\n\n    def play_media(self, media_type, media_id, **kwargs):\n        \"\"\"Send play commmand.\"\"\"\n        _LOGGER.info('play_media: %s', media_id)\n        self._is_standby = False\n\n        media_file = self._cache_dir + '/' + media_id[media_id.rfind('/') + 1:];\n        sink = 'pulse::bluez_sink.' + re.sub(':', '_', self._address) + '.a2dp_sink'\n        volume = str(self._volume * 100)\n\n        pre_silence_file = \"\"\n        post_silence_file = \"\"\n\n        media_file_to_play = media_file\n\n        if (self._pre_silence_duration > 0) or (self._post_silence_duration > 0):\n            media_file_to_play = \"/tmp/tts_{}\".format(os.path.basename(media_file))\n\n            if (self._pre_silence_duration > 0):\n              pre_silence_file = \"/tmp/pre_silence.mp3\"\n              command = \"sox -c 1 -r 24000 -n {} synth {} brownnoise gain -50\".format(pre_silence_file, self._pre_silence_duration)\n              _LOGGER.debug('Executing command: %s', command)\n              subprocess.call(command, shell=True)\n\n            if (self._post_silence_duration > 0):\n              post_silence_file = \"/tmp/post_silence.mp3\"\n              command = \"sox -c 1 -r 24000 -n {} synth {} brownnoise gain -50\".format(post_silence_file, self._post_silence_duration)\n              _LOGGER.debug('Executing command: %s', command)\n              subprocess.call(command, shell=True)\n\n            command = \"sox {} {} {} {}\".format(pre_silence_file, media_file, post_silence_file, media_file_to_play)\n            _LOGGER.debug('Executing command: %s', command)\n            subprocess.call(command, shell=True)\n\n        if self._tracker:\n            self._hass.services.call(bluetooth_tracker.DOMAIN, bluetooth_tracker.BLUETOOTH_TRACKER_SERVICE_TURN_OFF, None)\n            while self._hass.states.get(bluetooth_tracker.DOMAIN + '.' + bluetooth_tracker.ENTITY_ID).state == bluetooth_tracker.STATE_ON:\n                _LOGGER.debug('Waiting for Bluetooth tracker to turn off')\n                time.sleep(0.5)\n\n        command = \"mplayer -ao {} -quiet -channels 2 -volume {} {}\".format(sink, volume, media_file_to_play);\n        _LOGGER.debug('Executing command: %s', command)\n        subprocess.call(command, shell=True)\n\n        if (self._pre_silence_duration > 0) or (self._post_silence_duration > 0):\n            command = \"rm {} {} {}\".format(pre_silence_file, media_file_to_play, post_silence_file);\n            _LOGGER.debug('Executing command: %s', command)\n            subprocess.call(command, shell=True)\n\n        if self._tracker:\n            self._hass.services.call(bluetooth_tracker.DOMAIN, bluetooth_tracker.BLUETOOTH_TRACKER_SERVICE_TURN_ON, None)\n\n        self._is_standby = True\n"
  }
]