Skip to content

Adaptive Cover Pro ⛅ v2.23.0

Choose a tag to compare

@jrhubott jrhubott released this 26 May 05:04
· 89 commits to main since this release

ℹ Using release notes from: release_notes/v2.23.0.md

v2.23.0

🎯 Highlights

v2.23.0 closes a four-beta cycle that delivered five new features, resolved nine reported bugs, and carried a focused round of internal performance and cleanup work. The headline additions are adaptive_cover_pro.stop — a new service that routes stop commands through the ACP stack with an ACP-stamped context so manual override engages correctly — and manual_ignore_external, a Manual Override option that limits override engagement to commands passing through ACP itself, leaving RF remotes and third-party automations free to move the cover without pausing automatic control. sensor.<cover>_position_forecast is also new: a diagnostic sensor whose state is the next upcoming forecast event and whose forecast attribute carries a 12-hour position strip at 15-minute resolution. sensor.<cover>_climate_status now emits summer_mode, winter_mode, and intermediate slugs instead of raw English strings — this is a breaking change for automations or templates that compared against the previous values. Rounding out the release: two cooperating sunset_position bugs are fixed, the set_position service schema now accepts standard HA target: blocks, HA unit-system display lands in the config UI and sensors, custom-position slots gain an enable/disable toggle, and the _DAY_CACHE and O(1) forecast index lookup reduce per-update allocations.


✨ Features

adaptive_cover_pro.stop service (#456, #458)

A new adaptive_cover_pro.stop service mirrors adaptive_cover_pro.set_position: it calls coordinator.async_apply_user_stop, which engages manual override via manager.mark_user_command then dispatches cover.stop_cover through CoverCommandService.apply_user_stop with an ACP-stamped HA context. The resulting state-change event is recognized as ACP-originated, so manual_ignore_external mode does not treat it as external noise and the next automatic cycle does not counter-command the cover.

The Lovelace card (adaptive-cover-pro-card#67) should call adaptive_cover_pro.stop instead of cover.stop_cover once this service is available. Until the card is updated, direct cover.stop_cover calls will continue to work when manual_ignore_external is off; with manual_ignore_external on they will be silently ignored by the override detection paths.

manual_ignore_external option (#455)

A new boolean option manual_ignore_external (default False) on the Manual Override config step limits override engagement to commands that pass through ACP. When on:

  • Position changes from the proxy cover entity and the adaptive_cover_pro.set_position / adaptive_cover_pro.stop services still engage manual override (the mark_user_command path inside async_apply_user_position is unaffected).
  • Three detection paths in the coordinator are gated: the user-context fast-path in async_handle_cover_state_change, the numeric-diff path in async_handle_cover_state_change, and the cover.stop_cover detection in async_check_cover_service_call. Position changes arriving via these paths are silently skipped and automatic control continues uninterrupted.

The option is surfaced in diagnostics and on the set_manual_override service schema. Default False preserves existing behavior — no change for existing entries.

Translatable climate_status state values (#453, #454)

sensor.<cover>_climate_status switches from raw English strings ("Summer Mode", "Winter Mode", "Intermediate") to slugs: summer_mode, winter_mode, and intermediate. The sensor spec gains translation_key="climate_status", device_class=SensorDeviceClass.ENUM, and options=("summer_mode", "winter_mode", "intermediate") so HA renders the per-language label via the entity.sensor.climate_status.state block in each translation file.

Breaking change: automations and templates that compare climate_status state against the previous English values must update to the slug form. The old English strings are no longer emitted.

Translations updated in en.json, de.json, and fr.json.

Position forecast sensor (#432)

A new sensor.<cover>_position_forecast diagnostic sensor (device class timestamp) projects today's solar-tracking behavior as a coarse timeline. Its state is the ISO 8601 timestamp of the next upcoming forecast event. Its forecast attribute carries a list of {t, position, handler} samples at 15-minute steps over a 12-hour window; its events attribute lists {t, kind, label} boundary events where kind is one of "sunrise", "sunset", "fov_enter", "fov_exit".

The forecast.py module exposes build_forecast() as a pure function parameterized over a cover_factory closure, and build_forecast_for_coord() as a thin shim that wires it to the coordinator's sun provider, policy, and config service. Only solar tracking is projected — manual override, motion, weather safety, and custom positions depend on real-time inputs that would mislead a forecast if naively held at their current state. Translations added to en.json, de.json, and fr.json.

Custom-position slot enable/disable + decision-trace slot snapshot (#432)

Each custom-position slot gains a new opt-out config key custom_position_enabled_<N> (default True when absent). Setting it False silences a slot without losing its sensor, position, or priority configuration. The set_custom_position service gains an enabled field that writes custom_position_enabled_<N>. sensor.<cover>_decision_trace gains a custom_position_slots attribute — a stable 4-row list (one per slot) with {slot, enabled, sensor, sensor_name, position, priority, min_mode} — and a custom_position_active_slot_name attribute when a CustomPositionHandler wins.

active_slot and floor_binding on the decision trace (#421, #424)

sensor.<cover>_decision_trace gains custom_position_active_slot (1-based winning slot number, None when no custom handler wins) and custom_position_minimum_mode (present only when min_mode is active: True when the floor is constraining, False when the raw calculation already meets the floor).

HA unit system in config UI and sensors (#428)

Geometry and sun-tracking configuration now displays in the user's HA unit system. US-customary instances see inches; storage stays in metres. sensor.<cover>_target_position gains target_distance, actual_distances, and distance_unit attributes. sensor.<cover>_climate_status gains a ha_temperature_unit attribute.

My-preset entities gated by config-flow toggle (#434)

The My Position button and My Position Value number entities are off by default and gated behind a config-flow toggle. Existing entries where the entities were already configured keep them visible.

Shaded distance 0 m and glare zone Z height (#429)

_RANGE_DISTANCE lowered to (0.0, 50.0). GlareZone gains a z: float = 0.0 field; when z > 0, glare_zone_effective_distance offsets by z / tan(sol_elev) to protect a point above the floor.

Default new entry title to source device name

When the name field is left blank in the create flow, the entry title defaults to the attached HA device's name verbatim. Falls back to Adaptive {entity_name} when no device is attached.


🐛 Bug Fixes

adaptive_cover_pro.set_position accepts HA target keys (#460, #461)

Calling adaptive_cover_pro.set_position with a standard HA target: block (entity, device, or area selector) failed with a schema validation error. SET_POSITION_SCHEMA was defined as vol.Schema(..., extra=vol.PREVENT_EXTRA), which rejected the entity_id / device_id / area_id keys that HA's service target resolution injects into the call data. The schema now uses cv.make_entity_service_schema(...) — HA's canonical helper for service schemas that need to accept target keys.

sunset_position — wrong fallback during morning operational window (#438, #450)

compute_effective_default in helpers.py activated the sunset fallback whenever now < astronomical_sunrise + offset, even when the user's configured start_time had already passed. On winter mornings where sunrise lands after start_time (e.g. start at 08:00, sunrise at 08:15), the cover sat at sunset_position during the operational window instead of default_position. The fix adds an after_start_time kwarg; the before-sunrise branch is now suppressed when the operational window is already open.

sunset_position — cannot clear stored value of 0 (#439, #450)

Clearing sunset_position in the options UI appeared to save but reopening the config restored the previous value of 0. The root cause: voluptuous omits absent optional fields from the submitted dict, so options.update(user_input) left the prior 0 intact rather than replacing it with None. The fix introduces _POSITION_OPTIONAL_KEYS and calls optional_entities() with that list in both the config flow and options flow async_step_position handlers before dict.update(), so a cleared field is explicitly written as None.

Forecast tick chain — five cooperating fixes

sensor.<cover>_position_forecast was frozen at its boot-time value in all but the lightest-activity installations. Five cooperating root causes, all fixed:

  • Tasks destroyed before first await (#448): hass.async_create_background_task called from a sync timer callback produced weakly-held tasks the GC collected before the event loop ran them. The fix switches to config_entry.async_create_background_task, pinning each task to the entry's lifetime.
  • Tick function dispatched to executor: Without @callback, HA classified _tick as HassJobType.Executor and ran it in a worker thread, where loop.create_task(..., eager_start=True) raised RuntimeError: loop is not the running loop. Adding @callback to _tick keeps it on the event loop.
  • Ticks staggered per-entry: async_track_time_interval fired each entry's tick at a fixed offset from its startup time. Switching to async_track_time_change with minute=range(0, 60, FORECAST_RECOMPUTE_INTERVAL_MIN), second=0 aligns all entries to the wall-clock 5-minute grid.
  • Listeners not notified after recompute: async_recompute_forecast updated coordinator.data.position_forecast but never called async_update_listeners(). The fix adds the call immediately after the data field is updated.
  • FOV-enter/exit events lag the position curve: Event markers were placed at the first 15-minute sample whose handler had already flipped. The new _refine_fov_crossing function in forecast.py scans SunData's native 5-minute grid between bracketing samples, reducing timing error from ±15 min to ±5 min.

My Position button bypasses auto-control gate (#430, #431)

AdaptiveCoverMyPositionButton.async_press was dropping the command when the automatic-control switch was off. The fix passes bypass_auto_control=True and use_my_position=True through to _build_position_context.

Custom-position attrs propagate through pipeline registry (#421, #424)

PipelineRegistry.evaluate now uses dataclasses.replace to build PipelineResult from the winning handler, so all fields — including custom-position diagnostics — carry through to sensor.py.

Reconciliation pass honors in-transit guard (#418, #423)

The reconciliation pass in CoverCommandService now skips any entity whose cover state is opening or closing, preventing a re-send that could override a user's RF command mid-travel. The predicate is extracted as _is_cover_in_transit on CoverCommandService.

cover_type now present on control_status sensor attributes

sensor.<cover>_control_status was missing cover_type, causing the companion Lovelace card to render awning wedge-fill backwards. The fix adds "cover_type": s._cover_type to _control_status_attrs in sensor.py.

French label for cloud_suppression decision trace (#457, #459)

The previous French wording "Suppression de nuages" reads as "removing the clouds" to a native French speaker — a literal translation that misrepresents what the handler does. The new label "Désactivation par temps nuageux" (disabled when cloudy) matches the action-oriented phrasing the other decision-trace state labels use in fr.json. A regression guard test_fr_cloud_suppression_decision_trace_state in tests/test_translations.py pins the corrected string.


⚙️ Changed

  • O(1) forecast index lookup (#442, #447): _nearest_index in forecast.py replaces a linear scan with a direct arithmetic expression keyed on SUN_DATA_STEP_SECONDS = 300 in const.py.

  • Shared SunData day cache (#441, #445): All SunData instances at the same location share one pd.date_range + astral fill per day via a module-level _DAY_CACHE dict in sun.py.

  • Recorder exclusion for heavy sensor attributes (#446): actual_positions, actual_distances, position_explanation, manual_covers, trace, custom_position_slots, enabled_handlers, forecast, events, and per_entity are excluded from HA recorder writes. Live state, templates, and the Lovelace card are unaffected.

  • Enums consolidated into const.py: enums.py deleted; CoverType, TiltMode, TemperatureSource, PresenceDomain, ClimateStrategy, ControlMethod, and forecast constants moved into const.py. No behavior change.


🧪 Tests

  • ✅ 3,679 tests passing (up from 3,449 in v2.22.0).

Compatibility

  • Home Assistant 2026.3.0+
  • Python 3.11+
  • Breaking change: sensor.<cover>_climate_status state values changed from English strings ("Summer Mode", "Winter Mode", "Intermediate") to slugs (summer_mode, winter_mode, intermediate). Update any automation or template that compared against the old values.

References