mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-29 21:03:40 +00:00
update dev environment
This commit is contained in:
parent
5ee780da87
commit
6040a19136
32 changed files with 479 additions and 173 deletions
|
|
@ -2,6 +2,7 @@
|
|||
"name": "jpawlowski/hass.tibber_prices",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:3.13",
|
||||
"postCreateCommand": "scripts/setup",
|
||||
"postStartCommand": "scripts/motd",
|
||||
"containerEnv": {
|
||||
"PYTHONASYNCIODEBUG": "1"
|
||||
},
|
||||
|
|
@ -90,4 +91,4 @@
|
|||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
21
.github/ISSUE_TEMPLATE/bug.yml
vendored
21
.github/ISSUE_TEMPLATE/bug.yml
vendored
|
|
@ -1,16 +1,31 @@
|
|||
---
|
||||
name: "Bug report"
|
||||
description: "Report a bug with the integration"
|
||||
description: "Report a bug with the custom integration"
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Before you open a new issue, search through the existing issues to see if others have had the same problem.
|
||||
- type: input
|
||||
attributes:
|
||||
label: "Home Assistant version"
|
||||
description: "The version of Home Assistant you are using"
|
||||
placeholder: "2025.1.0"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: "Integration version"
|
||||
description: "The version of this custom integration you are using"
|
||||
placeholder: "1.0.0"
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "System Health details"
|
||||
description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io/more-info/system-health#github-issues)"
|
||||
validations:
|
||||
required: true
|
||||
required: false
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Checklist
|
||||
|
|
@ -52,3 +67,5 @@ body:
|
|||
attributes:
|
||||
label: "Diagnostics dump"
|
||||
description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)"
|
||||
validations:
|
||||
required: false
|
||||
|
|
|
|||
3
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
3
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
|
|
@ -1,6 +1,7 @@
|
|||
---
|
||||
name: "Feature request"
|
||||
description: "Suggest an idea for this project"
|
||||
description: "Suggest an idea for this custom integration"
|
||||
labels: ["Feature request"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
|
|
|||
34
.github/ISSUE_TEMPLATE/pull_request_template.md
vendored
Normal file
34
.github/ISSUE_TEMPLATE/pull_request_template.md
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
## Description
|
||||
|
||||
<!-- Describe your changes in detail -->
|
||||
|
||||
## Related Issue
|
||||
|
||||
<!-- If this PR fixes an issue, please link to the issue here -->
|
||||
|
||||
Fixes #(issue)
|
||||
|
||||
## Type of Change
|
||||
|
||||
<!-- Please delete options that are not relevant -->
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] Documentation update
|
||||
- [ ] Code quality improvements
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have read the [CONTRIBUTING](CONTRIBUTING.md) document
|
||||
- [ ] My code follows the code style of this project (run `scripts/lint`)
|
||||
- [ ] I have updated the documentation accordingly
|
||||
- [ ] I have updated the translations if needed
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- Please describe how you tested your changes -->
|
||||
|
||||
## Screenshots (if applicable)
|
||||
|
||||
<!-- Add screenshots to help explain your changes -->
|
||||
15
.github/workflows/lint.yml
vendored
15
.github/workflows/lint.yml
vendored
|
|
@ -22,13 +22,14 @@ jobs:
|
|||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d # v7.1.0
|
||||
with:
|
||||
version: "0.9.3"
|
||||
|
||||
- name: Install requirements
|
||||
run: python3 -m pip install -r requirements.txt
|
||||
run: scripts/bootstrap
|
||||
|
||||
- name: Lint
|
||||
run: python3 -m ruff check .
|
||||
|
||||
- name: Format
|
||||
run: python3 -m ruff format . --check
|
||||
- name: Lint check
|
||||
run: scripts/lint-check
|
||||
|
|
|
|||
4
.github/workflows/validate.yml
vendored
4
.github/workflows/validate.yml
vendored
|
|
@ -3,7 +3,7 @@ name: Validate
|
|||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
- cron: "0 0 * * *"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
|
@ -32,5 +32,5 @@ jobs:
|
|||
uses: hacs/action@d556e736723344f83838d08488c983a15381059a # 22.5.0
|
||||
with:
|
||||
category: integration
|
||||
# Remove this 'ignore' key when you have added brand images for your integration to https://github.com/home-assistant/brands
|
||||
# Remove this 'ignore' key when you have added brand images for your custom integration to https://github.com/home-assistant/brands
|
||||
ignore: brands
|
||||
|
|
|
|||
18
.gitignore
vendored
18
.gitignore
vendored
|
|
@ -4,6 +4,7 @@ __pycache__
|
|||
*.egg-info
|
||||
*/build/*
|
||||
*/dist/*
|
||||
.venv
|
||||
|
||||
|
||||
# misc
|
||||
|
|
@ -11,8 +12,23 @@ __pycache__
|
|||
.vscode
|
||||
coverage.xml
|
||||
.ruff_cache
|
||||
uv.lock
|
||||
|
||||
|
||||
# Home Assistant configuration
|
||||
config/*
|
||||
!config/configuration.yaml
|
||||
!config/configuration.yaml
|
||||
|
||||
# Home Assistant database and logs
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.log
|
||||
home-assistant.log*
|
||||
home-assistant_v2.db*
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
|
|
|||
31
.ruff.toml
31
.ruff.toml
|
|
@ -1,31 +0,0 @@
|
|||
# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml
|
||||
|
||||
target-version = "py313"
|
||||
line-length = 120
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
"ALL",
|
||||
]
|
||||
|
||||
ignore = [
|
||||
"ANN101", # Missing type annotation for `self` in method
|
||||
"ANN401", # Dynamically typed expressions (typing.Any) are disallowed
|
||||
"D203", # no-blank-line-before-class (incompatible with formatter)
|
||||
"D212", # multi-line-summary-first-line (incompatible with formatter)
|
||||
"COM812", # incompatible with formatter
|
||||
"ISC001", # incompatible with formatter
|
||||
]
|
||||
|
||||
[lint.flake8-pytest-style]
|
||||
fixture-parentheses = false
|
||||
|
||||
[lint.isort]
|
||||
force-single-line = false
|
||||
known-first-party = ["custom_components", "homeassistant"]
|
||||
|
||||
[lint.pyupgrade]
|
||||
keep-runtime-typing = true
|
||||
|
||||
[lint.mccabe]
|
||||
max-complexity = 25
|
||||
123
CONTRIBUTING.md
123
CONTRIBUTING.md
|
|
@ -1,61 +1,62 @@
|
|||
# Contribution guidelines
|
||||
|
||||
Contributing to this project should be as easy and transparent as possible, whether it's:
|
||||
|
||||
- Reporting a bug
|
||||
- Discussing the current state of the code
|
||||
- Submitting a fix
|
||||
- Proposing new features
|
||||
|
||||
## Github is used for everything
|
||||
|
||||
Github is used to host code, to track issues and feature requests, as well as accept pull requests.
|
||||
|
||||
Pull requests are the best way to propose changes to the codebase.
|
||||
|
||||
1. Fork the repo and create your branch from `main`.
|
||||
2. If you've changed something, update the documentation.
|
||||
3. Make sure your code lints (using `scripts/lint`).
|
||||
4. Test you contribution.
|
||||
5. Issue that pull request!
|
||||
|
||||
## Any contributions you make will be under the MIT Software License
|
||||
|
||||
In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern.
|
||||
|
||||
## Report bugs using Github's [issues](../../issues)
|
||||
|
||||
GitHub issues are used to track public bugs.
|
||||
Report a bug by [opening a new issue](../../issues/new/choose); it's that easy!
|
||||
|
||||
## Write bug reports with detail, background, and sample code
|
||||
|
||||
**Great Bug Reports** tend to have:
|
||||
|
||||
- A quick summary and/or background
|
||||
- Steps to reproduce
|
||||
- Be specific!
|
||||
- Give sample code if you can.
|
||||
- What you expected would happen
|
||||
- What actually happens
|
||||
- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work)
|
||||
|
||||
People *love* thorough bug reports. I'm not even kidding.
|
||||
|
||||
## Use a Consistent Coding Style
|
||||
|
||||
Use [black](https://github.com/ambv/black) to make sure the code follows the style.
|
||||
|
||||
## Test your code modification
|
||||
|
||||
This custom component is based on [integration_blueprint template](https://github.com/ludeeus/integration_blueprint).
|
||||
|
||||
It comes with development environment in a container, easy to launch
|
||||
if you use Visual Studio Code. With this container you will have a stand alone
|
||||
Home Assistant instance running and already configured with the included
|
||||
[`configuration.yaml`](./config/configuration.yaml)
|
||||
file.
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under its MIT License.
|
||||
# Contribution guidelines
|
||||
|
||||
Contributing to this project should be as easy and transparent as possible, whether it's:
|
||||
|
||||
- Reporting a bug
|
||||
- Discussing the current state of the code
|
||||
- Submitting a fix
|
||||
- Proposing new features
|
||||
|
||||
## Github is used for everything
|
||||
|
||||
Github is used to host code, to track issues and feature requests, as well as accept pull requests.
|
||||
|
||||
Pull requests are the best way to propose changes to the codebase.
|
||||
|
||||
1. Fork the repo and create your branch from `main`.
|
||||
2. Run `scripts/bootstrap` to install dependencies and pre-commit hooks.
|
||||
3. If you've changed something, update the documentation.
|
||||
4. Make sure your code lints (using `scripts/lint`).
|
||||
5. Test your contribution.
|
||||
6. Issue that pull request!
|
||||
|
||||
## Any contributions you make will be under the MIT Software License
|
||||
|
||||
In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern.
|
||||
|
||||
## Report bugs using Github's [issues](../../issues)
|
||||
|
||||
GitHub issues are used to track public bugs.
|
||||
Report a bug by [opening a new issue](../../issues/new/choose); it's that easy!
|
||||
|
||||
## Write bug reports with detail, background, and sample code
|
||||
|
||||
**Great Bug Reports** tend to have:
|
||||
|
||||
- A quick summary and/or background
|
||||
- Steps to reproduce
|
||||
- Be specific!
|
||||
- Give sample code if you can.
|
||||
- What you expected would happen
|
||||
- What actually happens
|
||||
- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work)
|
||||
|
||||
People *love* thorough bug reports. I'm not even kidding.
|
||||
|
||||
## Use a Consistent Coding Style
|
||||
|
||||
Use [Ruff](https://github.com/astral-sh/ruff) for linting and formatting. Run `scripts/lint` to format your code before submitting.
|
||||
|
||||
## Test your code modification
|
||||
|
||||
This custom component is based on [integration_blueprint template](https://github.com/ludeeus/integration_blueprint).
|
||||
|
||||
It comes with development environment in a container, easy to launch
|
||||
if you use Visual Studio Code. With this container you will have a stand alone
|
||||
Home Assistant instance running and already configured with the included
|
||||
[`configuration.yaml`](./config/configuration.yaml)
|
||||
file.
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under its MIT License.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ script:
|
|||
|
||||
scene:
|
||||
|
||||
# https://www.home-assistant.io/integrations/logger/
|
||||
logger:
|
||||
default: info
|
||||
logs:
|
||||
|
|
|
|||
|
|
@ -16,7 +16,12 @@ from homeassistant.helpers.storage import Store
|
|||
from homeassistant.loader import async_get_loaded_integration
|
||||
|
||||
from .api import TibberPricesApiClient
|
||||
from .const import DOMAIN, LOGGER, async_load_standard_translations, async_load_translations
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
async_load_standard_translations,
|
||||
async_load_translations,
|
||||
)
|
||||
from .coordinator import STORAGE_VERSION, TibberPricesDataUpdateCoordinator
|
||||
from .data import TibberPricesData
|
||||
from .services import async_setup_services
|
||||
|
|
@ -88,7 +93,12 @@ async def async_unload_entry(
|
|||
|
||||
# Unregister services if this was the last config entry
|
||||
if not hass.config_entries.async_entries(DOMAIN):
|
||||
for service in ["get_price", "get_apexcharts_data", "get_apexcharts_yaml", "refresh_user_data"]:
|
||||
for service in [
|
||||
"get_price",
|
||||
"get_apexcharts_data",
|
||||
"get_apexcharts_yaml",
|
||||
"refresh_user_data",
|
||||
]:
|
||||
if hass.services.has_service(DOMAIN, service):
|
||||
hass.services.async_remove(DOMAIN, service)
|
||||
|
||||
|
|
|
|||
|
|
@ -114,7 +114,11 @@ async def _verify_graphql_response(response_json: dict, query_type: QueryType) -
|
|||
if error_code in ["RATE_LIMITED", "TOO_MANY_REQUESTS"]:
|
||||
# Some GraphQL APIs return rate limit info in extensions
|
||||
retry_after = extensions.get("retryAfter", "unknown")
|
||||
_LOGGER.warning("Tibber API rate limited via GraphQL: %s (retry after %s)", message, retry_after)
|
||||
_LOGGER.warning(
|
||||
"Tibber API rate limited via GraphQL: %s (retry after %s)",
|
||||
message,
|
||||
retry_after,
|
||||
)
|
||||
raise TibberPricesApiClientError(
|
||||
TibberPricesApiClientError.RATE_LIMIT_ERROR.format(retry_after=retry_after)
|
||||
)
|
||||
|
|
@ -178,7 +182,10 @@ def _is_data_empty(data: dict, query_type: str) -> bool:
|
|||
)
|
||||
is_empty = not has_user_id or not has_homes
|
||||
_LOGGER.debug(
|
||||
"Viewer check - has_user_id: %s, has_homes: %s, is_empty: %s", has_user_id, has_homes, is_empty
|
||||
"Viewer check - has_user_id: %s, has_homes: %s, is_empty: %s",
|
||||
has_user_id,
|
||||
has_homes,
|
||||
is_empty,
|
||||
)
|
||||
|
||||
elif query_type == "price_info":
|
||||
|
|
@ -617,7 +624,8 @@ class TibberPricesApiClient:
|
|||
|
||||
except TimeoutError as error:
|
||||
_LOGGER.exception(
|
||||
"Request timeout after %d seconds - slow network or server overload", self._request_timeout
|
||||
"Request timeout after %d seconds - slow network or server overload",
|
||||
self._request_timeout,
|
||||
)
|
||||
raise TibberPricesApiClientCommunicationError(
|
||||
TibberPricesApiClientCommunicationError.TIMEOUT_ERROR.format(exception=str(error))
|
||||
|
|
@ -714,7 +722,13 @@ class TibberPricesApiClient:
|
|||
return False, 0
|
||||
|
||||
# Non-retryable errors - authentication and permission issues
|
||||
if isinstance(error, (TibberPricesApiClientAuthenticationError, TibberPricesApiClientPermissionError)):
|
||||
if isinstance(
|
||||
error,
|
||||
(
|
||||
TibberPricesApiClientAuthenticationError,
|
||||
TibberPricesApiClientPermissionError,
|
||||
),
|
||||
):
|
||||
return False, 0
|
||||
|
||||
# Handle API-specific errors
|
||||
|
|
|
|||
|
|
@ -585,7 +585,11 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
|
|||
|
||||
# Add basic description
|
||||
description = await async_get_entity_description(
|
||||
self.hass, "binary_sensor", self.entity_description.translation_key, language, "description"
|
||||
self.hass,
|
||||
"binary_sensor",
|
||||
self.entity_description.translation_key,
|
||||
language,
|
||||
"description",
|
||||
)
|
||||
if description:
|
||||
attributes["description"] = description
|
||||
|
|
@ -611,7 +615,11 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
|
|||
|
||||
# Add usage tips if available
|
||||
usage_tips = await async_get_entity_description(
|
||||
self.hass, "binary_sensor", self.entity_description.translation_key, language, "usage_tips"
|
||||
self.hass,
|
||||
"binary_sensor",
|
||||
self.entity_description.translation_key,
|
||||
language,
|
||||
"usage_tips",
|
||||
)
|
||||
if usage_tips:
|
||||
attributes["usage_tips"] = usage_tips
|
||||
|
|
@ -649,7 +657,10 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
|
|||
|
||||
# Add basic description from cache
|
||||
description = get_entity_description(
|
||||
"binary_sensor", self.entity_description.translation_key, language, "description"
|
||||
"binary_sensor",
|
||||
self.entity_description.translation_key,
|
||||
language,
|
||||
"description",
|
||||
)
|
||||
if description:
|
||||
attributes["description"] = description
|
||||
|
|
@ -664,14 +675,20 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
|
|||
if extended_descriptions:
|
||||
# Add long description if available in cache
|
||||
long_desc = get_entity_description(
|
||||
"binary_sensor", self.entity_description.translation_key, language, "long_description"
|
||||
"binary_sensor",
|
||||
self.entity_description.translation_key,
|
||||
language,
|
||||
"long_description",
|
||||
)
|
||||
if long_desc:
|
||||
attributes["long_description"] = long_desc
|
||||
|
||||
# Add usage tips if available in cache
|
||||
usage_tips = get_entity_description(
|
||||
"binary_sensor", self.entity_description.translation_key, language, "usage_tips"
|
||||
"binary_sensor",
|
||||
self.entity_description.translation_key,
|
||||
language,
|
||||
"usage_tips",
|
||||
)
|
||||
if usage_tips:
|
||||
attributes["usage_tips"] = usage_tips
|
||||
|
|
|
|||
|
|
@ -71,7 +71,10 @@ class TibberPricesFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||
|
||||
@classmethod
|
||||
@callback
|
||||
def async_get_supported_subentry_types(cls, config_entry: ConfigEntry) -> dict[str, type[ConfigSubentryFlow]]: # noqa: ARG003
|
||||
def async_get_supported_subentry_types(
|
||||
cls,
|
||||
config_entry: ConfigEntry, # noqa: ARG003
|
||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||
"""Return subentries supported by this integration."""
|
||||
return {"home": TibberPricesSubentryFlowHandler}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,10 @@ from .const import (
|
|||
DEFAULT_PRICE_RATING_THRESHOLD_LOW,
|
||||
DOMAIN,
|
||||
)
|
||||
from .price_utils import enrich_price_info_with_differences, find_price_data_for_interval
|
||||
from .price_utils import (
|
||||
enrich_price_info_with_differences,
|
||||
find_price_data_for_interval,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -377,7 +380,10 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||
self._cached_user_data = user_data
|
||||
self._last_user_update = current_time
|
||||
_LOGGER.debug("User data updated successfully")
|
||||
except (TibberPricesApiClientError, TibberPricesApiClientCommunicationError) as ex:
|
||||
except (
|
||||
TibberPricesApiClientError,
|
||||
TibberPricesApiClientCommunicationError,
|
||||
) as ex:
|
||||
_LOGGER.warning("Failed to update user data: %s", ex)
|
||||
|
||||
@callback
|
||||
|
|
|
|||
|
|
@ -1,38 +1,47 @@
|
|||
"""Diagnostics support for tibber_prices."""
|
||||
"""
|
||||
Diagnostics support for tibber_prices.
|
||||
|
||||
Learn more about diagnostics:
|
||||
https://developers.home-assistant.io/docs/core/integration_diagnostics
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
TO_REDACT = {"access_token"}
|
||||
from .data import TibberPricesConfigEntry
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(hass: HomeAssistant, entry: ConfigEntry) -> dict[str, Any]:
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, # noqa: ARG001
|
||||
entry: TibberPricesConfigEntry,
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id].coordinator
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
|
||||
return {
|
||||
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
|
||||
"coordinator_data": coordinator.data,
|
||||
"last_update_success": coordinator.last_update_success,
|
||||
"update_timestamps": {
|
||||
"price": coordinator.last_price_update.isoformat() if coordinator.last_price_update else None,
|
||||
"hourly_rating": coordinator.last_rating_update_hourly.isoformat()
|
||||
if coordinator.last_rating_update_hourly
|
||||
else None,
|
||||
"daily_rating": coordinator.last_rating_update_daily.isoformat()
|
||||
if coordinator.last_rating_update_daily
|
||||
else None,
|
||||
"monthly_rating": coordinator.last_rating_update_monthly.isoformat()
|
||||
if coordinator.last_rating_update_monthly
|
||||
else None,
|
||||
"entry": {
|
||||
"entry_id": entry.entry_id,
|
||||
"version": entry.version,
|
||||
"minor_version": entry.minor_version,
|
||||
"domain": entry.domain,
|
||||
"title": entry.title,
|
||||
"state": str(entry.state),
|
||||
},
|
||||
"coordinator": {
|
||||
"last_update_success": coordinator.last_update_success,
|
||||
"update_interval": str(coordinator.update_interval),
|
||||
"is_main_entry": coordinator.is_main_entry(),
|
||||
"data": coordinator.data,
|
||||
"update_timestamps": {
|
||||
"price": coordinator._last_price_update.isoformat() if coordinator._last_price_update else None, # noqa: SLF001
|
||||
"user": coordinator._last_user_update.isoformat() if coordinator._last_user_update else None, # noqa: SLF001
|
||||
},
|
||||
},
|
||||
"error": {
|
||||
"last_exception": str(coordinator.last_exception) if coordinator.last_exception else None,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,12 @@ class TibberPricesEntity(CoordinatorEntity[TibberPricesDataUpdateCoordinator]):
|
|||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, coordinator.config_entry.unique_id or coordinator.config_entry.entry_id)},
|
||||
identifiers={
|
||||
(
|
||||
DOMAIN,
|
||||
coordinator.config_entry.unique_id or coordinator.config_entry.entry_id,
|
||||
)
|
||||
},
|
||||
name=home_name,
|
||||
manufacturer="Tibber",
|
||||
model=translated_model,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,13 @@ from homeassistant.components.sensor import (
|
|||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import CURRENCY_EURO, PERCENTAGE, EntityCategory, UnitOfPower, UnitOfTime
|
||||
from homeassistant.const import (
|
||||
CURRENCY_EURO,
|
||||
PERCENTAGE,
|
||||
EntityCategory,
|
||||
UnitOfPower,
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
|
|
@ -239,10 +245,14 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
"highest_price_today": lambda: self._get_statistics_value(stat_func=max, in_euro=False, decimals=2),
|
||||
"highest_price_today_eur": lambda: self._get_statistics_value(stat_func=max, in_euro=True, decimals=4),
|
||||
"average_price_today": lambda: self._get_statistics_value(
|
||||
stat_func=lambda prices: sum(prices) / len(prices), in_euro=False, decimals=2
|
||||
stat_func=lambda prices: sum(prices) / len(prices),
|
||||
in_euro=False,
|
||||
decimals=2,
|
||||
),
|
||||
"average_price_today_eur": lambda: self._get_statistics_value(
|
||||
stat_func=lambda prices: sum(prices) / len(prices), in_euro=True, decimals=4
|
||||
stat_func=lambda prices: sum(prices) / len(prices),
|
||||
in_euro=True,
|
||||
decimals=4,
|
||||
),
|
||||
# Rating sensors
|
||||
"price_rating": lambda: self._get_rating_value(rating_type="current"),
|
||||
|
|
@ -357,7 +367,11 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
return None
|
||||
|
||||
def _get_statistics_value(
|
||||
self, *, stat_func: Callable[[list[float]], float], in_euro: bool, decimals: int | None = None
|
||||
self,
|
||||
*,
|
||||
stat_func: Callable[[list[float]], float],
|
||||
in_euro: bool,
|
||||
decimals: int | None = None,
|
||||
) -> float | None:
|
||||
"""
|
||||
Handle statistics sensor values using the provided statistical function.
|
||||
|
|
@ -794,7 +808,10 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
if self.entity_description.key == "price_level" and current_interval_data and "level" in current_interval_data:
|
||||
self._add_price_level_attributes(attributes, current_interval_data["level"])
|
||||
|
||||
if self.entity_description.key in ["next_interval_price", "next_interval_price_eur"]:
|
||||
if self.entity_description.key in [
|
||||
"next_interval_price",
|
||||
"next_interval_price_eur",
|
||||
]:
|
||||
price_info = self.coordinator.data.get("priceInfo", {})
|
||||
now = dt_util.now()
|
||||
next_interval_time = now + timedelta(minutes=MINUTES_PER_INTERVAL)
|
||||
|
|
@ -815,7 +832,12 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
attributes["level_id"] = level
|
||||
|
||||
def _find_price_timestamp(
|
||||
self, attributes: dict, price_info: Any, day_key: str, target_hour: int, target_date: date
|
||||
self,
|
||||
attributes: dict,
|
||||
price_info: Any,
|
||||
day_key: str,
|
||||
target_hour: int,
|
||||
target_date: date,
|
||||
) -> None:
|
||||
"""Find a price timestamp for a specific hour and date."""
|
||||
for price_data in price_info.get(day_key, []):
|
||||
|
|
@ -842,7 +864,12 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
if hasattr(self, "_last_rating_level") and self._last_rating_level is not None:
|
||||
attributes["level_id"] = self._last_rating_level
|
||||
attributes["level_value"] = PRICE_RATING_MAPPING.get(self._last_rating_level, self._last_rating_level)
|
||||
elif key in ["lowest_price_today", "lowest_price_today_eur", "highest_price_today", "highest_price_today_eur"]:
|
||||
elif key in [
|
||||
"lowest_price_today",
|
||||
"lowest_price_today_eur",
|
||||
"highest_price_today",
|
||||
"highest_price_today_eur",
|
||||
]:
|
||||
# Use the timestamp from the interval that has the extreme price (already stored during value calculation)
|
||||
if hasattr(self, "_last_extreme_interval") and self._last_extreme_interval:
|
||||
attributes["timestamp"] = self._last_extreme_interval.get("startsAt")
|
||||
|
|
|
|||
|
|
@ -336,7 +336,10 @@ def _get_entry_and_data(hass: HomeAssistant, entry_id: str) -> tuple[Any, Any, d
|
|||
"""Validate entry and extract coordinator and data."""
|
||||
if not entry_id:
|
||||
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="missing_entry_id")
|
||||
entry = next((e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id == entry_id), None)
|
||||
entry = next(
|
||||
(e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id == entry_id),
|
||||
None,
|
||||
)
|
||||
if not entry or not hasattr(entry, "runtime_data") or not entry.runtime_data:
|
||||
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="invalid_entry_id")
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
|
|
|
|||
|
|
@ -1,10 +1,37 @@
|
|||
# pyproject.toml
|
||||
[build-system]
|
||||
requires = ["setuptools==78.1.1"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.black]
|
||||
[project]
|
||||
name = "tibber_prices"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[tool.ruff]
|
||||
# Based on https://github.com/home-assistant/core/blob/dev/pyproject.toml
|
||||
target-version = "py313"
|
||||
line-length = 120
|
||||
target-version = ['py313']
|
||||
skip-string-normalization = false
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
line_length = 120
|
||||
[tool.ruff.lint]
|
||||
select = ["ALL"]
|
||||
ignore = [
|
||||
# "ANN101", # Missing type annotation for `self` in method
|
||||
"ANN401", # Dynamically typed expressions (typing.Any) are disallowed
|
||||
"D203", # no-blank-line-before-class (incompatible with formatter)
|
||||
"D212", # multi-line-summary-first-line (incompatible with formatter)
|
||||
"COM812", # incompatible with formatter
|
||||
"ISC001", # incompatible with formatter
|
||||
]
|
||||
|
||||
[tool.ruff.lint.flake8-pytest-style]
|
||||
fixture-parentheses = false
|
||||
|
||||
[tool.ruff.lint.pyupgrade]
|
||||
keep-runtime-typing = true
|
||||
|
||||
[tool.ruff.lint.mccabe]
|
||||
max-complexity = 25
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
force-single-line = false
|
||||
known-first-party = ["custom_components", "homeassistant"]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
colorlog>=6.9.0,<7.0.0
|
||||
homeassistant>=2025.10.0,<2025.11.0
|
||||
colorlog>=6.10.1,<6.11.0
|
||||
homeassistant>=2025.6.0,<2025.7.0
|
||||
pytest-homeassistant-custom-component>=0.13.0,<0.14.0
|
||||
pip>=21.3.1
|
||||
ruff>=0.11.6,<0.15.0
|
||||
pre-commit>=4.3.0,<4.4.0
|
||||
ruff>=0.14.1,<0.15.0
|
||||
|
|
|
|||
25
scripts/bootstrap
Executable file
25
scripts/bootstrap
Executable file
|
|
@ -0,0 +1,25 @@
|
|||
#!/bin/sh
|
||||
|
||||
# script/bootstrap: Install/update all dependencies required to run the project
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "==> Updating system packages..."
|
||||
sudo apt-get update
|
||||
sudo apt-get upgrade -y
|
||||
|
||||
echo "==> Checking for uv..."
|
||||
if ! command -v uv >/dev/null 2>&1; then
|
||||
echo "UV not found, installing..."
|
||||
pipx install uv
|
||||
fi
|
||||
|
||||
echo "==> Installing dependencies..."
|
||||
python3 -m pip install --requirement requirements.txt
|
||||
|
||||
echo "==> Installing pre-commit hooks..."
|
||||
pre-commit install
|
||||
|
||||
echo "==> Bootstrap completed!"
|
||||
39
scripts/help
Executable file
39
scripts/help
Executable file
|
|
@ -0,0 +1,39 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# script/help: Display information about available scripts.
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
REPO_NAME=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || basename "$(pwd)")
|
||||
|
||||
printf "\033[1m%s\033[36m %s\033[32m %s\033[0m \n\n" "Development environment for" "$REPO_NAME" ""
|
||||
|
||||
echo "Available scripts:"
|
||||
echo ""
|
||||
|
||||
find scripts -type f -perm -111 -print0 | sort -z | while IFS= read -r -d '' script; do
|
||||
script_name=$(basename "$script")
|
||||
description=$(awk -v prefix="# script/$script_name:" '
|
||||
BEGIN {desc=""}
|
||||
$0 ~ prefix {
|
||||
line = $0
|
||||
sub(prefix, "", line)
|
||||
sub(/^# */, "", line)
|
||||
desc = desc (desc ? " " : "") line
|
||||
next
|
||||
}
|
||||
desc != "" {exit}
|
||||
END {print desc}
|
||||
' "$script")
|
||||
if [ -z "$description" ]; then
|
||||
description="No description available"
|
||||
fi
|
||||
if [ ${#description} -gt 60 ]; then
|
||||
description=$(echo "$description" | cut -c1-57)...
|
||||
fi
|
||||
printf " \033[36m %-25s\033[0m %s\n" "scripts/$script_name" "$description"
|
||||
done
|
||||
|
||||
echo ""
|
||||
15
scripts/lint
15
scripts/lint
|
|
@ -1,8 +1,17 @@
|
|||
#!/usr/bin/env bash
|
||||
#!/bin/sh
|
||||
|
||||
# script/lint: Run linting tools and apply formatting
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
ruff format .
|
||||
ruff check . --fix
|
||||
echo "==> Running linting tools..."
|
||||
|
||||
echo "==> Running Ruff format..."
|
||||
uv run ruff format .
|
||||
|
||||
echo "==> Running Ruff check..."
|
||||
uv run ruff check . --fix
|
||||
|
||||
echo "==> Linting completed!"
|
||||
|
|
|
|||
15
scripts/lint-check
Executable file
15
scripts/lint-check
Executable file
|
|
@ -0,0 +1,15 @@
|
|||
#!/bin/sh
|
||||
|
||||
# script/lint-check: Check linting without making changes.
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "==> Checking code format..."
|
||||
uv run ruff format . --check
|
||||
|
||||
echo "==> Checking code with Ruff..."
|
||||
uv run ruff check .
|
||||
|
||||
echo "==> Linting check completed!"
|
||||
24
scripts/motd
Executable file
24
scripts/motd
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
#/bin/sh
|
||||
|
||||
# script/motd: Display Message of the Day for development environment
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
REPO_NAME=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || basename "$(pwd)")
|
||||
|
||||
echo ""
|
||||
echo "🎉 Welcome to the $REPO_NAME development environment!"
|
||||
echo ""
|
||||
echo "📂 Project: $(pwd)"
|
||||
echo "🌿 Git branch: $(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo 'Not a git repo')"
|
||||
echo "🐍 Python: $(python3 --version 2>/dev/null || echo 'Not available')"
|
||||
echo ""
|
||||
|
||||
scripts/help
|
||||
|
||||
echo ""
|
||||
echo "💡 Tip: Run 'scripts/develop' to start Home Assistant with your custom integration."
|
||||
echo " Access it at http://localhost:8123"
|
||||
echo ""
|
||||
|
|
@ -4,4 +4,7 @@ set -e
|
|||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
python3 -m pip install --requirement requirements.txt
|
||||
#python3 -m pip install --requirement requirements.txt
|
||||
scripts/bootstrap
|
||||
|
||||
echo "==> Project is now ready to go!"
|
||||
|
|
|
|||
11
scripts/update
Executable file
11
scripts/update
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
#!/bin/sh
|
||||
|
||||
# script/update: Update project after a fresh pull
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
scripts/bootstrap
|
||||
|
||||
echo "==> Update completed!"
|
||||
|
|
@ -4,7 +4,9 @@ from unittest.mock import AsyncMock, Mock, patch
|
|||
|
||||
import pytest
|
||||
|
||||
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
|
||||
from custom_components.tibber_prices.coordinator import (
|
||||
TibberPricesDataUpdateCoordinator,
|
||||
)
|
||||
|
||||
|
||||
class TestBasicCoordinator:
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import pytest
|
|||
|
||||
from custom_components.tibber_prices.api import TibberPricesApiClientCommunicationError
|
||||
from custom_components.tibber_prices.const import DOMAIN
|
||||
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
|
||||
from custom_components.tibber_prices.coordinator import (
|
||||
TibberPricesDataUpdateCoordinator,
|
||||
)
|
||||
|
||||
|
||||
class TestEnhancedCoordinator:
|
||||
|
|
@ -63,7 +65,10 @@ class TestEnhancedCoordinator:
|
|||
"custom_components.tibber_prices.coordinator.aiohttp_client.async_get_clientsession",
|
||||
return_value=mock_session,
|
||||
),
|
||||
patch("custom_components.tibber_prices.coordinator.Store", return_value=mock_store),
|
||||
patch(
|
||||
"custom_components.tibber_prices.coordinator.Store",
|
||||
return_value=mock_store,
|
||||
),
|
||||
):
|
||||
coordinator = TibberPricesDataUpdateCoordinator(
|
||||
hass=mock_hass,
|
||||
|
|
@ -88,7 +93,10 @@ class TestEnhancedCoordinator:
|
|||
"custom_components.tibber_prices.coordinator.aiohttp_client.async_get_clientsession",
|
||||
return_value=mock_session,
|
||||
),
|
||||
patch("custom_components.tibber_prices.coordinator.Store", return_value=mock_store),
|
||||
patch(
|
||||
"custom_components.tibber_prices.coordinator.Store",
|
||||
return_value=mock_store,
|
||||
),
|
||||
):
|
||||
main_coordinator = TibberPricesDataUpdateCoordinator(
|
||||
hass=mock_hass,
|
||||
|
|
@ -112,7 +120,10 @@ class TestEnhancedCoordinator:
|
|||
"custom_components.tibber_prices.coordinator.aiohttp_client.async_get_clientsession",
|
||||
return_value=mock_session,
|
||||
),
|
||||
patch("custom_components.tibber_prices.coordinator.Store", return_value=mock_store),
|
||||
patch(
|
||||
"custom_components.tibber_prices.coordinator.Store",
|
||||
return_value=mock_store,
|
||||
),
|
||||
):
|
||||
sub_coordinator = TibberPricesDataUpdateCoordinator(
|
||||
hass=mock_hass,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ from __future__ import annotations
|
|||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
|
||||
from custom_components.tibber_prices.coordinator import (
|
||||
TibberPricesDataUpdateCoordinator,
|
||||
)
|
||||
|
||||
# Constants for test validation
|
||||
INTERVALS_PER_DAY = 96
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from custom_components.tibber_prices.price_utils import enrich_price_info_with_differences
|
||||
from custom_components.tibber_prices.price_utils import (
|
||||
enrich_price_info_with_differences,
|
||||
)
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
# Constants for integration testing
|
||||
|
|
|
|||
Loading…
Reference in a new issue