Skip to content

You can now set activation hours from service call#32

Merged
jyourstone merged 2 commits intomainfrom
new/service_override
Mar 21, 2026
Merged

You can now set activation hours from service call#32
jyourstone merged 2 commits intomainfrom
new/service_override

Conversation

@jyourstone
Copy link
Copy Markdown
Owner

@jyourstone jyourstone commented Mar 21, 2026

Closes #29

Summary by CodeRabbit

  • New Features

    • Dynamic hours override: set or clear per-device scheduled hours at runtime with immediate recomputation
    • New services to apply and remove schedule overrides; active override exposed as a status attribute
  • Documentation

    • README updated with service docs and an example automation for temperature-based schedule adjustments
  • Tests

    • Added tests covering override lifecycle, persistence, sensor attributes, and service resolution

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 21, 2026

Walkthrough

Adds a runtime "Dynamic hours override" to the power_saver integration. Introduces two services — power_saver.set_schedule_hours (requires device_id and hours, applies and persists an override and recomputes the schedule) and power_saver.clear_schedule_hours_override (requires device_id, clears the override and recomputes). Adds coordinator APIs and persistence for hours_override, exposes schedule_hours_override in sensor attributes when active, updates service definitions, strings/translations, tests, and README documentation.

Suggested labels

enhancement

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'You can now set activation hours from service call' is clear, concise, and directly describes the main feature added in this PR.
Linked Issues check ✅ Passed The PR fully implements the requested feature from issue #29: allows setting activation hours (0-24) via service calls, eliminating the need for multiple entities.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing the service call override feature for activation hours, with no unrelated modifications.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch new/service_override

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

You can generate walkthrough in a markdown collapsible section to save space.

Enable the reviews.collapse_walkthrough setting to generate walkthrough in a markdown collapsible section.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
custom_components/power_saver/__init__.py (1)

89-100: Consider wrapping _find_coordinator in try/except for better error messages.

The service handlers call _find_coordinator which raises ValueError on invalid device IDs. While this will fail the service call (correct behavior), wrapping it in a try/except and raising HomeAssistantError would provide a cleaner error to users via the UI.

♻️ Optional improvement for better UX
+from homeassistant.exceptions import HomeAssistantError
+
         async def handle_set_schedule_hours(call: ServiceCall) -> None:
             """Handle the set_schedule_hours service call."""
             device_id = call.data[ATTR_DEVICE_ID]
             hours = call.data[ATTR_HOURS]
-            coord = _find_coordinator(hass, device_id)
+            try:
+                coord = _find_coordinator(hass, device_id)
+            except ValueError as err:
+                raise HomeAssistantError(str(err)) from err
             await coord.async_set_hours_override(hours)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@custom_components/power_saver/__init__.py` around lines 89 - 100, Wrap calls
to _find_coordinator in both service handlers (handle_set_schedule_hours and
handle_clear_override) in a try/except that catches ValueError and raises
homeassistant.exceptions.HomeAssistantError with a clear, user-facing message
(include the device_id in the message for context); ensure HomeAssistantError is
imported at the top of the module if not already. This converts the raw
ValueError into a clean Home Assistant service error while preserving the
original failure behavior.
tests/test_services.py (2)

86-111: These async test functions don't use await.

test_hours_override_included_in_fingerprint and test_hours_override_property are marked as async but don't contain any await statements. They should be regular synchronous test functions.

♻️ Proposed fix
-async def test_hours_override_included_in_fingerprint():
+def test_hours_override_included_in_fingerprint():
     """Test that changing hours_override changes the options fingerprint."""
-def test_hours_override_property():
+def test_hours_override_property():
     """Test the hours_override property returns current value."""

(Note: test_hours_override_property is already sync—only test_hours_override_included_in_fingerprint needs the change)

Also applies to: 166-181

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_services.py` around lines 86 - 111, The test function
test_hours_override_included_in_fingerprint is declared async but contains no
await calls — make it a regular synchronous test by changing "async def
test_hours_override_included_in_fingerprint()" to a normal "def" function; keep
the rest of the body (patching DataUpdateCoordinator.__init__, creating
PowerSaverCoordinator via __new__, setting coordinator._hours_override and
calling coordinator._compute_options_fingerprint()) unchanged. Note that
test_hours_override_property is already synchronous and does not need
modification.

17-21: This test doesn't validate real behavior.

The test creates a MagicMock, manually sets _hours_override = None, then asserts it's None. This doesn't test any actual coordinator logic—it only tests that Python assignment works.

Consider either removing this test or testing the actual coordinator initialization (which your other tests already cover better via __new__ pattern).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_services.py` around lines 17 - 21, The test
test_hours_override_default_is_none is invalid because it only uses a MagicMock
and assigns _hours_override manually; either remove this test or replace it to
exercise real coordinator behavior: instantiate a real PowerSaverCoordinator
(not a MagicMock) or use the existing __new__-based initialization used
elsewhere, then assert the coordinator._hours_override is None to validate
default initialization of PowerSaverCoordinator._hours_override.
custom_components/power_saver/coordinator.py (1)

547-550: Consider validating hours_override type on restore.

The restored value isn't validated. If storage is corrupted with an invalid type (e.g., a string), it could cause runtime issues when used in schedule computation. As per coding guidelines, defensive parsing should be applied.

♻️ Proposed defensive validation
             hours_override = data.get("hours_override")
-            if hours_override is not None:
-                self._hours_override = hours_override
-                _LOGGER.info("Restored hours_override: %s", hours_override)
+            if hours_override is not None:
+                try:
+                    self._hours_override = float(hours_override)
+                    if not 0 <= self._hours_override <= 24:
+                        _LOGGER.warning(
+                            "Restored hours_override %s out of range, ignoring",
+                            hours_override,
+                        )
+                        self._hours_override = None
+                    else:
+                        _LOGGER.info("Restored hours_override: %s", self._hours_override)
+                except (ValueError, TypeError):
+                    _LOGGER.warning(
+                        "Invalid hours_override in storage: %s, ignoring", hours_override
+                    )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@custom_components/power_saver/coordinator.py` around lines 547 - 550, The
restored hours_override value is not validated and may be an invalid type from
corrupted storage; update the restore logic around the hours_override variable
to defensively validate before assigning to self._hours_override: check the type
(e.g., isinstance(hours_override, int) or number as appropriate for your
domain), ensure it falls within acceptable bounds if applicable, and only then
set self._hours_override; otherwise log a warning via _LOGGER (include the bad
value) and ignore/reset to a safe default (None or previous value).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@custom_components/power_saver/__init__.py`:
- Around line 131-135: The code currently removes services even if unload_ok is
False; change the condition to only remove the services when the unload
succeeded and the integration state is gone by updating the check to: if
unload_ok and not hass.data.get(DOMAIN): then call hass.services.async_remove
for SERVICE_SET_SCHEDULE_HOURS and SERVICE_CLEAR_SCHEDULE_HOURS_OVERRIDE (ensure
you reference the existing unload_ok variable and the constants DOMAIN,
SERVICE_SET_SCHEDULE_HOURS, SERVICE_CLEAR_SCHEDULE_HOURS_OVERRIDE).

---

Nitpick comments:
In `@custom_components/power_saver/__init__.py`:
- Around line 89-100: Wrap calls to _find_coordinator in both service handlers
(handle_set_schedule_hours and handle_clear_override) in a try/except that
catches ValueError and raises homeassistant.exceptions.HomeAssistantError with a
clear, user-facing message (include the device_id in the message for context);
ensure HomeAssistantError is imported at the top of the module if not already.
This converts the raw ValueError into a clean Home Assistant service error while
preserving the original failure behavior.

In `@custom_components/power_saver/coordinator.py`:
- Around line 547-550: The restored hours_override value is not validated and
may be an invalid type from corrupted storage; update the restore logic around
the hours_override variable to defensively validate before assigning to
self._hours_override: check the type (e.g., isinstance(hours_override, int) or
number as appropriate for your domain), ensure it falls within acceptable bounds
if applicable, and only then set self._hours_override; otherwise log a warning
via _LOGGER (include the bad value) and ignore/reset to a safe default (None or
previous value).

In `@tests/test_services.py`:
- Around line 86-111: The test function
test_hours_override_included_in_fingerprint is declared async but contains no
await calls — make it a regular synchronous test by changing "async def
test_hours_override_included_in_fingerprint()" to a normal "def" function; keep
the rest of the body (patching DataUpdateCoordinator.__init__, creating
PowerSaverCoordinator via __new__, setting coordinator._hours_override and
calling coordinator._compute_options_fingerprint()) unchanged. Note that
test_hours_override_property is already synchronous and does not need
modification.
- Around line 17-21: The test test_hours_override_default_is_none is invalid
because it only uses a MagicMock and assigns _hours_override manually; either
remove this test or replace it to exercise real coordinator behavior:
instantiate a real PowerSaverCoordinator (not a MagicMock) or use the existing
__new__-based initialization used elsewhere, then assert the
coordinator._hours_override is None to validate default initialization of
PowerSaverCoordinator._hours_override.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 96ccd20d-a074-42d9-a51f-e75a5ee6f637

📥 Commits

Reviewing files that changed from the base of the PR and between 13a575c and cddcf1b.

📒 Files selected for processing (11)
  • README.md
  • custom_components/power_saver/__init__.py
  • custom_components/power_saver/const.py
  • custom_components/power_saver/coordinator.py
  • custom_components/power_saver/sensor.py
  • custom_components/power_saver/services.yaml
  • custom_components/power_saver/strings.json
  • custom_components/power_saver/translations/en.json
  • custom_components/power_saver/translations/sv.json
  • tests/test_sensor.py
  • tests/test_services.py

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
custom_components/power_saver/__init__.py (1)

94-100: Consider logging warnings before raising HomeAssistantError.

The error is caught but not logged before re-raising. As per coding guidelines, defensive error handling should include _LOGGER.warning() to aid debugging.

♻️ Proposed fix
             try:
                 coord = _find_coordinator(hass, device_id)
             except ValueError as err:
+                _LOGGER.warning(
+                    "Failed to find coordinator for device %s: %s", device_id, err
+                )
                 raise HomeAssistantError(
                     f"Device {device_id} not found or not a Power Saver device"
                 ) from err

Apply similar logging to handle_clear_override (lines 105-110).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@custom_components/power_saver/__init__.py` around lines 94 - 100, The
try/except around _find_coordinator in the code path that calls
coord.async_set_hours_override(hours) should log a warning before re-raising
HomeAssistantError: add a _LOGGER.warning call in the except ValueError as err
block that includes the device_id and err (or err.args) for context, then raise
the HomeAssistantError from err as currently done; apply the same change to the
matching error handling in handle_clear_override so both blocks log warnings
before raising.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@custom_components/power_saver/__init__.py`:
- Around line 94-100: The try/except around _find_coordinator in the code path
that calls coord.async_set_hours_override(hours) should log a warning before
re-raising HomeAssistantError: add a _LOGGER.warning call in the except
ValueError as err block that includes the device_id and err (or err.args) for
context, then raise the HomeAssistantError from err as currently done; apply the
same change to the matching error handling in handle_clear_override so both
blocks log warnings before raising.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6bd0b108-3dec-4a8c-9c2d-32c8a33f8112

📥 Commits

Reviewing files that changed from the base of the PR and between cddcf1b and ecd75c1.

📒 Files selected for processing (3)
  • custom_components/power_saver/__init__.py
  • custom_components/power_saver/coordinator.py
  • tests/test_services.py
✅ Files skipped from review due to trivial changes (1)
  • tests/test_services.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • custom_components/power_saver/coordinator.py

@jyourstone jyourstone merged commit 2c4ddbe into main Mar 21, 2026
3 checks passed
@jyourstone jyourstone deleted the new/service_override branch March 21, 2026 12:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Update from service call

1 participant