├── .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 |
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 |
--------------------------------------------------------------------------------