Fifth runtime number per profile (5-120 min, default 30 = the old
RAMP_HALF_WIDTH_SECONDS constant). No options-flow field — the entity is
the only knob, meant to be driven seasonally from Node-RED. Two-tier
restore (restored -> default; no options tier, which would reset the
width on every unrelated options save). The curve and the sun-event
day-anchoring read the width once per tick and share the same value, so
widened evening ramps complete past the next_* sensor flip.
8 new tests; 164 passing. Bump to 2.5.0-cdit.1.
sensor.sun_next_rising flips to tomorrow at sunrise; the old anchor only
pulled it back when >12h away. With short June nights tomorrow's sunrise
is within 12h by mid-afternoon, so from ~17:30 local until sunset the
pair described tomorrow and the curve returned minimum.
Replace the clock-distance heuristic with ordering-based anchoring in a
new pure helper anchor_sun_events() (color_and_brightness.py):
- daytime (sunrise > sunset): pull sunrise back one day, unconditionally
- stale "today's sunset" still on yesterday: push sunset forward one day
- post-sunset ramp tail (both flipped, still within half_width): pull
both back so the down-ramp completes instead of snapping to min
8 regression tests incl. the June-afternoon repro and a winter check.
Config:
- .ruff.toml target-version py310 -> py312, matching pyproject's
requires-python (>=3.12) and the 3.12 'type' aliases switch.py
already ships. Unblocks two false invalid-syntax errors.
- Ignore D102 in tests (test-class methods), consistent with the
existing D100/D103 test ignores.
Code (no behavior changes):
- COM812 trailing commas + UP017 dt.UTC via ruff --fix, black re-wrap
- RUF002: unicode minus/en-dash -> ASCII hyphen in docstrings
- SIM105: contextlib.suppress for the lux-reading parse in config_flow
- TRY300: move return out of try in _read_lux_sensor
- PLC0415: hoist inline asyncio/logging imports to module top
- E741: rename ambiguous 'l' comprehension variable to light_id
- PT006/PT018: parametrize tuple + split compound assertion
- D102: docstring for OptionsFlow.async_step_init
- ARG001/ARG002: noqa with justification on HA-required signatures
- PLR0912/PLR0915: noqa on prepare_adaptation_data and
_update_attrs_and_maybe_adapt_lights (upstream-inherited complexity;
splitting would hurt readability more than it helps)
./scripts/lint passes clean; 148 tests pass.
The 'Lux reduction' sensor published the retained brightness factor, so it
read 100% when nothing was being reduced (0% actual reduction) — the label
and value were inverses. Now it publishes the actual reduction:
100 - round(factor*100), so 0% = no dimming, higher = more cut, None when
the gate is inactive.
Updates the output-sensors spec scenarios to match (71 -> 29, 100 -> 0,
adds the gate-inactive None case) and adds a publish-level value test
(none existed — the prior scenarios were never enforced in code).
Optional per-profile lux sensor binding that dims lights when daylight
alone exceeds a user-set target (factor = target/current). Lights turn
off entirely below min_brightness. Two config fields, live lux reading
in the options flow, two conditional output sensors, sensor state
subscription with 5pp significance guard. 26/26 tasks, 138 tests green.
Three sensor entities per AL profile expose the curve's current outputs
plus actual sun elevation as graphable numerics with
SensorStateClass.MEASUREMENT, so HA's recorder + apexcharts-card chart
them natively:
- sensor.<profile>_output_brightness (% — from self._settings)
- sensor.<profile>_output_color_temp (K — from self._settings)
- sensor.<profile>_sun_elevation (° — from sun.sun.attributes.elevation)
Architecture: master switch publishes computed outputs to
hass.data[DOMAIN][entry_id]["outputs"] after each curve tick, then fires
a per-entry dispatcher signal. Sensors subscribe and read from the cache.
Single computation path, push-based updates, no polling.
The existing master switch attributes (brightness_pct, color_temp_kelvin,
synthetic sun_position in [-1,+1]) are unchanged — sensors are purely
additive. Manifest bumped to 2.2.0-cdit.1 (minor; no breaking changes).
Artifact realignment along the way: Q2 answer assumed sun_position was
Sun2 elevation degrees, but reading color_and_brightness.py revealed
it's a synthetic float in [-1,+1] derived from the brightness curve.
Switched to pulling actual elevation from sun.sun (Decision 8 added),
renamed sensor from sun_position to sun_elevation. Friendly names use
asymmetric "Output" prefix to dodge collision with the adapt-brightness
switch and Min/Max color-temp numbers.
Remaining: group 6 (manual live-HA verification on
homeassistant.onca-blenny.ts.net) — requires HACS deploy.
15 new sensor tests pass; full suite green (123/123). openspec
validate --strict green. ruff clean on new files.
Adds four live-tunable `number` entities per AL profile (min/max
brightness, min/max color temp) that own the runtime curve values.
Slider changes take effect on the next curve tick — no integration
reload. State persists across HA restart via `RestoreNumber`.
Curve math now reads `min_brightness`, `max_brightness`,
`min_color_temp`, `max_color_temp` from the four runtime entities
via the entity registry, falling back to `entry.options` when an
entity is unavailable. The options flow seeds its four range fields
from the current entity state so the dialog matches reality.
Also fixes entity friendly names via HA's `has_entity_name`
composition: profile "Dining MVP" now reads as "Dining MVP",
"Dining MVP Brightness", "Dining MVP Color" — short enough for
HA's tightest cards. `unique_id`s are unchanged so existing
entity_ids stay stable.
Manifest bumped to 2.1.0-cdit.1 (minor, no breaking changes).
14 new tests in `tests/test_number_platform.py`; 108 passing
overall. OpenSpec change archived once 9.x live-HA verification
completes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Test infrastructure:
- Add `pytest-homeassistant-custom-component` (PHACC) as a `test`
dependency group. PHACC ships `hass`, `enable_custom_integrations`,
`MockConfigEntry`, and friends without needing to clone HA core as a
sibling directory. Modernizes the test setup from upstream's
`setup-symlinks` pattern.
- conftest.py: add `auto_enable_custom_integrations` autouse fixture
so HA discovers the integration under `custom_components/` during
tests. Keep the upstream template-deprecation no-op.
- Import paths: all tests now import from `custom_components.adaptive_lighting`
(not `homeassistant.components.adaptive_lighting`) and from
`pytest_homeassistant_custom_component.common` (not `tests.common`).
New tests (groups 7.2-7.16):
- tests/test_color_and_brightness.py (16 tests): TestBrightnessCurve
asserts min-before-sunrise, midpoint-at-event, max-during-day, sunset
ramp symmetry. TestColorTempCurve verifies the same shape applies to
K. TestSunPosition checks the synthetic +1/-1/0 derivation.
TestTanhDayCurveDirect exercises the helper directly.
- tests/test_config_flow.py (12 tests): six sections in order, each
section contains only its specified fields, conditional visibility
of send_split_delay, default sun entities, strict-typed entity
selectors, NumberSelector slider/box configs, BooleanSelector for
every flag, full user→create-entry flow, YAML-managed entry aborts
with `yaml_managed` reason, options flow renders the sectioned schema.
- tests/test_init.py (6 tests): successful setup on current version,
stale version raises ConfigEntryError via async_migrate_entry,
unload is clean, tombstone removes orphan sleep entity + logs INFO,
tombstone is idempotent, tombstone respects config_entry ownership.
Source modernizations driven by the tests:
- __init__.py: add `async_migrate_entry` that surfaces the
"incompatible — delete and recreate" message and sets the entry to
MIGRATION_ERROR. Removed direct `ConfigEntryError` from
`async_setup_entry` (HA routes version mismatches through the
migration handler now).
- switch.py: removed upstream's YAML-managed-entry auto-remove hack.
YAML profiles now load normally and the options flow handles the
"you must edit configuration.yaml" message (spec R7).
Existing tests updated to PHACC paths: test_adaptation_utils.py
(38 tests), test_hass_utils.py (22 tests). Both pass without
modification beyond the import fix.
Deleted: tests/test_switch.py (2,999 LOC of upstream tests, most
covering sleep mode / take-over-control / manual-control state
machines that no longer exist; CDiT-specific switch tests deferred
to a follow-up change).
Result: `uv run --group test pytest tests/` → 94 passed in 0.57s.
Deferred from this change:
- 4.3: manual UI test that toggling a field and saving reloads cleanly
(requires a real HA instance, can't be done from CLI).
- 9.4: GitHub repo description / topics update (do via `gh repo edit`
outside the change scope).
openspec status: 4/4 artifacts complete; strict-validate green.
const.py:
- Drop 21 retired CONF_*/DEFAULT_* (sleep cluster, manual sun timing,
brightness curve variants, take-over-control cluster).
- Add CONF_SUNRISE_ENTITY / CONF_SUNSET_ENTITY (default
sensor.sun_next_rising / _setting).
- Add RAMP_HALF_WIDTH_SECONDS = 1800.
- Add CONFIG_ENTRY_VERSION = 2 (gate for strict version-break).
- Re-default DEFAULT_MIN_BRIGHTNESS 1→5, DEFAULT_MIN_COLOR_TEMP 2000→2200.
- Update ICON_MAIN / _BRIGHTNESS / _COLOR_TEMP to the CDiT picks.
manifest.json: bump to 2.0.0-cdit.1, pin homeassistant: 2025.1.0,
re-point codeowners/documentation/issue_tracker to the CaseyRo fork.
switch.py:
- Drop sleep_mode_switch creation in async_setup_entry; integration
now creates 3 switches per profile (master, adapt_color, adapt_brightness).
- Strip sleep-mode state machine from AdaptiveSwitch (sleep_mode_switch
attribute, sleep_transition, adapt_until_sleep, the state-change
listener, the _sleep_mode_switch_state_event_action method).
- Bridge-stub _take_over_control / _detect_non_ha_changes /
_adapt_only_on_bare_turn_on / _only_once / _auto_reset_manual_control_time
as class-level False/0 so Manager-side branches become dead code without
needing a full rewrite of AdaptiveLightingManager.
- Remove handle_set_manual_control service + its registration.
- Replace astral-driven curve init with the new SunLightSettings call
shape; add AdaptiveSwitch._today_sun_events() helper that reads the
configured sunrise/sunset entities.
- Set _attr_icon on AdaptiveSwitch.
color_and_brightness.py:
- Rewrite SunLightSettings as a pure curve-math wrapper (5 fields:
name + bounds + ramp half-width); methods take t_sunrise/t_sunset
as args, never reads HA state.
- Implement piecewise tanh ramp curve per spec R4 / design D11 in
_tanh_day_curve(). Both brightness and color-temp use the same shape.
- Drop SunEvents, sleep-mode branches, brightness_mode-switch, astral
dependency, force_rgb_color, lerp_color_hsv sleep-tinted color blending.
config_flow.py:
- Rewrite as sectioned schema (Targets / Daytime curve / Sun schedule /
Light control / Advanced / Diagnostics) via HA's section() helper.
- Native HA selectors throughout (NumberSelector, EntitySelector,
BooleanSelector). Strict typing on sun-event entity pickers.
- Conditional visibility for send_split_delay (driver:
separate_turn_on_commands).
- Extend OptionsFlowWithReload (with ImportError fallback for HA < 2025.1
dev environment); async_abort(reason="yaml_managed") for SOURCE_IMPORT.
- VERSION = CONFIG_ENTRY_VERSION.
__init__.py:
- Reject older config entries with ConfigEntryError ("recreate the entry")
per spec R8 / design D4.
- Add _remove_orphan_sleep_entities tombstone helper that scans the
entity registry for sleep-mode entities owned by this entry and
removes them on first setup. Idempotent and config_entry-scoped.
_docs_helpers.py: stub the removed DOCS_MANUAL_CONTROL / SET_MANUAL_CONTROL_SCHEMA
so the docs-generator script keeps importing.
Validation:
- ALL modules import cleanly under HA 2024.12.5 (dev env).
- openspec validate cdit-config-redesign --strict: green.
- ruff check --select=F: clean.
Remaining: groups 7 (16 tests), 8 (strings + plain-language pass), 9 (docs).
* fix: reduce log verbosity for self-triggered off-to-on warning (#1433)
Move full event object dump from warning to debug level in
_off_to_on_state_event_is_from_turn_on() (switch.py:2717).
For lights with large effect_list attributes (e.g. Govee lights
with 130+ effects), the warning dumped the entire Event object
including both old_state and new_state, creating log entries
thousands of characters long.
The warning now logs only entity_id and context.id, while the
full event remains available at debug level for troubleshooting.
Fixes#1433
* fix: correct indentation for _LOGGER.debug block
* fix: correct indentation for _LOGGER.warning and _LOGGER.debug
* fix: remove unintended encoding corruption, keep only log level change
Reset switch.py to main and re-apply only the intended change:
move the full event object from warning to debug level in
_off_to_on_state_event_is_from_turn_on().
Addresses reviewer feedback about unintended Unicode corruption
(→ and ≈ characters were corrupted to mojibake).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Florian Horner <florianhorner@Mac.fritz.box>
Co-authored-by: Bas Nijholt <basnijholt@users.noreply.github.com>
* test: regression test — AL must not override manual brightness with separate_turn_on_commands
End-to-end scenario: user adjusts brightness via a directly-bound Zigbee
switch (e.g. IKEA RODRET). No HA service call is made; ZHA reports the new
brightness via async_update_entity. On the next adaptation interval AL must
detect the change and stop overriding the user's brightness.
The test verifies the user-visible symptom: after two adaptation cycles
following a simulated direct-Zigbee brightness change, the light's brightness
must still be the manually set value — not AL's own target.
NOTE: this test FAILS on the current code. It is committed here to document
the bug before the fix is applied in the next commit.
* fix: merge last_service_data across split calls to fix detect_non_ha_changes with separate_turn_on_commands
When separate_turn_on_commands=True, each adaptation cycle makes two
light.turn_on calls (brightness, then color_temp). Previously each call
overwrote last_service_data[light], so after the cycle only the color_temp
key remained. _attributes_have_changed() then saw old_brightness=None and
silently skipped the brightness comparison, so a manually-set brightness was
never detected and AL kept overriding it.
Fix: merge instead of overwrite so all split-call attributes accumulate:
self.manager.last_service_data[light] = {
**self.manager.last_service_data.get(light, {}),
**service_data,
}
* test: add intermediate assertions to regression test
Two assertions were promised in the PR description but missing:
1. After the force-adapt, assert that last_service_data contains BOTH
brightness AND color — directly proving the merge fix works.
2. After the first non-forced update, assert that BRIGHTNESS is in
manual_control — proving detection fired, not just that the final
state is right.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* refactor: remove spurious comments, trim test docstring and assertions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor: strip verbose comments from test, trim assert messages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* test: add message to bare assert
## Summary
- Fixes regression in v1.30.0 where lights turned on by automations were incorrectly marked as "manually controlled"
- Makes `adapt_only_on_bare_turn_on` respect individual attribute tracking from #1356
## Root Cause
PR #1356 added a call to `update_manually_controlled_from_event()` in the `turn_on_off_event_listener.on()` handler for ALL `light.turn_on` events, including when turning a light on from OFF state.
When an automation turns on a light with brightness/color attributes, this incorrectly marked the light as "manually controlled", preventing Adaptive Lighting from adapting it.
## Fix
1. Only call `update_manually_controlled_from_event()` when the light was **already ON** before the turn_on event. Turning on from OFF is handled by `_respond_to_off_to_on_event()`.
2. Make `adapt_only_on_bare_turn_on` respect `take_over_control_mode`:
- With `PAUSE_CHANGED`: Only pause adaptation of specified attributes, continue adapting unspecified ones
- With `PAUSE_ALL`: Pause all adaptation (existing behavior)
## Expected Behavior After Fix
| Scenario | `adapt_only_on_bare_turn_on` | `take_over_control_mode` | Result |
|----------|------------------------------|--------------------------|--------|
| Turn on from OFF with brightness | `false` | Either | NOT manually controlled |
| Turn on from OFF with brightness | `true` | `PAUSE_ALL` | All adaptation paused |
| Turn on from OFF with brightness | `true` | `PAUSE_CHANGED` | Only brightness paused, color adapts |
| Turn on from OFF without attributes | Either | Either | NOT manually controlled |
| Change brightness while ON | Either | Either | Brightness manually controlled |
## Test plan
- [x] Turn on light via automation with brightness/color (`adapt_only_on_bare_turn_on=false`) - should adapt
- [x] Turn on light via scene (`adapt_only_on_bare_turn_on=true`, `PAUSE_ALL`) - should pause all adaptation
- [x] Turn on light with brightness only (`adapt_only_on_bare_turn_on=true`, `PAUSE_CHANGED`) - should adapt color
- [x] Both intercept=True and intercept=False paths tested for consistency
- [x] CI tests pass
Fixes#1378
Co-authored-by: Mario Guggenberger <mg@protyposis.net>
* refactor: introduce light control parameter enum
* refactor: replace manual control flag with parameter enum
* test: update deprecated color temp attribute
* build: set execution bits on task scripts
* feat: individual manual control of brightness and color
* test: add tests for individual manual control evaluation
* fix: sequential manual changes not always detected
If multiple attributes of a light were changed within an interval, only the last change was detected because the check in the interval only used the latest event. For example, if there was a brightness change and a following color change, only the color attribute was detected as manually controlled. To fix this, the manual control attribute flags are now set directly from the event handler so that all events are processed.
* fix: invalid service description
* docs: fix missing space in config description
* refactor: pluralize multivalued bitmask enum name
* Add regression tests for SimpleSwitch initial state bug
Adds tests that verify SimpleSwitch._state is set immediately in __init__
rather than waiting for async_added_to_hass(). These tests currently FAIL
because _state is None after __init__, which causes an infinite loop in
_setup_listeners when the entity is disabled (since async_added_to_hass
is never called for disabled entities).
Regression tests for: https://github.com/basnijholt/adaptive-lighting/issues/1264
* Fix infinite loop when disabling SimpleSwitch entities
The issue was that SimpleSwitch._state was initialized to None in __init__,
but only set to a boolean value in async_added_to_hass(). When an entity
is disabled, async_added_to_hass() is never called, so _state stayed None.
The _setup_listeners() method has a while loop that waits for
_state is not None for all SimpleSwitch children (sleep_mode_switch,
adapt_brightness_switch, adapt_color_switch). With _state stuck at None,
this created an infinite loop.
The fix sets _state to initial_state directly in __init__ instead of
waiting for async_added_to_hass() to set it. The async_added_to_hass()
will still properly restore state from the last session or set based
on initial_state as before.
Fixes: https://github.com/basnijholt/adaptive-lighting/issues/1264
When start() creates a task with asyncio.create_task(), the task is scheduled
but not immediately executed. This means is_running() returns True (task exists
and not done), but start_time is still None because _run() hasn't executed yet.
This causes a TypeError when comparing event.time_fired > timer.start_time.
Fix by setting start_time in start() before creating the task.
Fixes#1272
* remove @bind_hass decorator
* attempt to pass hass
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Bas Nijholt <bas@nijho.lt>
* suggested fix for HA2025 deprecation warnings
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Bas Nijholt <bas@nijho.lt>
* Do not adapt lights that are turned on with an effect
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Bas Nijholt <basnijholt@gmail.com>