update dev environment

This commit is contained in:
Julian Pawlowski 2025-11-03 15:54:01 +00:00
parent 5ee780da87
commit 6040a19136
32 changed files with 479 additions and 173 deletions

View file

@ -2,6 +2,7 @@
"name": "jpawlowski/hass.tibber_prices", "name": "jpawlowski/hass.tibber_prices",
"image": "mcr.microsoft.com/devcontainers/python:3.13", "image": "mcr.microsoft.com/devcontainers/python:3.13",
"postCreateCommand": "scripts/setup", "postCreateCommand": "scripts/setup",
"postStartCommand": "scripts/motd",
"containerEnv": { "containerEnv": {
"PYTHONASYNCIODEBUG": "1" "PYTHONASYNCIODEBUG": "1"
}, },

View file

@ -1,16 +1,31 @@
--- ---
name: "Bug report" name: "Bug report"
description: "Report a bug with the integration" description: "Report a bug with the custom integration"
labels: ["bug"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: Before you open a new issue, search through the existing issues to see if others have had the same problem. 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 - type: textarea
attributes: attributes:
label: "System Health details" 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)" description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io/more-info/system-health#github-issues)"
validations: validations:
required: true required: false
- type: checkboxes - type: checkboxes
attributes: attributes:
label: Checklist label: Checklist
@ -52,3 +67,5 @@ body:
attributes: attributes:
label: "Diagnostics dump" label: "Diagnostics dump"
description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)" description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)"
validations:
required: false

View file

@ -1,6 +1,7 @@
--- ---
name: "Feature request" name: "Feature request"
description: "Suggest an idea for this project" description: "Suggest an idea for this custom integration"
labels: ["Feature request"]
body: body:
- type: markdown - type: markdown
attributes: attributes:

View 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 -->

View file

@ -22,13 +22,14 @@ jobs:
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with: with:
python-version: "3.13" 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 - name: Install requirements
run: python3 -m pip install -r requirements.txt run: scripts/bootstrap
- name: Lint - name: Lint check
run: python3 -m ruff check . run: scripts/lint-check
- name: Format
run: python3 -m ruff format . --check

View file

@ -32,5 +32,5 @@ jobs:
uses: hacs/action@d556e736723344f83838d08488c983a15381059a # 22.5.0 uses: hacs/action@d556e736723344f83838d08488c983a15381059a # 22.5.0
with: with:
category: integration 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 ignore: brands

16
.gitignore vendored
View file

@ -4,6 +4,7 @@ __pycache__
*.egg-info *.egg-info
*/build/* */build/*
*/dist/* */dist/*
.venv
# misc # misc
@ -11,8 +12,23 @@ __pycache__
.vscode .vscode
coverage.xml coverage.xml
.ruff_cache .ruff_cache
uv.lock
# Home Assistant configuration # Home Assistant configuration
config/* 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

View file

@ -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

View file

@ -14,10 +14,11 @@ Github is used to host code, to track issues and feature requests, as well as ac
Pull requests are the best way to propose changes to the codebase. Pull requests are the best way to propose changes to the codebase.
1. Fork the repo and create your branch from `main`. 1. Fork the repo and create your branch from `main`.
2. If you've changed something, update the documentation. 2. Run `scripts/bootstrap` to install dependencies and pre-commit hooks.
3. Make sure your code lints (using `scripts/lint`). 3. If you've changed something, update the documentation.
4. Test you contribution. 4. Make sure your code lints (using `scripts/lint`).
5. Issue that pull request! 5. Test your contribution.
6. Issue that pull request!
## Any contributions you make will be under the MIT Software License ## Any contributions you make will be under the MIT Software License
@ -44,7 +45,7 @@ People *love* thorough bug reports. I'm not even kidding.
## Use a Consistent Coding Style ## Use a Consistent Coding Style
Use [black](https://github.com/ambv/black) to make sure the code follows the 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 ## Test your code modification

View file

@ -12,6 +12,7 @@ script:
scene: scene:
# https://www.home-assistant.io/integrations/logger/
logger: logger:
default: info default: info
logs: logs:

View file

@ -16,7 +16,12 @@ from homeassistant.helpers.storage import Store
from homeassistant.loader import async_get_loaded_integration from homeassistant.loader import async_get_loaded_integration
from .api import TibberPricesApiClient 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 .coordinator import STORAGE_VERSION, TibberPricesDataUpdateCoordinator
from .data import TibberPricesData from .data import TibberPricesData
from .services import async_setup_services from .services import async_setup_services
@ -88,7 +93,12 @@ async def async_unload_entry(
# Unregister services if this was the last config entry # Unregister services if this was the last config entry
if not hass.config_entries.async_entries(DOMAIN): 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): if hass.services.has_service(DOMAIN, service):
hass.services.async_remove(DOMAIN, service) hass.services.async_remove(DOMAIN, service)

View file

@ -114,7 +114,11 @@ async def _verify_graphql_response(response_json: dict, query_type: QueryType) -
if error_code in ["RATE_LIMITED", "TOO_MANY_REQUESTS"]: if error_code in ["RATE_LIMITED", "TOO_MANY_REQUESTS"]:
# Some GraphQL APIs return rate limit info in extensions # Some GraphQL APIs return rate limit info in extensions
retry_after = extensions.get("retryAfter", "unknown") 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( raise TibberPricesApiClientError(
TibberPricesApiClientError.RATE_LIMIT_ERROR.format(retry_after=retry_after) 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 is_empty = not has_user_id or not has_homes
_LOGGER.debug( _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": elif query_type == "price_info":
@ -617,7 +624,8 @@ class TibberPricesApiClient:
except TimeoutError as error: except TimeoutError as error:
_LOGGER.exception( _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( raise TibberPricesApiClientCommunicationError(
TibberPricesApiClientCommunicationError.TIMEOUT_ERROR.format(exception=str(error)) TibberPricesApiClientCommunicationError.TIMEOUT_ERROR.format(exception=str(error))
@ -714,7 +722,13 @@ class TibberPricesApiClient:
return False, 0 return False, 0
# Non-retryable errors - authentication and permission issues # Non-retryable errors - authentication and permission issues
if isinstance(error, (TibberPricesApiClientAuthenticationError, TibberPricesApiClientPermissionError)): if isinstance(
error,
(
TibberPricesApiClientAuthenticationError,
TibberPricesApiClientPermissionError,
),
):
return False, 0 return False, 0
# Handle API-specific errors # Handle API-specific errors

View file

@ -585,7 +585,11 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
# Add basic description # Add basic description
description = await async_get_entity_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: if description:
attributes["description"] = description attributes["description"] = description
@ -611,7 +615,11 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
# Add usage tips if available # Add usage tips if available
usage_tips = await async_get_entity_description( 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: if usage_tips:
attributes["usage_tips"] = usage_tips attributes["usage_tips"] = usage_tips
@ -649,7 +657,10 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
# Add basic description from cache # Add basic description from cache
description = get_entity_description( description = get_entity_description(
"binary_sensor", self.entity_description.translation_key, language, "description" "binary_sensor",
self.entity_description.translation_key,
language,
"description",
) )
if description: if description:
attributes["description"] = description attributes["description"] = description
@ -664,14 +675,20 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
if extended_descriptions: if extended_descriptions:
# Add long description if available in cache # Add long description if available in cache
long_desc = get_entity_description( 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: if long_desc:
attributes["long_description"] = long_desc attributes["long_description"] = long_desc
# Add usage tips if available in cache # Add usage tips if available in cache
usage_tips = get_entity_description( 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: if usage_tips:
attributes["usage_tips"] = usage_tips attributes["usage_tips"] = usage_tips

View file

@ -71,7 +71,10 @@ class TibberPricesFlowHandler(ConfigFlow, domain=DOMAIN):
@classmethod @classmethod
@callback @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 subentries supported by this integration."""
return {"home": TibberPricesSubentryFlowHandler} return {"home": TibberPricesSubentryFlowHandler}

View file

@ -30,7 +30,10 @@ from .const import (
DEFAULT_PRICE_RATING_THRESHOLD_LOW, DEFAULT_PRICE_RATING_THRESHOLD_LOW,
DOMAIN, 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__) _LOGGER = logging.getLogger(__name__)
@ -377,7 +380,10 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._cached_user_data = user_data self._cached_user_data = user_data
self._last_user_update = current_time self._last_user_update = current_time
_LOGGER.debug("User data updated successfully") _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) _LOGGER.warning("Failed to update user data: %s", ex)
@callback @callback

View file

@ -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 __future__ import annotations
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from homeassistant.components.diagnostics import async_redact_data
from .const import DOMAIN
if TYPE_CHECKING: if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant 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.""" """Return diagnostics for a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id].coordinator coordinator = entry.runtime_data.coordinator
return { return {
"entry": async_redact_data(entry.as_dict(), TO_REDACT), "entry": {
"coordinator_data": coordinator.data, "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, "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": { "update_timestamps": {
"price": coordinator.last_price_update.isoformat() if coordinator.last_price_update else None, "price": coordinator._last_price_update.isoformat() if coordinator._last_price_update else None, # noqa: SLF001
"hourly_rating": coordinator.last_rating_update_hourly.isoformat() "user": coordinator._last_user_update.isoformat() if coordinator._last_user_update else None, # noqa: SLF001
if coordinator.last_rating_update_hourly },
else None, },
"daily_rating": coordinator.last_rating_update_daily.isoformat() "error": {
if coordinator.last_rating_update_daily "last_exception": str(coordinator.last_exception) if coordinator.last_exception else None,
else None,
"monthly_rating": coordinator.last_rating_update_monthly.isoformat()
if coordinator.last_rating_update_monthly
else None,
}, },
} }

View file

@ -77,7 +77,12 @@ class TibberPricesEntity(CoordinatorEntity[TibberPricesDataUpdateCoordinator]):
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE, 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, name=home_name,
manufacturer="Tibber", manufacturer="Tibber",
model=translated_model, model=translated_model,

View file

@ -10,7 +10,13 @@ from homeassistant.components.sensor import (
SensorEntity, SensorEntity,
SensorEntityDescription, 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 homeassistant.util import dt as dt_util
from .const import ( 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": 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), "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( "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( "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 # Rating sensors
"price_rating": lambda: self._get_rating_value(rating_type="current"), "price_rating": lambda: self._get_rating_value(rating_type="current"),
@ -357,7 +367,11 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
return None return None
def _get_statistics_value( 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: ) -> float | None:
""" """
Handle statistics sensor values using the provided statistical function. 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: 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"]) 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", {}) price_info = self.coordinator.data.get("priceInfo", {})
now = dt_util.now() now = dt_util.now()
next_interval_time = now + timedelta(minutes=MINUTES_PER_INTERVAL) next_interval_time = now + timedelta(minutes=MINUTES_PER_INTERVAL)
@ -815,7 +832,12 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
attributes["level_id"] = level attributes["level_id"] = level
def _find_price_timestamp( 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: ) -> None:
"""Find a price timestamp for a specific hour and date.""" """Find a price timestamp for a specific hour and date."""
for price_data in price_info.get(day_key, []): 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: if hasattr(self, "_last_rating_level") and self._last_rating_level is not None:
attributes["level_id"] = self._last_rating_level attributes["level_id"] = self._last_rating_level
attributes["level_value"] = PRICE_RATING_MAPPING.get(self._last_rating_level, 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) # 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: if hasattr(self, "_last_extreme_interval") and self._last_extreme_interval:
attributes["timestamp"] = self._last_extreme_interval.get("startsAt") attributes["timestamp"] = self._last_extreme_interval.get("startsAt")

View file

@ -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.""" """Validate entry and extract coordinator and data."""
if not entry_id: if not entry_id:
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="missing_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: if not entry or not hasattr(entry, "runtime_data") or not entry.runtime_data:
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="invalid_entry_id") raise ServiceValidationError(translation_domain=DOMAIN, translation_key="invalid_entry_id")
coordinator = entry.runtime_data.coordinator coordinator = entry.runtime_data.coordinator

View file

@ -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 line-length = 120
target-version = ['py313']
skip-string-normalization = false
[tool.isort] [tool.ruff.lint]
profile = "black" select = ["ALL"]
line_length = 120 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"]

View file

@ -1,5 +1,6 @@
colorlog>=6.9.0,<7.0.0 colorlog>=6.10.1,<6.11.0
homeassistant>=2025.10.0,<2025.11.0 homeassistant>=2025.6.0,<2025.7.0
pytest-homeassistant-custom-component>=0.13.0,<0.14.0 pytest-homeassistant-custom-component>=0.13.0,<0.14.0
pip>=21.3.1 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
View 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
View 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 ""

View file

@ -1,8 +1,17 @@
#!/usr/bin/env bash #!/bin/sh
# script/lint: Run linting tools and apply formatting
set -e set -e
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
ruff format . echo "==> Running linting tools..."
ruff check . --fix
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
View 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
View 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 ""

View file

@ -4,4 +4,7 @@ set -e
cd "$(dirname "$0")/.." 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
View 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!"

View file

@ -4,7 +4,9 @@ from unittest.mock import AsyncMock, Mock, patch
import pytest import pytest
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator from custom_components.tibber_prices.coordinator import (
TibberPricesDataUpdateCoordinator,
)
class TestBasicCoordinator: class TestBasicCoordinator:

View file

@ -9,7 +9,9 @@ import pytest
from custom_components.tibber_prices.api import TibberPricesApiClientCommunicationError from custom_components.tibber_prices.api import TibberPricesApiClientCommunicationError
from custom_components.tibber_prices.const import DOMAIN 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: class TestEnhancedCoordinator:
@ -63,7 +65,10 @@ class TestEnhancedCoordinator:
"custom_components.tibber_prices.coordinator.aiohttp_client.async_get_clientsession", "custom_components.tibber_prices.coordinator.aiohttp_client.async_get_clientsession",
return_value=mock_session, 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( coordinator = TibberPricesDataUpdateCoordinator(
hass=mock_hass, hass=mock_hass,
@ -88,7 +93,10 @@ class TestEnhancedCoordinator:
"custom_components.tibber_prices.coordinator.aiohttp_client.async_get_clientsession", "custom_components.tibber_prices.coordinator.aiohttp_client.async_get_clientsession",
return_value=mock_session, 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( main_coordinator = TibberPricesDataUpdateCoordinator(
hass=mock_hass, hass=mock_hass,
@ -112,7 +120,10 @@ class TestEnhancedCoordinator:
"custom_components.tibber_prices.coordinator.aiohttp_client.async_get_clientsession", "custom_components.tibber_prices.coordinator.aiohttp_client.async_get_clientsession",
return_value=mock_session, 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( sub_coordinator = TibberPricesDataUpdateCoordinator(
hass=mock_hass, hass=mock_hass,

View file

@ -5,7 +5,9 @@ from __future__ import annotations
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from unittest.mock import Mock, patch 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 # Constants for test validation
INTERVALS_PER_DAY = 96 INTERVALS_PER_DAY = 96

View file

@ -2,7 +2,9 @@
from datetime import datetime, timedelta 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 from homeassistant.util import dt as dt_util
# Constants for integration testing # Constants for integration testing