mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-12 14:54:04 +02:00
Merge remote-tracking branch 'origin/main' into fix/unavailable-light-recovery
# Conflicts: # custom_components/adaptive_lighting/switch.py # tests/test_switch.py
This commit is contained in:
commit
c2d9ea3e51
7 changed files with 181 additions and 12 deletions
|
|
@ -1568,6 +1568,17 @@
|
|||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "ahmadtawakol",
|
||||
"name": "Ahmad Tawakol",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/2355493?v=4",
|
||||
"profile": "https://github.com/ahmadtawakol",
|
||||
"contributions": [
|
||||
"code",
|
||||
"bug",
|
||||
"maintenance"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
|
|
|
|||
28
.dockerignore
Normal file
28
.dockerignore
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# The Home Assistant core checkout. tests/README.md has you clone it to ./core,
|
||||
# but the Dockerfile clones its own copy to /core and links /app/core to it.
|
||||
# Without this entry `COPY . /app/` ships ~300MB into every build and leaves
|
||||
# /app/core as a real directory, so `ln -s /core /app/core` links *inside* it
|
||||
# rather than creating the intended symlink.
|
||||
core/
|
||||
|
||||
# Local virtualenvs
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Not used by the build
|
||||
.git/
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Caches and test output
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
htmlcov/
|
||||
.coverage
|
||||
.coverage.*
|
||||
coverage.xml
|
||||
|
|
@ -14,7 +14,7 @@ repos:
|
|||
files: ^README[^/]*\.md$
|
||||
args: ["--notitle"]
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.16.5
|
||||
rev: v0.16.6
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: ["--fix"]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
[](https://github.com/hacs/integration)
|
||||

|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
[](#contributors-)
|
||||
[](#contributors-)
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
# 🌞 Adaptive Lighting: Enhance Your Home's Atmosphere with Smart, Sun-Synchronized Lighting 🌙
|
||||
|
|
@ -1120,6 +1120,7 @@ Notice the values of `brightness_mode_time_light` and `brightness_mode_time_dark
|
|||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/hesseleo"><img src="https://avatars.githubusercontent.com/u/44778508?v=4?s=100" width="100px;" alt="Leonhard Hesse"/><br /><sub><b>Leonhard Hesse</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=hesseleo" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://www.tim-maps.com"><img src="https://avatars.githubusercontent.com/u/6741938?v=4?s=100" width="100px;" alt="Tim Stallmann"/><br /><sub><b>Tim Stallmann</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=timstallmann" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lehneres"><img src="https://avatars.githubusercontent.com/u/7437288?v=4?s=100" width="100px;" alt="lehneres"/><br /><sub><b>lehneres</b></sub></a><br /><a href="#ideas-lehneres" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ahmadtawakol"><img src="https://avatars.githubusercontent.com/u/2355493?v=4?s=100" width="100px;" alt="Ahmad Tawakol"/><br /><sub><b>Ahmad Tawakol</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=ahmadtawakol" title="Code">💻</a> <a href="https://github.com/basnijholt/adaptive-lighting/issues?q=author%3Aahmadtawakol" title="Bug reports">🐛</a> <a href="#maintenance-ahmadtawakol" title="Maintenance">🚧</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from homeassistant.components.light import (
|
|||
ATTR_SUPPORTED_COLOR_MODES,
|
||||
ATTR_TRANSITION,
|
||||
ATTR_XY_COLOR,
|
||||
VALID_TRANSITION,
|
||||
ColorMode,
|
||||
LightEntityFeature,
|
||||
is_on,
|
||||
|
|
@ -623,6 +624,18 @@ def _is_state_event(
|
|||
)
|
||||
|
||||
|
||||
def _turn_off_transition(turn_off_event: Event) -> float | None:
|
||||
"""Normalize the raw event transition using the light service's validator.
|
||||
|
||||
Service-call events retain raw data after validation, so repeat the
|
||||
service's coercion and clamping before calculating transition windows.
|
||||
"""
|
||||
transition = turn_off_event.data[ATTR_SERVICE_DATA].get(ATTR_TRANSITION)
|
||||
if transition is None:
|
||||
return None
|
||||
return VALID_TRANSITION(transition)
|
||||
|
||||
|
||||
def _expand_light_groups(
|
||||
hass: HomeAssistant,
|
||||
lights: list[str],
|
||||
|
|
@ -3186,7 +3199,7 @@ class AdaptiveLightingManager:
|
|||
):
|
||||
return False
|
||||
|
||||
transition = turn_off_event.data[ATTR_SERVICE_DATA].get(ATTR_TRANSITION)
|
||||
transition = _turn_off_transition(turn_off_event)
|
||||
delay = max(transition or 0, TURNING_OFF_DELAY)
|
||||
elapsed = (dt_util.utcnow() - turn_off_event.time_fired).total_seconds()
|
||||
if not 0 <= elapsed <= delay:
|
||||
|
|
@ -3270,7 +3283,7 @@ class AdaptiveLightingManager:
|
|||
|
||||
turn_off_event = self.turn_off_event.get(entity_id)
|
||||
if turn_off_event is not None:
|
||||
transition = turn_off_event.data[ATTR_SERVICE_DATA].get(ATTR_TRANSITION)
|
||||
transition = _turn_off_transition(turn_off_event)
|
||||
else:
|
||||
transition = None
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,17 @@
|
|||
set -ex
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# '-n' keeps a re-run idempotent: without it 'ln -fs' follows an existing
|
||||
# symlink and creates the new link *inside* the target directory, leaving a
|
||||
# stray 'tests/tests' and 'custom_components/adaptive_lighting/adaptive_lighting'
|
||||
# in the working tree.
|
||||
|
||||
# Link custom components
|
||||
cd core/homeassistant/components/
|
||||
ln -fs ../../../custom_components/adaptive_lighting adaptive_lighting
|
||||
ln -fsn ../../../custom_components/adaptive_lighting adaptive_lighting
|
||||
cd -
|
||||
|
||||
# Link tests
|
||||
cd core/tests/components/
|
||||
ln -fs ../../../tests/ adaptive_lighting
|
||||
ln -fsn ../../../tests/ adaptive_lighting
|
||||
cd -
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ from homeassistant.components.adaptive_lighting.switch import (
|
|||
SimpleSwitch,
|
||||
_attributes_have_changed,
|
||||
_expand_light_groups,
|
||||
_turn_off_transition,
|
||||
color_difference_redmean,
|
||||
create_context,
|
||||
is_our_context,
|
||||
|
|
@ -113,6 +114,7 @@ from homeassistant.const import (
|
|||
ATTR_ENTITY_ID,
|
||||
ATTR_FLOOR_ID,
|
||||
ATTR_LABEL_ID,
|
||||
ATTR_SERVICE_DATA,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
CONF_LIGHTS,
|
||||
CONF_NAME,
|
||||
|
|
@ -1539,8 +1541,10 @@ async def test_apply_updates_non_ha_change_baseline(
|
|||
)
|
||||
|
||||
direction = 1 if manual_value < adaptive_value else -1
|
||||
# Legacy template lights round via mireds; 70 K keeps one reported step
|
||||
# below 100 K and two steps above it across the configured range.
|
||||
small_change = (
|
||||
15 if manual_attribute == LightControlAttributes.BRIGHTNESS else 60
|
||||
15 if manual_attribute == LightControlAttributes.BRIGHTNESS else 70
|
||||
)
|
||||
freezer.tick(90)
|
||||
set_physical_state(manual_value + direction * small_change)
|
||||
|
|
@ -4262,17 +4266,17 @@ def _turn_off_service_event(
|
|||
entity_ids: list[str],
|
||||
ts: float,
|
||||
context: Context,
|
||||
transition: float,
|
||||
transition: float | str | None,
|
||||
) -> Event:
|
||||
service_data = {ATTR_ENTITY_ID: entity_ids}
|
||||
if transition is not None:
|
||||
service_data[ATTR_TRANSITION] = transition
|
||||
return Event(
|
||||
EVENT_CALL_SERVICE,
|
||||
{
|
||||
"domain": LIGHT_DOMAIN,
|
||||
"service": SERVICE_TURN_OFF,
|
||||
"service_data": {
|
||||
ATTR_ENTITY_ID: entity_ids,
|
||||
ATTR_TRANSITION: transition,
|
||||
},
|
||||
"service_data": service_data,
|
||||
},
|
||||
time_fired_timestamp=ts,
|
||||
context=context,
|
||||
|
|
@ -4566,6 +4570,113 @@ async def test_just_turned_off_same_automation_context(hass, cleanup):
|
|||
assert not await manager.just_turned_off(ENTITY_LIGHT_1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("transition", "window"),
|
||||
[(10, 10), (10.0, 10), ("10", 10), ("10000", 6553), ("inf", 6553), (None, 5)],
|
||||
)
|
||||
async def test_just_turned_off_normalized_transition(hass, cleanup, transition, window):
|
||||
"""Both turn-off guards use coerced and clamped transition windows."""
|
||||
await setup_lights(hass)
|
||||
_, switch = await setup_switch(hass, {CONF_LIGHTS: [ENTITY_LIGHT_1]})
|
||||
await hass.async_block_till_done()
|
||||
manager = switch.manager
|
||||
|
||||
now = dt_util.utcnow().timestamp()
|
||||
context = Context()
|
||||
other_context = Context()
|
||||
|
||||
# Setting up the switch turns the light on, and that 'turn_on' would be read
|
||||
# as the legitimate explanation for the 'off' → 'on' state changes below.
|
||||
manager.turn_on_event.pop(ENTITY_LIGHT_1, None)
|
||||
|
||||
def set_events(turn_off_ts: float, off_to_on_context: Context) -> None:
|
||||
manager.turn_off_event[ENTITY_LIGHT_1] = _turn_off_service_event(
|
||||
[ENTITY_LIGHT_1],
|
||||
turn_off_ts,
|
||||
context,
|
||||
transition=transition,
|
||||
)
|
||||
manager.on_to_off_event[ENTITY_LIGHT_1] = _state_changed_event(
|
||||
ENTITY_LIGHT_1,
|
||||
turn_off_ts,
|
||||
other_context,
|
||||
)
|
||||
manager.off_to_on_event[ENTITY_LIGHT_1] = _state_changed_event(
|
||||
ENTITY_LIGHT_1,
|
||||
now,
|
||||
off_to_on_context,
|
||||
)
|
||||
|
||||
# A matching context is ignored within the normalized transition window.
|
||||
set_events(now - window + 1, context)
|
||||
assert await manager.just_turned_off(ENTITY_LIGHT_1)
|
||||
|
||||
# Past that window the same shape must stop matching.
|
||||
set_events(now - window - 1, context)
|
||||
assert not await manager.just_turned_off(ENTITY_LIGHT_1)
|
||||
|
||||
# `just_turned_off`'s own `max(transition, TURNING_OFF_DELAY)`: reached when
|
||||
# the 'off' → 'on' state change carries a fresh context, so the check above
|
||||
# returns early and the delay is computed from the 'on' → 'off' change.
|
||||
manager.turn_off_event[ENTITY_LIGHT_1] = _turn_off_service_event(
|
||||
[ENTITY_LIGHT_1],
|
||||
now - window - 1,
|
||||
context,
|
||||
transition=transition,
|
||||
)
|
||||
manager.on_to_off_event[ENTITY_LIGHT_1] = _state_changed_event(
|
||||
ENTITY_LIGHT_1,
|
||||
now - window - 1,
|
||||
context,
|
||||
)
|
||||
manager.off_to_on_event[ENTITY_LIGHT_1] = _state_changed_event(
|
||||
ENTITY_LIGHT_1,
|
||||
now,
|
||||
Context(),
|
||||
)
|
||||
assert not await manager.just_turned_off(ENTITY_LIGHT_1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("transition", "expected"),
|
||||
[("2", 2.0), ("10000", 6553), ("inf", 6553), ("-2", 0), (None, None)],
|
||||
)
|
||||
async def test_turn_off_event_keeps_raw_transition(hass, cleanup, transition, expected):
|
||||
"""Normalize raw event data to the same transition used by the light service."""
|
||||
await setup_lights(hass)
|
||||
_, switch = await setup_switch(hass, {CONF_LIGHTS: [ENTITY_LIGHT_1]})
|
||||
await hass.async_block_till_done()
|
||||
manager = switch.manager
|
||||
|
||||
service_data = {ATTR_ENTITY_ID: ENTITY_LIGHT_1}
|
||||
if transition is not None:
|
||||
service_data[ATTR_TRANSITION] = transition
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
service_data,
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
event = manager.turn_off_event[ENTITY_LIGHT_1]
|
||||
assert event.data[ATTR_SERVICE_DATA].get(ATTR_TRANSITION) == transition
|
||||
assert _turn_off_transition(event) == expected
|
||||
|
||||
# A 'transition' that cannot be coerced is rejected by the schema, so it
|
||||
# never reaches the listener.
|
||||
manager.turn_off_event.pop(ENTITY_LIGHT_1)
|
||||
with pytest.raises(voluptuous.error.MultipleInvalid):
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: ENTITY_LIGHT_1, ATTR_TRANSITION: "not-a-number"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert ENTITY_LIGHT_1 not in manager.turn_off_event
|
||||
|
||||
|
||||
async def test_just_turned_off_group_context_reuse_end_to_end(hass, cleanup):
|
||||
"""A tracked member turn-on explains a group's reused OFF context (#1378)."""
|
||||
await setup_lights(hass, with_group=True)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue