├── .github ├── FUNDING.yml └── workflows │ └── hassfest.yaml ├── custom_components └── snooz │ ├── const.py │ ├── manifest.json │ ├── models.py │ ├── translations │ └── en.json │ ├── services.yaml │ ├── strings.json │ ├── __init__.py │ ├── sensor.py │ ├── fan.py │ └── config_flow.py ├── info.md ├── LICENSE ├── README.md └── header.svg /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [AustinBrunkhorst] 2 | -------------------------------------------------------------------------------- /custom_components/snooz/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the SNOOZ Noise Maker integration.""" 2 | 3 | DOMAIN = "snooz" 4 | -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | # Snooz available in Home Assistant Core 2 | Snooz is now available as a [core integration](https://www.home-assistant.io/integrations/snooz/) in Home Assistant 2022.11+. This custom component will no longer be maintained. -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v2" 14 | - uses: home-assistant/actions/hassfest@master -------------------------------------------------------------------------------- /custom_components/snooz/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "snooz", 3 | "name": "SNOOZ", 4 | "config_flow": true, 5 | "version": "0.2.0", 6 | "documentation": "https://github.com/AustinBrunkhorst/snooz", 7 | "requirements": ["pysnooz==0.8.3"], 8 | "dependencies": ["bluetooth"], 9 | "codeowners": [ 10 | "@AustinBrunkhorst" 11 | ], 12 | "bluetooth": [ 13 | { "local_name": "Snooz*" }, 14 | { "service_uuid": "729f0608-496a-47fe-a124-3a62aaa3fbc0" } 15 | ], 16 | "iot_class": "local_push" 17 | } 18 | -------------------------------------------------------------------------------- /custom_components/snooz/models.py: -------------------------------------------------------------------------------- 1 | from bleak.backends.device import BLEDevice 2 | from homeassistant.components.bluetooth.passive_update_processor import \ 3 | PassiveBluetoothProcessorCoordinator 4 | from pysnooz.device import SnoozDevice 5 | 6 | 7 | class SnoozConfigurationData: 8 | """Configuration data for SNOOZ.""" 9 | 10 | def __init__(self, ble_device: BLEDevice, device: SnoozDevice, coordinator: PassiveBluetoothProcessorCoordinator) -> None: 11 | self.ble_device = ble_device 12 | self.device = device 13 | self.coordinator = coordinator 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Austin Brunkhorst 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /custom_components/snooz/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device is already configured", 5 | "already_in_progress": "Configuration flow is already in progress", 6 | "no_devices_found": "No SNOOZ devices discovered." 7 | }, 8 | "flow_title": "{name}", 9 | "step": { 10 | "bluetooth_confirm": { 11 | "description": "Do you want to setup {name}?" 12 | }, 13 | "pairing_timeout": { 14 | "description": "The device did not enter pairing mode. Click Submit to try again.\n\n### Troubleshooting\n1. Check that the device isn't connected to the mobile app.\n2. Unplug the device for 5 seconds, then plug it back in." 15 | }, 16 | "user": { 17 | "data": { 18 | "address": "Device" 19 | }, 20 | "description": "Choose a device to setup" 21 | } 22 | }, 23 | "progress": { 24 | "wait_for_pairing_mode": "To complete setup, put this device in pairing mode.\n\n### How to enter pairing mode\n1. Force quit SNOOZ mobile apps.\n2. Press and hold the power button on the device.\n3. Release when the lights start blinking (approximately 5 seconds)." 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /custom_components/snooz/services.yaml: -------------------------------------------------------------------------------- 1 | turn_off: 2 | name: Turn off 3 | description: Turn off device with optional transition time. 4 | target: 5 | entity: 6 | domain: fan 7 | fields: 8 | transition: 9 | name: Transition duration 10 | description: Duration to transition from current volume to off. 11 | selector: 12 | number: 13 | min: 1 14 | max: 300 15 | unit_of_measurement: seconds 16 | 17 | turn_on: 18 | name: Turn on 19 | description: Turn on device with optional transition time. 20 | target: 21 | entity: 22 | domain: fan 23 | fields: 24 | transition: 25 | name: Transition duration 26 | description: Duration to transition from current volume to new volume. If volume is not specified, the last volume state on the device will be used. 27 | selector: 28 | number: 29 | min: 1 30 | max: 300 31 | unit_of_measurement: seconds 32 | volume: 33 | name: Volume level 34 | description: Number indicating volume level to set. If a transition is specified, this is the final volume level. 35 | advanced: true 36 | selector: 37 | number: 38 | min: 0 39 | max: 100 40 | unit_of_measurement: "%" 41 | 42 | disconnect: 43 | name: Disconnect 44 | description: Disconnect the underlying bluetooth connection 45 | target: 46 | entity: 47 | domain: fan 48 | -------------------------------------------------------------------------------- /custom_components/snooz/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "flow_title": "[%key:component::bluetooth::config::flow_title%]", 4 | "step": { 5 | "user": { 6 | "description": "[%key:component::bluetooth::config::step::user::description%]", 7 | "data": { 8 | "address": "[%key:component::bluetooth::config::step::user::data::address%]" 9 | } 10 | }, 11 | "bluetooth_confirm": { 12 | "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" 13 | }, 14 | "pairing_timeout": { 15 | "description": "The device did not enter pairing mode. Click Submit to try again.\n\n### Troubleshooting\n1. Check that the device isn't connected to the mobile app.\n2. Unplug the device for 5 seconds, then plug it back in." 16 | } 17 | }, 18 | "progress": { 19 | "wait_for_pairing_mode": "To complete setup, put this device in pairing mode.\n\n### How to enter pairing mode\n1. Force quit SNOOZ mobile apps.\n2. Press and hold the power button on the device. Release when the lights start blinking (approximately 5 seconds)." 20 | }, 21 | "error": { 22 | }, 23 | "abort": { 24 | "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", 25 | "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", 26 | "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /custom_components/snooz/__init__.py: -------------------------------------------------------------------------------- 1 | """The SNOOZ Noise Maker integration.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | 6 | from custom_components.snooz.const import DOMAIN 7 | from custom_components.snooz.models import SnoozConfigurationData 8 | from homeassistant.components.bluetooth import (BluetoothScanningMode, 9 | async_ble_device_from_address) 10 | from homeassistant.components.bluetooth.passive_update_processor import \ 11 | PassiveBluetoothProcessorCoordinator 12 | from homeassistant.config_entries import ConfigEntry 13 | from homeassistant.const import CONF_ADDRESS, CONF_TOKEN, Platform 14 | from homeassistant.core import HomeAssistant 15 | from homeassistant.exceptions import ConfigEntryNotReady 16 | from pysnooz.device import SnoozDevice 17 | from pysnooz.advertisement import SnoozAdvertisementData 18 | 19 | PLATFORMS: list[Platform] = [Platform.FAN, Platform.SENSOR] 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 25 | """Set up SNOOZ device from a config entry.""" 26 | address = entry.data.get(CONF_ADDRESS) 27 | assert address 28 | 29 | token = entry.data.get(CONF_TOKEN) 30 | assert token 31 | 32 | if not (ble_device := async_ble_device_from_address(hass, address.upper())): 33 | raise ConfigEntryNotReady( 34 | f"Could not find SNOOZ with address {address}. Try power cycling the device." 35 | ) 36 | 37 | data = SnoozAdvertisementData() 38 | coordinator = PassiveBluetoothProcessorCoordinator( 39 | hass, _LOGGER, address=address, mode=BluetoothScanningMode.ACTIVE, update_method=data.update 40 | ) 41 | 42 | device = SnoozDevice(ble_device, token, hass.loop) 43 | 44 | hass.data.setdefault(DOMAIN, {})[ 45 | entry.entry_id 46 | ] = SnoozConfigurationData(ble_device, device, coordinator) 47 | 48 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 49 | entry.async_on_unload(coordinator.async_start()) 50 | 51 | return True 52 | 53 | 54 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 55 | """Unload a config entry.""" 56 | if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): 57 | hass.data[DOMAIN].pop(entry.entry_id) 58 | if not hass.config_entries.async_entries(DOMAIN): 59 | hass.data.pop(DOMAIN) 60 | 61 | return unload_ok 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > **Note** 2 | > Snooz is now available as a [core integration](https://www.home-assistant.io/integrations/snooz/) in Home Assistant 2022.11+. This custom component will no longer be maintained. 3 | 4 |

5 | Home Assistant + SNOOZ 6 |

7 | 8 | Custom Home Assistant component for [SNOOZ][snooz] white noise sound machine. 9 | 10 | ## Installation 11 | ### Requirements 12 | - [Home Assistant][homeassistant] **2022.8+** 13 | - [Bluetooth Component][bluetooth_component] 14 | 15 | ### HACS 16 | 1. Add `https://github.com/AustinBrunkhorst/snooz` as a [custom repository][hacsrepository]. 17 | - Alternatively copy `custom_components/snooz/*` to `custom_components/snooz` in your Home Assistant configuration directory. 18 | 2. Ensure your SNOOZ device is within range of your Home Assistant host hardware. 19 | 3. Add the **SNOOZ** integration in *Settings > Devices & Services* 20 | 4. Select a device to setup 21 | 5. Enter the device in pairing mode to complete the setup 22 | 23 | New to HACS? [Learn more][hacsinstall] 24 | 25 | [![Gift a coffee][giftacoffeebadgeblue]][giftacoffee] 26 | 27 | ## Fan 28 | SNOOZ devices are exposed as fan entities and support the following services: 29 | - `fan.turn_on` 30 | - `fan.turn_off` 31 | - `fan.set_percentage` 32 | 33 | ## Sensor 34 | ### `sensor.connection_status` 35 | The bluetooth connection status of the device. 36 | - connected 37 | - disconnected 38 | - connecting 39 | 40 | ### `sensor.signal_strength` 41 | The bluetooth RSSI signal strength of the device. 42 | 43 | ## Services 44 | ### `snooz.turn_on` 45 | Power on the device. Optionally transition the volume over time. 46 | | | | | 47 | |----------|----------|-----------------------------------------------------| 48 | | volume | optional | Volume to set before turning on | 49 | | duration | optional | Duration in seconds to transition to the new volume | 50 | 51 | ### `snooz.turn_off` 52 | Power off the device. Optionally transition the volume over time. 53 | | | | | 54 | |----------|----------|-----------------------------------------------------| 55 | | duration | optional | Duration in seconds to transition to the new volume | 56 | 57 | ### `snooz.disconnect` 58 | Terminate any connections to this device. 59 | 60 | ## Troubleshooting 61 | > How do I enter pairing mode? 62 | 1. Unplug SNOOZ and let sit for 5 seconds. 63 | 2. Plug SNOOZ back in. 64 | 3. Confirm no other phones are trying to connect to the device. 65 | 4. With one finger, press and hold the power button on SNOOZ. Release when the lights start blinking (approximately 5 seconds). 66 | 67 | > My device isn't discovered on the integration setup 68 | 69 | Make sure the SNOOZ device is in pairing mode and within Bluetooth range of the host device running Home Assistant. 70 | 71 | > Can I use the SNOOZ mobile app when the device is connected to Home Assistant? 72 | 73 | No. This is a limitation with the SNOOZ device supporting only 1 simultaneous connection. 74 | 75 | ## ⚠ Disclaimer ⚠ 76 | This integration is in no way affiliated with SNOOZ. SNOOZ does not offer support for this integration, as it was built by reverse engineering communication with SNOOZ's mobile app. 77 | 78 | [snooz]: https://getsnooz.com/ 79 | [snoozlogo]: snooz.png 80 | [snoozdevice]: device.jpg 81 | [homeassistant]: https://www.home-assistant.io/ 82 | [giftacoffee]: https://www.buymeacoffee.com/abrunkhorst 83 | [giftacoffeebadge]: https://img.shields.io/badge/Gift%20a%20coffee-green.svg?style=flat 84 | [giftacoffeebadgeblue]: https://img.shields.io/badge/Gift%20a%20coffee-blue.svg?style=for-the-badge 85 | [commits-shield]: https://img.shields.io/github/commit-activity/y/AustinBrunkhorst/snooz.svg?style=flat 86 | [commits]: https://github.com/custom-components/blueprint/commits/master 87 | [hacs]: https://github.com/custom-components/hacs 88 | [hacsinstall]: https://hacs.xyz/docs/installation/manual 89 | [hacsrepository]: https://hacs.xyz/docs/faq/custom_repositories/ 90 | [hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=flat 91 | [hacsfolder]: https://github.com/AustinBrunkhorst/snooz/tree/master/custom_components/snooz 92 | [license-shield]: https://img.shields.io/github/license/AustinBrunkhorst/snooz.svg?style=flat 93 | [bluetoothctl]: https://www.linux-magazine.com/Issues/2017/197/Command-Line-bluetoothctl 94 | [bluetooth_component]: https://www.home-assistant.io/components/bluetooth/ 95 | -------------------------------------------------------------------------------- /header.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /custom_components/snooz/sensor.py: -------------------------------------------------------------------------------- 1 | """Support for SNOOZ device sensors.""" 2 | from __future__ import annotations 3 | 4 | from collections.abc import Callable 5 | from typing import Optional, Union 6 | 7 | from custom_components.snooz.const import DOMAIN 8 | from custom_components.snooz.models import SnoozConfigurationData 9 | from homeassistant.components.bluetooth.passive_update_processor import ( 10 | PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, 11 | PassiveBluetoothEntityKey, PassiveBluetoothProcessorEntity) 12 | from homeassistant.components.sensor import (SensorDeviceClass, SensorEntity, 13 | SensorEntityDescription, 14 | SensorStateClass) 15 | from homeassistant.config_entries import ConfigEntry 16 | from homeassistant.const import (ATTR_MANUFACTURER, ATTR_MODEL, ATTR_NAME, 17 | SIGNAL_STRENGTH_DECIBELS_MILLIWATT) 18 | from homeassistant.core import HomeAssistant 19 | from homeassistant.helpers.entity import DeviceInfo, EntityCategory 20 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 21 | from pysnooz.device import SnoozDevice 22 | from sensor_state_data import (DeviceClass, DeviceKey, SensorDeviceInfo, 23 | SensorUpdate, Units) 24 | 25 | SENSOR_DESCRIPTIONS = { 26 | ( 27 | DeviceClass.SIGNAL_STRENGTH, 28 | Units.SIGNAL_STRENGTH_DECIBELS_MILLIWATT, 29 | ): SensorEntityDescription( 30 | key=f"{DeviceClass.SIGNAL_STRENGTH}_{Units.SIGNAL_STRENGTH_DECIBELS_MILLIWATT}", 31 | device_class=SensorDeviceClass.SIGNAL_STRENGTH, 32 | native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, 33 | state_class=SensorStateClass.MEASUREMENT, 34 | entity_registry_enabled_default=True, 35 | entity_category=EntityCategory.DIAGNOSTIC, 36 | ), 37 | } 38 | 39 | def _device_key_to_bluetooth_entity_key( 40 | device_key: DeviceKey, 41 | ) -> PassiveBluetoothEntityKey: 42 | """Convert a device key to an entity key.""" 43 | return PassiveBluetoothEntityKey(device_key.key, device_key.device_id) 44 | 45 | 46 | def _sensor_device_info_to_hass( 47 | sensor_device_info: SensorDeviceInfo, 48 | ) -> DeviceInfo: 49 | """Convert a sensor device info to a sensor device info.""" 50 | hass_device_info = DeviceInfo({}) 51 | if sensor_device_info.name is not None: 52 | hass_device_info[ATTR_NAME] = sensor_device_info.name 53 | if sensor_device_info.manufacturer is not None: 54 | hass_device_info[ATTR_MANUFACTURER] = sensor_device_info.manufacturer 55 | if sensor_device_info.model is not None: 56 | hass_device_info[ATTR_MODEL] = sensor_device_info.model 57 | return hass_device_info 58 | 59 | def sensor_update_to_bluetooth_data_update( 60 | sensor_update: SensorUpdate, 61 | ) -> PassiveBluetoothDataUpdate: 62 | """Convert a sensor update to a bluetooth data update.""" 63 | return PassiveBluetoothDataUpdate( 64 | devices={ 65 | device_id: _sensor_device_info_to_hass(device_info) 66 | for device_id, device_info in sensor_update.devices.items() 67 | }, 68 | entity_descriptions={ 69 | _device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[ 70 | (description.device_class, description.native_unit_of_measurement) 71 | ] 72 | for device_key, description in sensor_update.entity_descriptions.items() 73 | if description.native_unit_of_measurement 74 | }, 75 | entity_data={ 76 | _device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value 77 | for device_key, sensor_values in sensor_update.entity_values.items() 78 | }, 79 | entity_names={ 80 | _device_key_to_bluetooth_entity_key(device_key): sensor_values.name 81 | for device_key, sensor_values in sensor_update.entity_values.items() 82 | }, 83 | ) 84 | 85 | async def async_setup_entry( 86 | hass: HomeAssistant, 87 | entry: ConfigEntry, 88 | async_add_entities: AddEntitiesCallback, 89 | ) -> None: 90 | """Set up the SNOOZ device sensors.""" 91 | config_data: SnoozConfigurationData = hass.data[DOMAIN][entry.entry_id] 92 | processor = PassiveBluetoothDataProcessor( 93 | update_method=sensor_update_to_bluetooth_data_update 94 | ) 95 | async_add_entities([ 96 | SnoozConnectionStatusSensorEntity(config_data.device), 97 | ]) 98 | entry.async_on_unload( 99 | processor.async_add_entities_listener( 100 | SnoozSensorEntity, async_add_entities 101 | ) 102 | ) 103 | entry.async_on_unload(config_data.coordinator.async_register_processor(processor)) 104 | 105 | 106 | class SnoozSensorEntity( 107 | PassiveBluetoothProcessorEntity[ 108 | PassiveBluetoothDataProcessor[Optional[Union[float, int]]] 109 | ], 110 | SensorEntity, 111 | ): 112 | """Representation of a SNOOZ device sensor.""" 113 | 114 | @property 115 | def native_value(self) -> int | float | None: 116 | """Return the native value.""" 117 | return self.processor.entity_data.get(self.entity_key) 118 | 119 | class SnoozConnectionStatusSensorEntity(SensorEntity): 120 | """Representation of a SNOOZ connection status.""" 121 | def __init__(self, device: SnoozDevice) -> None: 122 | self._device = device 123 | self._attr_entity_registry_enabled_default = True 124 | self._attr_entity_category = EntityCategory.DIAGNOSTIC 125 | self._attr_unique_id = f"{device.address}.connection_status" 126 | self._attr_name = f"{device.display_name} Connection Status" 127 | self._attr_should_poll = False 128 | 129 | @property 130 | def native_value(self) -> str: 131 | return self._device.connection_status.name.lower() 132 | 133 | async def async_added_to_hass(self): 134 | await super().async_added_to_hass() 135 | 136 | self.async_on_remove(self._subscribe_to_device_events()) 137 | 138 | def _subscribe_to_device_events(self) -> Callable[[], None]: 139 | events = self._device.events 140 | 141 | def unsubscribe(): 142 | events.on_connection_status_change -= self._on_connection_status_changed 143 | 144 | events.on_connection_status_change += self._on_connection_status_changed 145 | 146 | return unsubscribe 147 | 148 | def _on_connection_status_changed(self, new_status) -> None: 149 | self.async_write_ha_state() 150 | 151 | -------------------------------------------------------------------------------- /custom_components/snooz/fan.py: -------------------------------------------------------------------------------- 1 | """Support for SNOOZ noise maker""" 2 | 3 | import logging 4 | from collections.abc import Callable, Mapping 5 | from datetime import timedelta 6 | from typing import Any 7 | 8 | import voluptuous as vol 9 | from custom_components.snooz.const import DOMAIN 10 | from custom_components.snooz.models import SnoozConfigurationData 11 | from homeassistant.components.fan import FanEntity, FanEntityFeature 12 | from homeassistant.config_entries import ConfigEntry 13 | from homeassistant.const import (CONF_ADDRESS, SERVICE_TURN_OFF, 14 | SERVICE_TURN_ON, STATE_OFF, STATE_ON) 15 | from homeassistant.core import HomeAssistant 16 | from homeassistant.helpers import entity_platform 17 | from homeassistant.helpers.restore_state import RestoreEntity 18 | from pysnooz.api import SnoozDeviceState, UnknownSnoozState 19 | from pysnooz.commands import (SnoozCommandData, SnoozCommandResultStatus, 20 | set_volume, turn_off, turn_on) 21 | from pysnooz.device import SnoozConnectionStatus, SnoozDevice 22 | 23 | # transitions logging is pretty verbose, so only enable warnings/errors 24 | logging.getLogger("transitions.core").setLevel(logging.WARNING) 25 | 26 | _LOGGER = logging.getLogger(__name__) 27 | _LOGGER.setLevel(logging.DEBUG) 28 | 29 | ATTR_TRANSITION = "transition" 30 | ATTR_VOLUME = "volume" 31 | 32 | async def async_setup_entry( 33 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities 34 | ) -> bool: 35 | """Set up SNOOZ device from a config entry.""" 36 | 37 | address: str = entry.data[CONF_ADDRESS] 38 | data: SnoozConfigurationData = hass.data[DOMAIN][entry.entry_id] 39 | 40 | platform = entity_platform.async_get_current_platform() 41 | platform.async_register_entity_service( 42 | SERVICE_TURN_ON, 43 | { 44 | vol.Optional(ATTR_VOLUME): vol.All( 45 | vol.Coerce(int), vol.Range(min=0, max=100) 46 | ), 47 | vol.Optional(ATTR_TRANSITION): vol.All( 48 | vol.Coerce(int), vol.Range(min=1, max=5*60) 49 | ), 50 | }, 51 | "async_turn_on", 52 | ) 53 | 54 | platform.async_register_entity_service( 55 | SERVICE_TURN_OFF, 56 | { 57 | vol.Optional(ATTR_TRANSITION): vol.All( 58 | vol.Coerce(int), vol.Range(min=1, max=5*60) 59 | ), 60 | }, 61 | "async_turn_off", 62 | ) 63 | 64 | platform.async_register_entity_service( 65 | "disconnect", 66 | {}, 67 | "async_disconnect", 68 | ) 69 | 70 | async_add_entities( 71 | [ 72 | SnoozFan( 73 | hass, 74 | data.device.display_name, 75 | address, 76 | data.device, 77 | ) 78 | ] 79 | ) 80 | 81 | return True 82 | 83 | 84 | class SnoozFan(FanEntity, RestoreEntity): 85 | """Fan representation of SNOOZ device""" 86 | 87 | def __init__(self, hass, name: str, address: str, device: SnoozDevice) -> None: 88 | self.hass = hass 89 | self._address = address 90 | self._device = device 91 | self._attr_unique_id = address 92 | self._attr_supported_features = FanEntityFeature.SET_SPEED 93 | self._attr_name = name 94 | self._attr_is_on = None 95 | self._attr_should_poll = False 96 | self._attr_percentage = None 97 | self._last_command_successful = None 98 | 99 | def _write_state_changed(self) -> None: 100 | # cache state for restore entity 101 | if not self.assumed_state: 102 | self._attr_is_on = self._device.state.on 103 | self._attr_percentage = self._device.state.volume 104 | 105 | self.async_write_ha_state() 106 | 107 | def _on_connection_status_changed(self, new_status: SnoozConnectionStatus) -> None: 108 | self._write_state_changed() 109 | 110 | def _on_device_state_changed(self, new_state: SnoozDeviceState) -> None: 111 | self._write_state_changed() 112 | 113 | async def async_added_to_hass(self): 114 | await super().async_added_to_hass() 115 | 116 | if last_state := await self.async_get_last_state(): 117 | self._attr_is_on = last_state.state == STATE_ON if last_state.state in (STATE_ON, STATE_OFF) else None 118 | self._attr_percentage = last_state.attributes.get("percentage") 119 | self._last_command_successful = last_state.attributes.get("last_command_successful") 120 | 121 | self.async_on_remove(self._subscribe_to_device_events()) 122 | 123 | def _subscribe_to_device_events(self) -> Callable[[], None]: 124 | events = self._device.events 125 | 126 | def unsubscribe(): 127 | events.on_connection_status_change -= self._on_connection_status_changed 128 | events.on_state_change -= self._on_device_state_changed 129 | 130 | events.on_connection_status_change += self._on_connection_status_changed 131 | events.on_state_change += self._on_device_state_changed 132 | 133 | return unsubscribe 134 | 135 | async def async_will_remove_from_hass(self): 136 | await self._device.async_disconnect() 137 | 138 | @property 139 | def percentage(self) -> int: 140 | return self._attr_percentage if self.assumed_state else self._device.state.volume 141 | 142 | @property 143 | def is_on(self) -> bool: 144 | return self._attr_is_on if self.assumed_state else self._device.state.on 145 | 146 | @property 147 | def assumed_state(self) -> bool: 148 | return not self._device.is_connected or self._device.state is UnknownSnoozState 149 | 150 | @property 151 | def extra_state_attributes(self) -> Mapping[Any, Any]: 152 | return {"last_command_successful": self._last_command_successful} 153 | 154 | async def async_disconnect(self, **kwargs) -> None: 155 | """Disconnect the underlying bluetooth device.""" 156 | await self._device.async_disconnect() 157 | 158 | async def async_turn_on(self, percentage: int = None, preset_mode: str = None, **kwargs) -> None: 159 | transition = self._get_transition(kwargs) 160 | await self._async_execute_command(turn_on(percentage or kwargs.get("volume"), transition)) 161 | 162 | async def async_turn_off(self, **kwargs) -> None: 163 | transition = self._get_transition(kwargs) 164 | await self._async_execute_command(turn_off(transition)) 165 | 166 | async def async_set_percentage(self, percentage: int) -> None: 167 | await self._async_execute_command(set_volume(percentage)) 168 | 169 | async def _async_execute_command(self, command: SnoozCommandData) -> None: 170 | result = await self._device.async_execute_command(command) 171 | self._last_command_successful = result.status == SnoozCommandResultStatus.SUCCESSFUL 172 | self.async_write_ha_state() 173 | 174 | def _get_transition(self, kwargs: Mapping[str, Any]) -> timedelta: 175 | seconds = kwargs.get(ATTR_TRANSITION) 176 | return timedelta(seconds=seconds) if seconds else None 177 | -------------------------------------------------------------------------------- /custom_components/snooz/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for SNOOZ Bluetooth integration.""" 2 | from __future__ import annotations 3 | 4 | import asyncio 5 | from typing import Any 6 | 7 | import voluptuous as vol 8 | from custom_components.snooz.const import DOMAIN 9 | from homeassistant.components.bluetooth import (BluetoothScanningMode, 10 | BluetoothServiceInfo, 11 | async_discovered_service_info, 12 | async_process_advertisements) 13 | from homeassistant.config_entries import ConfigFlow 14 | from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TOKEN 15 | from homeassistant.data_entry_flow import FlowResult 16 | from pysnooz.advertisement import SnoozAdvertisementData, get_snooz_display_name 17 | 18 | # number of seconds to wait for a device to be put in pairing mode 19 | WAIT_FOR_PAIRING_TIMEOUT = 30 20 | 21 | class DeviceDiscovery: 22 | info: BluetoothServiceInfo 23 | device: SnoozAdvertisementData 24 | 25 | def __init__(self, info: BluetoothServiceInfo, device: SnoozAdvertisementData): 26 | self.info = info 27 | self.device = device 28 | 29 | class SnoozConfigFlow(ConfigFlow, domain=DOMAIN): 30 | """Handle a config flow for SNOOZ.""" 31 | 32 | VERSION = 1 33 | 34 | def __init__(self) -> None: 35 | """Initialize the config flow.""" 36 | self._discovery: DeviceDiscovery = None 37 | self._discovered_devices: dict[str, DeviceDiscovery] = {} 38 | self._pairing_task: asyncio.Task | None = None 39 | 40 | async def async_step_bluetooth( 41 | self, discovery_info: BluetoothServiceInfo 42 | ) -> FlowResult: 43 | """Handle the bluetooth discovery step.""" 44 | await self.async_set_unique_id(discovery_info.address) 45 | self._abort_if_unique_id_configured() 46 | device = SnoozAdvertisementData() 47 | if not device.supported(discovery_info): 48 | return self.async_abort(reason="not_supported") 49 | self._discovery = DeviceDiscovery(discovery_info, device) 50 | return await self.async_step_bluetooth_confirm() 51 | 52 | async def async_step_bluetooth_confirm( 53 | self, user_input: dict[str, Any] | None = None 54 | ) -> FlowResult: 55 | """Confirm discovery.""" 56 | assert self._discovery is not None 57 | 58 | if user_input is not None: 59 | if not self._discovery.device.is_pairing: 60 | return await self.async_step_wait_for_pairing_mode() 61 | 62 | return self.create_snooz_entry(self._discovery) 63 | 64 | self._set_confirm_only() 65 | placeholders = {"name": get_snooz_display_name(self._discovery.device.title, self._discovery.info.address)} 66 | self.context["title_placeholders"] = placeholders 67 | return self.async_show_form( 68 | step_id="bluetooth_confirm", description_placeholders=placeholders 69 | ) 70 | 71 | async def async_step_user( 72 | self, user_input: dict[str, Any] | None = None 73 | ) -> FlowResult: 74 | """Handle the user step to pick discovered device.""" 75 | if user_input is not None: 76 | name = user_input[CONF_NAME] 77 | 78 | discovered = self._discovered_devices.get(name) 79 | self._discovery = discovered 80 | 81 | if not discovered.device.is_pairing: 82 | return await self.async_step_wait_for_pairing_mode() 83 | 84 | address = discovered.info.address 85 | await self.async_set_unique_id(address, raise_on_progress=False) 86 | return self.create_snooz_entry(discovered) 87 | 88 | configured_addresses = self._async_current_ids() 89 | 90 | for info in async_discovered_service_info(self.hass): 91 | address = info.address 92 | if address in configured_addresses: 93 | continue 94 | device = SnoozAdvertisementData() 95 | if device.supported(info): 96 | self._discovered_devices[get_snooz_display_name(device.title, info.address)] = DeviceDiscovery(info, device) 97 | 98 | if not self._discovered_devices: 99 | return self.async_abort(reason="no_devices_found") 100 | 101 | return self.async_show_form( 102 | step_id="user", 103 | data_schema=vol.Schema( 104 | {vol.Required(CONF_NAME): vol.In([get_snooz_display_name(d.device.title, d.info.address) for d in self._discovered_devices.values()])} 105 | ), 106 | ) 107 | 108 | async def async_step_wait_for_pairing_mode( 109 | self, user_input: dict[str, Any] | None = None 110 | ) -> FlowResult: 111 | """Wait for device to enter pairing mode.""" 112 | if not self._pairing_task: 113 | self._pairing_task = self.hass.async_create_task(self._async_wait_for_pairing_mode()) 114 | return self.async_show_progress( 115 | step_id="wait_for_pairing_mode", 116 | progress_action="wait_for_pairing_mode", 117 | ) 118 | 119 | try: 120 | await self._pairing_task 121 | except asyncio.TimeoutError: 122 | self._pairing_task = None 123 | return self.async_show_progress_done(next_step_id="pairing_timeout") 124 | 125 | self._pairing_task = None 126 | 127 | return self.async_show_progress_done(next_step_id="pairing_complete") 128 | 129 | async def async_step_pairing_complete(self, user_input: dict[str, Any] | None = None) -> FlowResult: 130 | assert self._discovery 131 | 132 | return self.create_snooz_entry(self._discovery) 133 | 134 | async def async_step_pairing_timeout(self, user_input: dict[str, Any] | None = None) -> FlowResult: 135 | """Inform the user that the device never entered pairing mode.""" 136 | if user_input is not None: 137 | return await self.async_step_wait_for_pairing_mode() 138 | 139 | self._set_confirm_only() 140 | return self.async_show_form(step_id="pairing_timeout") 141 | 142 | def create_snooz_entry(self, discovery: DeviceDiscovery) -> FlowResult: 143 | return self.async_create_entry( 144 | title=get_snooz_display_name(discovery.device.title, discovery.info.address), 145 | data={CONF_ADDRESS: discovery.info.address, CONF_TOKEN: discovery.device.pairing_token} 146 | ) 147 | 148 | async def _async_wait_for_pairing_mode(self) -> None: 149 | """Process advertisements until pairing mode is detected""" 150 | assert self._discovery 151 | device = self._discovery.device 152 | 153 | def is_device_in_pairing_mode( 154 | service_info: BluetoothServiceInfo, 155 | ) -> bool: 156 | return device.supported(service_info) and device.is_pairing 157 | 158 | try: 159 | await async_process_advertisements( 160 | self.hass, 161 | is_device_in_pairing_mode, 162 | {"address": self._discovery.info.address}, 163 | BluetoothScanningMode.ACTIVE, 164 | WAIT_FOR_PAIRING_TIMEOUT, 165 | ) 166 | finally: 167 | self.hass.async_create_task( 168 | self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) 169 | ) 170 | --------------------------------------------------------------------------------