4
4
import asyncio
5
5
import base64
6
6
import bisect
7
- from collections import defaultdict
7
+ from collections . abc import Callable , Coroutine
8
8
from copy import deepcopy
9
9
from dataclasses import dataclass
10
10
import datetime
@@ -802,10 +802,19 @@ def _set_changeable_settings(
802
802
self ._only_once = data [CONF_ONLY_ONCE ]
803
803
self ._prefer_rgb_color = data [CONF_PREFER_RGB_COLOR ]
804
804
self ._separate_turn_on_commands = data [CONF_SEPARATE_TURN_ON_COMMANDS ]
805
- self ._take_over_control = data [CONF_TAKE_OVER_CONTROL ]
806
805
self ._transition = data [CONF_TRANSITION ]
807
806
self ._adapt_delay = data [CONF_ADAPT_DELAY ]
808
807
self ._send_split_delay = data [CONF_SEND_SPLIT_DELAY ]
808
+ self ._take_over_control = data [CONF_TAKE_OVER_CONTROL ]
809
+ self ._detect_non_ha_changes = data [CONF_DETECT_NON_HA_CHANGES ]
810
+ if not data [CONF_TAKE_OVER_CONTROL ] and data [CONF_DETECT_NON_HA_CHANGES ]:
811
+ _LOGGER .warning (
812
+ "%s: Config mismatch: 'detect_non_ha_changes: true' "
813
+ "requires 'take_over_control' to be enabled. Adjusting config "
814
+ "and continuing setup with `take_over_control: true`." ,
815
+ self ._name ,
816
+ )
817
+ self ._take_over_control = True
809
818
self ._auto_reset_manual_control_time = data [CONF_AUTORESET_CONTROL ]
810
819
self ._expand_light_groups () # updates manual control timers
811
820
_loc = get_astral_location (self .hass )
@@ -1128,11 +1137,23 @@ async def _update_attrs_and_maybe_adapt_lights(
1128
1137
)
1129
1138
)
1130
1139
self .async_write_ha_state ()
1140
+
1131
1141
if lights is None :
1132
1142
lights = self ._lights
1133
- if (self ._only_once and not force ) or not lights :
1143
+
1144
+ if not force and self ._only_once :
1134
1145
return
1135
- await self ._adapt_lights (lights , transition , force , context )
1146
+
1147
+ filtered_lights = []
1148
+ for light in lights :
1149
+ # Don't adapt lights that haven't finished prior transitions.
1150
+ if force or not self .turn_on_off_listener .transition_timers .get (light ):
1151
+ filtered_lights .append (light )
1152
+
1153
+ if not filtered_lights :
1154
+ return
1155
+
1156
+ await self ._adapt_lights (filtered_lights , transition , force , context )
1136
1157
1137
1158
async def _adapt_lights (
1138
1159
self ,
@@ -1532,8 +1553,6 @@ def __init__(self, hass: HomeAssistant):
1532
1553
self .sleep_tasks : dict [str , asyncio .Task ] = {}
1533
1554
# Tracks which lights are manually controlled
1534
1555
self .manual_control : dict [str , bool ] = {}
1535
- # Counts the number of times (in a row) a light had a changed state.
1536
- self .cnt_significant_changes : dict [str , int ] = defaultdict (int )
1537
1556
# Track 'state_changed' events of self.lights resulting from this integration
1538
1557
self .last_state_change : dict [str , list [State ]] = {}
1539
1558
# Track last 'service_data' to 'light.turn_on' resulting from this integration
@@ -1543,9 +1562,8 @@ def __init__(self, hass: HomeAssistant):
1543
1562
self .auto_reset_manual_control_timers : dict [str , _AsyncSingleShotTimer ] = {}
1544
1563
self .auto_reset_manual_control_times : dict [str , float ] = {}
1545
1564
1546
- # When a state is different `max_cnt_significant_changes` times in a row,
1547
- # mark it as manually_controlled.
1548
- self .max_cnt_significant_changes = 2
1565
+ # Track light transitions
1566
+ self .transition_timers : dict [str , _AsyncSingleShotTimer ] = {}
1549
1567
1550
1568
self .remove_listener = self .hass .bus .async_listen (
1551
1569
EVENT_CALL_SERVICE , self .turn_on_off_event_listener
@@ -1554,6 +1572,56 @@ def __init__(self, hass: HomeAssistant):
1554
1572
EVENT_STATE_CHANGED , self .state_changed_event_listener
1555
1573
)
1556
1574
1575
+ def _handle_timer (
1576
+ self ,
1577
+ light : str ,
1578
+ timers_dict : dict [str , _AsyncSingleShotTimer ],
1579
+ delay : float | None ,
1580
+ reset_coroutine : Callable [[], Coroutine [Any , Any , None ]],
1581
+ ) -> None :
1582
+ timer = timers_dict .get (light )
1583
+ if timer is not None :
1584
+ if delay is None : # Timer object exists, but should not anymore
1585
+ timer .cancel ()
1586
+ timers_dict .pop (light )
1587
+ else : # Timer object already exists, just update the delay and restart it
1588
+ timer .delay = delay
1589
+ timer .start ()
1590
+ elif delay is not None : # Timer object does not exist, create it
1591
+ timer = _AsyncSingleShotTimer (delay , reset_coroutine )
1592
+ timers_dict [light ] = timer
1593
+ timer .start ()
1594
+
1595
+ def start_transition_timer (self , light : str ) -> None :
1596
+ """Mark a light as manually controlled."""
1597
+ _LOGGER .debug ("Start transition timer for %s" , light )
1598
+ last_service_data = self .last_service_data
1599
+ if (
1600
+ not last_service_data
1601
+ or light not in last_service_data
1602
+ or ATTR_TRANSITION not in last_service_data [light ]
1603
+ ):
1604
+ return
1605
+
1606
+ delay = last_service_data [light ][ATTR_TRANSITION ]
1607
+
1608
+ async def reset ():
1609
+ _LOGGER .debug (
1610
+ "Transition finished for light %s" ,
1611
+ light ,
1612
+ )
1613
+ switches = _get_switches_with_lights (self .hass , [light ])
1614
+ for switch in switches :
1615
+ if not switch .is_on :
1616
+ continue
1617
+ await switch ._update_attrs_and_maybe_adapt_lights (
1618
+ [light ],
1619
+ force = False ,
1620
+ context = switch .create_context ("transit" ),
1621
+ )
1622
+
1623
+ self ._handle_timer (light , self .transition_timers , delay , reset )
1624
+
1557
1625
def set_auto_reset_manual_control_times (self , lights : list [str ], time : float ):
1558
1626
"""Set the time after which the lights are automatically reset."""
1559
1627
if time == 0 :
@@ -1576,40 +1644,28 @@ def mark_as_manual_control(self, light: str) -> None:
1576
1644
_LOGGER .debug ("Marking '%s' as manually controlled." , light )
1577
1645
self .manual_control [light ] = True
1578
1646
delay = self .auto_reset_manual_control_times .get (light )
1579
- timer = self .auto_reset_manual_control_timers .get (light )
1580
- if timer is not None :
1581
- if delay is None : # Timer object exists, but should not anymore
1582
- timer .cancel ()
1583
- self .auto_reset_manual_control_timers .pop (light )
1584
- else : # Timer object already exists, just update the delay and restart it
1585
- timer .delay = delay
1586
- timer .start ()
1587
- elif delay is not None : # Timer object does not exist, create it
1588
1647
1589
- async def reset ():
1590
- self .reset (light )
1591
- switches = _get_switches_with_lights (self .hass , [light ])
1592
- for switch in switches :
1593
- if not switch .is_on :
1594
- continue
1595
- # pylint: disable=protected-access
1596
- await switch ._update_attrs_and_maybe_adapt_lights (
1597
- [light ],
1598
- transition = switch ._initial_transition ,
1599
- force = True ,
1600
- context = switch .create_context ("autoreset" ),
1601
- )
1602
- _LOGGER .debug (
1603
- "Auto resetting 'manual_control' status of '%s' because"
1604
- " it was not manually controlled for %s seconds." ,
1605
- light ,
1606
- delay ,
1648
+ async def reset ():
1649
+ self .reset (light )
1650
+ switches = _get_switches_with_lights (self .hass , [light ])
1651
+ for switch in switches :
1652
+ if not switch .is_on :
1653
+ continue
1654
+ await switch ._update_attrs_and_maybe_adapt_lights (
1655
+ [light ],
1656
+ transition = switch ._initial_transition ,
1657
+ force = True ,
1658
+ context = switch .create_context ("autoreset" ),
1607
1659
)
1608
- assert not self .manual_control [light ]
1660
+ _LOGGER .debug (
1661
+ "Auto resetting 'manual_control' status of '%s' because"
1662
+ " it was not manually controlled for %s seconds." ,
1663
+ light ,
1664
+ delay ,
1665
+ )
1666
+ assert not self .manual_control [light ]
1609
1667
1610
- timer = _AsyncSingleShotTimer (delay , reset )
1611
- self .auto_reset_manual_control_timers [light ] = timer
1612
- timer .start ()
1668
+ self ._handle_timer (light , self .auto_reset_manual_control_timers , delay , reset )
1613
1669
1614
1670
def reset (self , * lights , reset_manual_control = True ) -> None :
1615
1671
"""Reset the 'manual_control' status of the lights."""
@@ -1621,7 +1677,6 @@ def reset(self, *lights, reset_manual_control=True) -> None:
1621
1677
timer .cancel ()
1622
1678
self .last_state_change .pop (light , None )
1623
1679
self .last_service_data .pop (light , None )
1624
- self .cnt_significant_changes [light ] = 0
1625
1680
1626
1681
async def turn_on_off_event_listener (self , event : Event ) -> None :
1627
1682
"""Track 'light.turn_off' and 'light.turn_on' service calls."""
@@ -1700,11 +1755,7 @@ async def state_changed_event_listener(self, event: Event) -> None:
1700
1755
new_state .context .id ,
1701
1756
)
1702
1757
1703
- if (
1704
- new_state is not None
1705
- and new_state .state == STATE_ON
1706
- and is_our_context (new_state .context )
1707
- ):
1758
+ if new_state is not None and new_state .state == STATE_ON :
1708
1759
# It is possible to have multiple state change events with the same context.
1709
1760
# This can happen because a `turn_on.light(brightness_pct=100, transition=30)`
1710
1761
# event leads to an instant state change of
@@ -1717,21 +1768,29 @@ async def state_changed_event_listener(self, event: Event) -> None:
1717
1768
# incorrect 'min_kelvin' and 'max_kelvin', which happens e.g., for
1718
1769
# Philips Hue White GU10 Bluetooth lights).
1719
1770
old_state : list [State ] | None = self .last_state_change .get (entity_id )
1720
- if (
1721
- old_state is not None
1722
- and old_state [0 ].context .id == new_state .context .id
1723
- ):
1724
- # If there is already a state change event from this event (with this
1725
- # context) then append it to the already existing list.
1726
- _LOGGER .debug (
1727
- "State change event of '%s' is already in 'self.last_state_change' (%s)"
1728
- " adding this state also" ,
1729
- entity_id ,
1730
- new_state .context .id ,
1731
- )
1771
+ if is_our_context (new_state .context ):
1772
+ if (
1773
+ old_state is not None
1774
+ and old_state [0 ].context .id == new_state .context .id
1775
+ ):
1776
+ _LOGGER .debug (
1777
+ "TurnOnOffListener: State change event of '%s' is already"
1778
+ " in 'self.last_state_change' (%s)"
1779
+ " adding this state also" ,
1780
+ entity_id ,
1781
+ new_state .context .id ,
1782
+ )
1783
+ self .last_state_change [entity_id ].append (new_state )
1784
+ else :
1785
+ _LOGGER .debug (
1786
+ "TurnOnOffListener: New adapt '%s' found for %s" ,
1787
+ new_state ,
1788
+ entity_id ,
1789
+ )
1790
+ self .last_state_change [entity_id ] = [new_state ]
1791
+ self .start_transition_timer (entity_id )
1792
+ elif old_state is not None :
1732
1793
self .last_state_change [entity_id ].append (new_state )
1733
- else :
1734
- self .last_state_change [entity_id ] = [new_state ]
1735
1794
1736
1795
def is_manually_controlled (
1737
1796
self ,
@@ -1786,64 +1845,58 @@ async def significant_change(
1786
1845
detected, we mark the light as 'manually controlled' until the light
1787
1846
or switch is turned 'off' and 'on' again.
1788
1847
"""
1789
- if light not in self .last_state_change :
1790
- return False
1791
- old_states : list [State ] = self .last_state_change [light ]
1792
- await self .hass .helpers .entity_component .async_update_entity (light )
1793
- new_state = self .hass .states .get (light )
1848
+ last_service_data = self .last_service_data .get (light )
1849
+ if last_service_data is None :
1850
+ return
1794
1851
compare_to = functools .partial (
1795
1852
_attributes_have_changed ,
1796
1853
light = light ,
1797
- new_attributes = new_state .attributes ,
1798
1854
adapt_brightness = adapt_brightness ,
1799
1855
adapt_color = adapt_color ,
1800
1856
context = context ,
1801
1857
)
1802
- for index , old_state in enumerate (old_states ):
1803
- changed = compare_to (old_attributes = old_state .attributes )
1804
- if not changed :
1805
- _LOGGER .debug (
1806
- "State of '%s' didn't change wrt change event nr. %s (context.id=%s)" ,
1807
- light ,
1808
- index ,
1809
- context .id ,
1810
- )
1811
- break
1812
-
1813
- last_service_data = self .last_service_data .get (light )
1814
- if changed and last_service_data is not None :
1815
- # It can happen that the state change events that are associated
1816
- # with the last 'light.turn_on' call by this integration were not
1817
- # final states. Possibly a later EVENT_STATE_CHANGED happened, where
1818
- # the correct target brightness/color was reached.
1819
- changed = compare_to (old_attributes = last_service_data )
1820
- if not changed :
1858
+ # Update state and check for a manual change not done in HA.
1859
+ # Ensure HASS is correctly updating your light's state with
1860
+ # light.turn_on calls if any problems arise. This
1861
+ # can happen e.g. using zigbee2mqtt with 'report: false' in device settings.
1862
+ if switch ._detect_non_ha_changes :
1863
+ _LOGGER .debug (
1864
+ "%s: 'detect_non_ha_changes: true', calling update_entity(%s)"
1865
+ " and check if it's last adapt succeeded." ,
1866
+ switch ._name ,
1867
+ light ,
1868
+ )
1869
+ # This update_entity probably isn't necessary now that we're checking
1870
+ # if transitions finished from our last adapt.
1871
+ await self .hass .helpers .entity_component .async_update_entity (light )
1872
+ refreshed_state = self .hass .states .get (light )
1873
+ _LOGGER .debug (
1874
+ "%s: Current state of %s: %s" ,
1875
+ switch ._name ,
1876
+ light ,
1877
+ refreshed_state ,
1878
+ )
1879
+ changed = compare_to (
1880
+ old_attributes = last_service_data ,
1881
+ new_attributes = refreshed_state .attributes ,
1882
+ )
1883
+ if changed :
1821
1884
_LOGGER .debug (
1822
1885
"State of '%s' didn't change wrt 'last_service_data' (context.id=%s)" ,
1823
1886
light ,
1824
1887
context .id ,
1825
1888
)
1826
-
1827
- n_changes = self .cnt_significant_changes [light ]
1828
- if changed :
1829
- self .cnt_significant_changes [light ] += 1
1830
- if n_changes >= self .max_cnt_significant_changes :
1831
- # Only mark a light as significantly changing, if changed==True
1832
- # N times in a row. We do this because sometimes a state changes
1833
- # happens only *after* a new update interval has already started.
1834
1889
self .mark_as_manual_control (light )
1835
1890
_fire_manual_control_event (switch , light , context , is_async = False )
1836
- else :
1837
- if n_changes > 1 :
1838
- _LOGGER .debug (
1839
- "State of '%s' had 'cnt_significant_changes=%s' but the state"
1840
- " changed to the expected settings now" ,
1841
- light ,
1842
- n_changes ,
1843
- )
1844
- self .cnt_significant_changes [light ] = 0
1845
-
1846
- return changed
1891
+ return True
1892
+ _LOGGER .debug (
1893
+ "%s: Light '%s' correctly matches our last adapt's service data, continuing..."
1894
+ " context.id=%s." ,
1895
+ switch ._name ,
1896
+ light ,
1897
+ context .id ,
1898
+ )
1899
+ return False
1847
1900
1848
1901
async def maybe_cancel_adjusting (
1849
1902
self , entity_id : str , off_to_on_event : Event , on_to_off_event : Event | None
0 commit comments