Skip to content

Commit c53ab53

Browse files
committed
major update
1 parent 179c672 commit c53ab53

9 files changed

Lines changed: 258 additions & 33 deletions

File tree

README.md

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Filuvdata%2Fgoogle_nest_fan%2Frefs%2Fheads%2Fmain%2Fcustom_components%2Fgoogle_nest_fan%2Fmanifest.json&query=%24.version&prefix=v&label=dev-version&labelColor=orange)
77

88

9-
A custom HomeAssistant integration that creates an additional entities for each Google Device with a Fan "trait" such as a Nest Thermostat. This entity will track when the fan will shut off "In 2 hours 4 minutes" (rather than the 12 hours hard coded into the core [Google Nest](https://www.home-assistant.io/integrations/nest/) integration.
9+
A custom HomeAssistant integration that creates an additional entities for each Google Device with a Fan "trait" such as a Nest Thermostat. This entity will track when the fan will shut off "In 2 hours 4 minutes" (rather than the 12 hours hard coded into the core [Google Nest](https://www.home-assistant.io/integrations/nest/) integration.
1010

1111
A service `google_nest_fan.run_fan` will also be created as an action to run fans for a custom duration.
1212

13+
A cooresponding number (slider) entity will also be created and will allow you to select the duration you want to start and run your thermostat's fan.
14+
1315
The core Google Nest Integration must be configured and working for this integration to work.
1416

1517
## Installation with HACS
@@ -20,19 +22,6 @@ The recommended way to install this is via HACS:
2022

2123
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?category=custom_respository&owner=iluvdata&repository=google_nest_fan)
2224

23-
#### Semi-manual install
24-
25-
1. Click on HACS in the Homeassistant side bar
26-
2. Click on the three dots in the upper right-hand corner and select "Custom repositories."
27-
3. In the form enter:
28-
29-
1. Respository: `iluvdata/google_nest_fan`
30-
2. Select "Integration" as "Type"
31-
32-
## Manual Installation
33-
34-
Copy the `google_nest_fan` directory to the `custom_components` directory of your Homeassistant Instance.
35-
3625
## Configuration
3726

3827

@@ -46,3 +35,64 @@ If the core **Google Nest** integration is configured this integration should be
4635

4736
Simple use the slider to select the amount of time you want to run the fan. Upon changing the slider, it should send the request to the device to start the fan for the given duration. To stop the fan, simply turn off through the core integration. If you update the slider, it should change the duration to the most recent selected value.
4837

38+
### UI Customization
39+
40+
You can add a custom feature to your thermostat card using the [`Nerwyn/custom-card-features`](https://github.com/Nerwyn/custom-card-features) HACS dashboard integration.
41+
42+
[![My Home Assistant](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?repository=custom-card-features&owner=Nerwyn&category=Plugin)
43+
44+
You can then use a similar configuration to create a custom thermostat card:
45+
46+
<img src="https://raw.githubusercontent.com/iluvdata/google_nest_fan/main/assets/thermostat_card.png" width="600"/>
47+
48+
<details>
49+
<summary style="font-weight: bold;">Config for custom thermostat card</summary>
50+
51+
```yaml
52+
type: thermostat
53+
entity: climate.cottage
54+
show_current_as_primary: false
55+
features:
56+
- type: climate-hvac-modes
57+
- style: icons
58+
type: climate-preset-modes
59+
- type: custom:service-call
60+
entries:
61+
- type: slider
62+
entity_id: number.cottage_fan_run_duration
63+
tap_action:
64+
action: perform-action
65+
target:
66+
entity_id: number.cottage_fan_run_duration
67+
perform_action: number.set_value
68+
data:
69+
value: "{{ value }}"
70+
confirmation: false
71+
range:
72+
- 0
73+
- 15
74+
step: 0.25
75+
value_from_hass_delay: 0
76+
haptics: true
77+
autofill_entity_id: true
78+
label: >-
79+
{{ iif(state_attr("climate.cottage", "fan_mode") === "on" and value >
80+
0, "Running Fan " + (value | round(0, "floor") ) + "h" + ((value %
81+
1)*60) + "m", iif(state_attr("climate.cottage", "fan_mode") === "off"
82+
and value > 0, "Waiting to Start " + (value | round(0, "floor") ) +
83+
"h" + ((value % 1)*60) + "m", iif(state_attr("climate.cottage",
84+
"fan_mode") === "on" and value === 0,"Waiting to Stop", "Start Fan")))
85+
}}
86+
styles: |-
87+
:host{
88+
{{ "--color: rgb(0, 154, 199);" if state_attr("climate.cottage", "fan_mode") === "on" }}
89+
}
90+
icon: >-
91+
{{ iif(state_attr("climate.cottage", "fan_mode") === "on" and value >
92+
0, "mdi:fan", "mdi:fan-off") }}
93+
thumb: default
94+
styles: ""
95+
96+
```
97+
</details>
98+

assets/thermostat_card.png

23.4 KB
Loading

custom_components/google_nest_fan/__init__.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,9 @@
88
from google_nest_sdm.device import Device
99

1010
# pylint: disable=hass-component-root-import
11-
from homeassistant.components.nest import ApiException
11+
from homeassistant.components.nest import ApiException, NestConfigEntry
1212
from homeassistant.components.nest.climate import FanTrait, ThermostatHvacTrait
1313
from homeassistant.components.nest.const import DOMAIN as NEST_DOMAIN
14-
from homeassistant.components.nest.types import NestConfigEntry
1514
from homeassistant.config_entries import ConfigEntry
1615
from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID
1716
from homeassistant.core import HomeAssistant, ServiceCall
@@ -42,10 +41,6 @@ class GoogleNestFanData:
4241
type GoogleNestFan = ConfigEntry[GoogleNestFanData]
4342

4443

45-
def _get_config_entry(hass: HomeAssistant) -> GoogleNestFan:
46-
return cast(GoogleNestFan, hass.config_entries.async_loaded_entries(DOMAIN).pop())
47-
48-
4944
async def async_setup(hass: HomeAssistant, config_type: ConfigType) -> bool:
5045
"""Setup Services."""
5146

@@ -103,14 +98,16 @@ async def _async_run_fan(call: ServiceCall) -> None:
10398

10499
async def async_setup_entry(hass: HomeAssistant, entry: GoogleNestFan) -> bool:
105100
"""Setup this config."""
106-
entries: list[NestConfigEntry] = hass.config_entries.async_loaded_entries(
107-
NEST_DOMAIN
108-
)
109-
if not entries:
101+
102+
devices: dict[str, Device] = {}
103+
if not (
104+
entries := cast(
105+
list[NestConfigEntry], hass.config_entries.async_loaded_entries(NEST_DOMAIN)
106+
)
107+
):
110108
raise ConfigEntryError(
111109
translation_domain=DOMAIN, translation_key="nest_not_loaded"
112110
)
113-
devices: dict[str, Device] = {}
114111
for nestentry in entries:
115112
for device in nestentry.runtime_data.device_manager.devices.values():
116113
if ThermostatHvacTrait.NAME in device.traits:
@@ -125,3 +122,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleNestFan) -> bool:
125122
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
126123

127124
return True
125+
126+
127+
async def async_unload_entry(hass: HomeAssistant, entry: GoogleNestFan) -> bool:
128+
"""Unload a config entry."""
129+
return True

custom_components/google_nest_fan/const.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@
99
import homeassistant.helpers.config_validation as cv
1010

1111
DOMAIN = "google_nest_fan"
12-
PLATFORMS = {Platform.SENSOR}
12+
PLATFORMS = {Platform.SENSOR, Platform.NUMBER}
13+
14+
SCAN_INTERVAL = timedelta(minutes=1)
1315

1416
FAN_SERVICE_DURATION = "duration"
1517

1618
FAN_SERVICE = "run_fan"
1719

1820
MAX_RUN_TIME: Final[timedelta] = timedelta(hours=15)
1921

22+
RUN_TIME_DEBOUNCE: Final[timedelta] = timedelta(seconds=2)
2023

2124
FAN_SERVICE_SCHEMA = vol.Schema(
2225
vol.All(

custom_components/google_nest_fan/manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"requirements": [],
1313
"ssdp": [],
1414
"zeroconf": [],
15-
"version": "2025.10.1",
16-
"integration_type": "service",
15+
"version": "2025.10.2",
16+
"integration_type": "helper",
1717
"issue_tracker": "https://github.com/iluvdata/google_nest_fan/issues"
1818
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""Number change the fan setting."""
2+
3+
from collections.abc import Callable
4+
from datetime import UTC, datetime, timedelta
5+
from typing import Any
6+
7+
from google_nest_sdm.device import Device
8+
from google_nest_sdm.device_traits import FanTrait
9+
10+
from homeassistant.components.nest import ApiException
11+
12+
# pylint: disable-next=hass-component-root-import
13+
from homeassistant.components.nest.device_info import NestDeviceInfo
14+
from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode
15+
from homeassistant.const import UnitOfTime
16+
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
17+
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
18+
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
19+
from homeassistant.helpers.event import async_call_later
20+
21+
from . import _LOGGER, GoogleNestFan
22+
from .const import MAX_RUN_TIME, RUN_TIME_DEBOUNCE
23+
24+
25+
async def async_setup_entry(
26+
hass: HomeAssistant,
27+
entry: GoogleNestFan,
28+
async_add_entities: AddConfigEntryEntitiesCallback,
29+
) -> None:
30+
"""Setup up the number entities for each Nest SDM Fan."""
31+
fan_run_stop_entities: list[RunTime] = []
32+
for device in entry.runtime_data.devices.values():
33+
if FanTrait.NAME in device.traits:
34+
fan_trait: FanTrait = device.traits[FanTrait.NAME]
35+
if fan_trait.timer_mode is not None:
36+
fan_run_stop_entities.append(RunTime(hass, device))
37+
if fan_run_stop_entities:
38+
async_add_entities(fan_run_stop_entities)
39+
40+
41+
class RunTime(NumberEntity):
42+
"""Number entity to support number."""
43+
44+
_attr_translation_key = "start_fan"
45+
_attr_name = "Fan Run Duration"
46+
_attr_icon = "mdi:fan-clock"
47+
_attr_has_entity_name = True
48+
_cancel_set_timer: CALLBACK_TYPE | None = None
49+
_update_listener: Callable[[], None] | None = None
50+
timeout: datetime | None = None
51+
52+
def __init__(self, hass: HomeAssistant, device: Device) -> None:
53+
"""Initilize Run Time."""
54+
self.should_poll = True
55+
self.hass: HomeAssistant = hass
56+
self._device: Device = device
57+
self._fan_trait: FanTrait = device.traits[FanTrait.NAME]
58+
self._attr_native_min_value = 0
59+
self._attr_native_max_value = MAX_RUN_TIME.total_seconds() / 3600
60+
self.device_class = NumberDeviceClass.DURATION
61+
self._attr_native_unit_of_measurement = UnitOfTime.HOURS
62+
self._attr_native_step = 0.25
63+
self._attr_mode = NumberMode.SLIDER
64+
self._device_info = NestDeviceInfo(device)
65+
# The API "name" field is a unique device identifier.
66+
self._attr_unique_id = f"{device.name}-{self.device_class}"
67+
self._attr_device_info = self._device_info.device_info
68+
69+
async def async_update(self) -> None:
70+
"""Update state."""
71+
self._async_update()
72+
73+
def _async_update(self) -> None:
74+
# Are we waiting for a debounce timer?
75+
if self._cancel_set_timer is not None:
76+
_LOGGER.debug("update during debounce delay: canceled")
77+
return
78+
if self.timeout is not None:
79+
remaining_time: timedelta = self.timeout - datetime.now(UTC)
80+
seconds: float = remaining_time.total_seconds()
81+
if seconds <= 0:
82+
self._async_fan_stop()
83+
else:
84+
self._attr_native_value = round(seconds * 4 / 3600) / 4
85+
self._attr_translation_key = "fan_running"
86+
else:
87+
self._async_fan_stop()
88+
self.async_write_ha_state()
89+
90+
def _async_update_from_listener(self) -> None:
91+
if self._fan_trait.timer_timeout is not None:
92+
self.timeout = self._fan_trait.timer_timeout
93+
self._async_update()
94+
95+
def _async_fan_stop(self) -> None:
96+
self.timeout = None
97+
self._attr_native_value = 0
98+
self._attr_translation_key = "start_fan"
99+
100+
async def async_added_to_hass(self) -> None:
101+
"""Run when entity is added to register update signal handler."""
102+
103+
self._update_listener = self._device.add_update_listener(
104+
self._async_update_from_listener
105+
)
106+
self._async_update_from_listener()
107+
108+
async def async_will_remove_from_hass(self) -> None:
109+
"""Cancel any pending debounces and listeners."""
110+
if self._cancel_set_timer is not None:
111+
self._cancel_set_timer()
112+
if self._update_listener is not None:
113+
self._update_listener()
114+
115+
async def async_set_native_value(self, value: float) -> None:
116+
"""Fired on value change."""
117+
_LOGGER.debug("got value %s", value)
118+
if value >= self._attr_native_max_value or value < 0:
119+
raise HomeAssistantError("Value out of range.")
120+
if self._cancel_set_timer is not None:
121+
self._cancel_set_timer()
122+
self._cancel_set_timer = None
123+
self._attr_native_value = value
124+
125+
async def _set_timer(now: Any) -> None:
126+
nonlocal value
127+
if value is None:
128+
raise HomeAssistantError("value")
129+
try:
130+
if value > 0:
131+
await self._fan_trait.set_timer("ON", int(value * 3600))
132+
self._attr_translation_key = "fan_running"
133+
# Set the timeout until it gets updated by nest pub/sub
134+
self.timeout = datetime.now(UTC) + timedelta(hours=value)
135+
else:
136+
await self._fan_trait.set_timer("OFF")
137+
self._async_fan_stop()
138+
except ApiException as err:
139+
raise ServiceValidationError(
140+
f"Error setting fan run time for {self.name} {value}h: {err}"
141+
) from err
142+
self.schedule_update_ha_state(True)
143+
if self._cancel_set_timer is not None:
144+
self._cancel_set_timer()
145+
self._cancel_set_timer = None
146+
147+
self._cancel_set_timer = async_call_later(
148+
self.hass, RUN_TIME_DEBOUNCE, _set_timer
149+
)

custom_components/google_nest_fan/sensor.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ async def async_setup_entry(
2828
if FanTrait.NAME in device.traits:
2929
fan_trait = device.traits[FanTrait.NAME]
3030
if fan_trait.timer_mode is not None:
31-
fan_run_stop_entities.append(FanStop(device))
31+
fan_run_stop_entities.append(FanStop(hass, device))
3232
if fan_run_stop_entities:
3333
async_add_entities(fan_run_stop_entities)
3434

@@ -37,12 +37,12 @@ class FanStop(SensorEntity):
3737
"""Sensor to include fan stop time."""
3838

3939
_attr_should_poll = False
40-
_attr_name = "Fan End Time"
40+
_attr_translation_key = "fan_timeout"
4141
_attr_device_class = SensorDeviceClass.TIMESTAMP
4242
_attr_icon = "mdi:fan-clock"
4343
_attr_has_entity_name = True
4444

45-
def __init__(self, device: Device) -> None:
45+
def __init__(self, hass: HomeAssistant, device: Device) -> None:
4646
"""Initialize the sensor."""
4747
super().__init__()
4848
self._device: Device = device
@@ -56,11 +56,16 @@ def available(self) -> bool: # pyright: ignore[reportIncompatibleVariableOverri
5656
"""Return device availability."""
5757
return self._device_info.available
5858

59+
async def async_update(self) -> None:
60+
"""Called on init."""
61+
self.async_write_ha_state()
62+
5963
async def async_added_to_hass(self) -> None:
6064
"""Run when entity is added to register update signal handler."""
6165
self.async_on_remove(
6266
self._device.add_update_listener(self.async_write_ha_state)
6367
)
68+
await self.async_update()
6469

6570
@property
6671
def native_value(self) -> datetime.datetime | str: # pyright: ignore[reportIncompatibleVariableOverride]

custom_components/google_nest_fan/translations/en.json

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,23 @@
2020
}
2121
}
2222
},
23+
"entity": {
24+
"number": {
25+
"start_fan": {
26+
"name": "Start Fan"
27+
},
28+
"fan_running": {
29+
"name": "Fan Running"
30+
}
31+
},
32+
"sensor": {
33+
"fan_timeout": {
34+
"name": "Fan Timeout"
35+
}
36+
}
37+
},
2338
"exceptions": {
24-
"exceeds_max_duration": "Duration exceeds the maximum supported by Nest of { max_time }."
39+
"exceeds_max_duration": "Duration exceeds the maximum supported by Nest of { max_time }.",
40+
"nest_not_loaded": "Nest integration is not configured."
2541
}
2642
}

0 commit comments

Comments
 (0)