├── .gitattributes ├── tests ├── api │ ├── __init__.py │ ├── test_models.py │ ├── test_base.py │ ├── test_planned.py │ └── test_probable.py ├── __init__.py ├── conftest.py ├── test_helpers.py └── test_calendar.py ├── .github ├── FUNDING.yml ├── dependabot.yml ├── workflows │ ├── release-drafter.yml │ ├── validate.yml │ ├── checks.yml │ └── release.yml └── release-drafter.yml ├── icons ├── icon.png ├── logo.png ├── icon@2x.png └── logo@2x.png ├── media ├── 3_group.png ├── 1_region.png ├── 4_devices.png ├── 5_device.png ├── 2_provider.png └── 6_calendars.png ├── scripts ├── setup ├── test ├── lint ├── bootstrap ├── develop └── bump_version ├── hacs.json ├── .vscode ├── tasks.json └── launch.json ├── .gitignore ├── custom_components └── yasno_outages │ ├── manifest.json │ ├── data.py │ ├── api │ ├── models.py │ ├── const.py │ ├── __init__.py │ ├── probable.py │ ├── base.py │ └── planned.py │ ├── entity.py │ ├── helpers.py │ ├── const.py │ ├── repairs.py │ ├── diagnostics.py │ ├── __init__.py │ ├── translations │ ├── en.json │ ├── uk.json │ └── nl.json │ ├── sensor.py │ ├── calendar.py │ ├── config_flow.py │ └── coordinator.py ├── config └── configuration.yaml ├── examples ├── dashboard.yaml └── automation.yaml ├── .pre-commit-config.yaml ├── license.md ├── contributing.md ├── .devcontainer.json ├── pyproject.toml ├── readme.md ├── readme.uk.md └── agents.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /tests/api/__init__.py: -------------------------------------------------------------------------------- 1 | """API tests package.""" 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for Yasno Outages integration.""" 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: denysdovhan 2 | buy_me_a_coffee: denysdovhan 3 | -------------------------------------------------------------------------------- /icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/ha-yasno-outages/HEAD/icons/icon.png -------------------------------------------------------------------------------- /icons/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/ha-yasno-outages/HEAD/icons/logo.png -------------------------------------------------------------------------------- /icons/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/ha-yasno-outages/HEAD/icons/icon@2x.png -------------------------------------------------------------------------------- /icons/logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/ha-yasno-outages/HEAD/icons/logo@2x.png -------------------------------------------------------------------------------- /media/3_group.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/ha-yasno-outages/HEAD/media/3_group.png -------------------------------------------------------------------------------- /media/1_region.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/ha-yasno-outages/HEAD/media/1_region.png -------------------------------------------------------------------------------- /media/4_devices.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/ha-yasno-outages/HEAD/media/4_devices.png -------------------------------------------------------------------------------- /media/5_device.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/ha-yasno-outages/HEAD/media/5_device.png -------------------------------------------------------------------------------- /media/2_provider.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/ha-yasno-outages/HEAD/media/2_provider.png -------------------------------------------------------------------------------- /media/6_calendars.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denysdovhan/ha-yasno-outages/HEAD/media/6_calendars.png -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | scripts/bootstrap 8 | 9 | echo "==> Project is now ready to go!" 10 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Yasno Outages", 3 | "homeassistant": "2024.6.0", 4 | "country": "UA", 5 | "render_readme": true, 6 | "zip_release": true, 7 | "filename": "yasno-outages.zip" 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant on port 8123", 6 | "type": "shell", 7 | "command": "scripts/develop", 8 | "problemMatcher": [] 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | echo "==> Running tests with coverage..." 8 | uv run pytest tests/ --cov=custom_components.yasno_outages --cov-report=term-missing 9 | 10 | echo "==> Tests completed!" 11 | -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | echo "==> Running Ruff format..." 8 | uv run ruff format . 9 | 10 | echo "==> Running Ruff check..." 11 | uv run ruff check . --fix 12 | 13 | echo "==> Linting completed!" 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # artifacts 2 | __pycache__ 3 | .pytest* 4 | *.egg-info 5 | */build/* 6 | */dist/* 7 | .venv 8 | node_modules 9 | 10 | # misc 11 | .coverage 12 | .vscode/settings.json 13 | coverage.xml 14 | .ruff_cache 15 | 16 | # macOS 17 | .DS_Store 18 | 19 | # Home Assistant configuration 20 | config/* 21 | !config/configuration.yaml 22 | .idea 23 | .cache 24 | -------------------------------------------------------------------------------- /custom_components/yasno_outages/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "yasno_outages", 3 | "name": "Yasno Outages", 4 | "codeowners": ["@denysdovhan"], 5 | "config_flow": true, 6 | "documentation": "https://github.com/denysdovhan/ha-yasno-outages", 7 | "iot_class": "cloud_polling", 8 | "issue_tracker": "https://github.com/denysdovhan/ha-yasno-outages", 9 | "requirements": ["python-dateutil>=2.8.2"], 10 | "version": "0.0.0-unreleased" 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Home Assistant: Attach Local", 6 | "type": "debugpy", 7 | "request": "attach", 8 | "connect": { 9 | "host": "localhost", 10 | "port": 5678 11 | }, 12 | "pathMappings": [ 13 | { 14 | "localRoot": "${workspaceFolder}", 15 | "remoteRoot": "." 16 | } 17 | ] 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: github-actions 5 | directory: / 6 | schedule: 7 | interval: monthly 8 | 9 | # Update Python dependencies 10 | - package-ecosystem: uv 11 | directory: / 12 | schedule: 13 | interval: monthly 14 | 15 | # Update Dev Container definitions 16 | - package-ecosystem: "devcontainers" 17 | directory: "/" 18 | schedule: 19 | interval: monthly 20 | -------------------------------------------------------------------------------- /scripts/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # script/bootstrap: Install/update all dependencies required to run the project 4 | 5 | set -e 6 | 7 | cd "$(dirname "$0")/.." 8 | 9 | echo "==> Checking for uv..." 10 | if ! command -v uv >/dev/null 2>&1; then 11 | echo "uv not found, installing with pipx..." 12 | pipx install uv 13 | fi 14 | 15 | echo "==> Installing dependencies (including dev)..." 16 | uv sync --dev 17 | 18 | echo "==> Installing pre-commit hooks..." 19 | uv run pre-commit install 20 | 21 | echo "==> Bootstrap completed!" 22 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | draft_release: 14 | name: Draft Release 15 | permissions: 16 | contents: write 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: 📝 Draft Release 20 | id: release_drafter 21 | uses: release-drafter/release-drafter@v6 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | -------------------------------------------------------------------------------- /config/configuration.yaml: -------------------------------------------------------------------------------- 1 | # Default configuration 2 | # https://www.home-assistant.io/integrations/default_config/ 3 | default_config: 4 | 5 | # Remote Python Debugger 6 | # https://www.home-assistant.io/integrations/debugpy/ 7 | debugpy: 8 | 9 | # Logger 10 | # https://www.home-assistant.io/integrations/logger/ 11 | logger: 12 | default: info 13 | logs: 14 | custom_components.yasno_outages: debug 15 | filters: 16 | homeassistant.components.go2rtc: 17 | - "Could not find go2rtc docker binary" 18 | homeassistant.setup: 19 | - ".*go2rtc.*" 20 | -------------------------------------------------------------------------------- /scripts/develop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | # Create config dir if not present 8 | if [[ ! -d "${PWD}/config" ]]; then 9 | mkdir -p "${PWD}/config" 10 | hass --config "${PWD}/config" --script ensure_config 11 | fi 12 | 13 | # Set the path to custom_components 14 | ## This let's us have the structure we want /custom_components/integration_blueprint 15 | ## while at the same time have Home Assistant configuration inside /config 16 | ## without resulting to symlinks. 17 | export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" 18 | 19 | # Start Home Assistant 20 | uv run hass --config "${PWD}/config" --debug 21 | -------------------------------------------------------------------------------- /custom_components/yasno_outages/data.py: -------------------------------------------------------------------------------- 1 | """Custom types for yasno_outages.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | from typing import TYPE_CHECKING 7 | 8 | if TYPE_CHECKING: 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.loader import Integration 11 | 12 | from .api import YasnoApi 13 | from .coordinator import YasnoOutagesCoordinator 14 | 15 | 16 | type YasnoOutagesConfigEntry = ConfigEntry[YasnoOutagesData] 17 | 18 | 19 | @dataclass 20 | class YasnoOutagesData: 21 | """Data for the Yasno Outages integration.""" 22 | 23 | api: YasnoApi 24 | coordinator: YasnoOutagesCoordinator 25 | integration: Integration 26 | -------------------------------------------------------------------------------- /examples/dashboard.yaml: -------------------------------------------------------------------------------- 1 | type: grid 2 | cards: 3 | - type: entities 4 | entities: 5 | - entity: calendar.yasno_kiiv_dtek_3_1_outages_calendar 6 | - entity: sensor.yasno_kiiv_dtek_3_1_electricity 7 | - entity: sensor.yasno_kiiv_dtek_3_1_next_outage 8 | - entity: sensor.yasno_kiiv_dtek_3_1_next_possible_outage 9 | - entity: sensor.yasno_kiiv_dtek_3_1_next_connectivity 10 | - entity: sensor.yasno_kiiv_dtek_3_1_schedule_updated_on 11 | - type: history-graph 12 | entities: 13 | - entity: binary_sensor.power 14 | name: Reality 15 | - entity: sensor.yasno_kiiv_dtek_3_1_electricity 16 | name: Theory 17 | hours_to_show: 18 18 | - initial_view: listWeek 19 | type: calendar 20 | entities: 21 | - calendar.yasno_kiiv_dtek_3_1_outages_calendar 22 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | schedule: 8 | - cron: 0 0 * * * 9 | 10 | jobs: 11 | hassfest: # https://developers.home-assistant.io/blog/2020/04/16/hassfest 12 | name: Hassfest Validation 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: ⬇️ Checkout Repo 16 | uses: actions/checkout@v6 17 | 18 | - name: ✅ Run hassfest validation 19 | uses: home-assistant/actions/hassfest@master 20 | 21 | hacs: # https://github.com/hacs/action 22 | name: HACS Validation 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: ⬇️ Checkout Repo 26 | uses: actions/checkout@v6 27 | 28 | - name: ✅ Run HACS validation 29 | uses: hacs/action@main 30 | with: 31 | category: integration 32 | -------------------------------------------------------------------------------- /scripts/bump_version: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Update the version in the manifest file.""" 3 | 4 | import json 5 | import sys 6 | from pathlib import Path 7 | 8 | MANIFEST = "custom_components/yasno_outages/manifest.json" 9 | 10 | 11 | def main(version: str) -> None: 12 | """Update the version in the manifest file.""" 13 | # Read the manifest file 14 | with Path.open(MANIFEST) as file: 15 | data = json.load(file) 16 | 17 | # Update the version 18 | data["version"] = version.replace("v", "") 19 | 20 | # Write the manifest file 21 | with Path.open(MANIFEST, "w") as file: 22 | json.dump(data, file, indent=2) 23 | 24 | 25 | if __name__ == "__main__": 26 | if len(sys.argv) > 1: 27 | version = sys.argv[1] 28 | main(version) 29 | else: 30 | print("Please provide a version argument.") # noqa: T201 31 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: Checks 2 | 3 | on: 4 | push: 5 | paths: 6 | - "**.py" 7 | - pyproject.toml 8 | - uv.lock 9 | pull_request: 10 | paths: 11 | - "**.py" 12 | - pyproject.toml 13 | - uv.lock 14 | 15 | jobs: 16 | checks: 17 | name: Checks 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: ⬇️ Checkout Repo 21 | uses: actions/checkout@v6 22 | 23 | - name: 🐍 Set up Python 24 | uses: actions/setup-python@v6 25 | with: 26 | python-version: "3.x" 27 | 28 | - name: 📦 Install uv 29 | uses: astral-sh/setup-uv@v7 30 | with: 31 | enable-cache: true 32 | 33 | - name: 📦 Install dependencies 34 | run: uv sync --locked --all-extras --dev 35 | 36 | - name: ⚙️ Lint 37 | run: uv run pre-commit run --all-files 38 | 39 | - name: 🧪 Run tests 40 | run: uv run pytest tests 41 | -------------------------------------------------------------------------------- /custom_components/yasno_outages/api/models.py: -------------------------------------------------------------------------------- 1 | """Data models for Yasno outages API.""" 2 | 3 | import datetime 4 | from dataclasses import dataclass 5 | from enum import StrEnum 6 | 7 | 8 | class OutageEventType(StrEnum): 9 | """Outage event types.""" 10 | 11 | DEFINITE = "Definite" 12 | NOT_PLANNED = "NotPlanned" 13 | 14 | 15 | class OutageSource(StrEnum): 16 | """Source type for outage events.""" 17 | 18 | PLANNED = "planned" 19 | PROBABLE = "probable" 20 | 21 | 22 | @dataclass(frozen=True) 23 | class OutageEvent: 24 | """Represents an outage event.""" 25 | 26 | event_type: OutageEventType 27 | start: datetime.datetime 28 | end: datetime.datetime 29 | source: OutageSource 30 | 31 | 32 | @dataclass(frozen=True) 33 | class OutageSlot: 34 | """Represents an outage time slot template.""" 35 | 36 | start: int # Minutes from midnight 37 | end: int # Minutes from midnight 38 | event_type: OutageEventType 39 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: check-ast 7 | - id: check-builtin-literals 8 | - id: check-case-conflict 9 | - id: check-docstring-first 10 | - id: check-executables-have-shebangs 11 | - id: check-json 12 | - id: check-shebang-scripts-are-executable 13 | - id: check-symlinks 14 | - id: check-toml 15 | - id: check-yaml 16 | - id: debug-statements 17 | - id: destroyed-symlinks 18 | - id: end-of-file-fixer 19 | - id: mixed-line-ending 20 | - id: requirements-txt-fixer 21 | - id: trailing-whitespace 22 | 23 | - repo: https://github.com/astral-sh/ruff-pre-commit 24 | rev: v0.14.1 25 | hooks: 26 | - id: ruff 27 | args: [--fix] 28 | - id: ruff-format 29 | 30 | - repo: https://github.com/pre-commit/mirrors-prettier 31 | rev: "v4.0.0-alpha.8" 32 | hooks: 33 | - id: prettier 34 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: ⬇️ Checkout Repo 16 | uses: actions/checkout@v6 17 | 18 | - name: 🐍 Set up Python 19 | uses: actions/setup-python@v6 20 | with: 21 | python-version: "3.x" 22 | 23 | - name: 🏷️ Bump manifest version 24 | run: python scripts/bump_version ${{ github.ref_name }} 25 | 26 | - name: 📦 Create zip package 27 | working-directory: custom_components/yasno_outages 28 | run: zip -r yasno-outages.zip . 29 | 30 | - name: 📤 Upload zip to release 31 | uses: svenstaro/upload-release-action@v2 32 | with: 33 | repo_token: ${{ secrets.GITHUB_TOKEN }} 34 | file: custom_components/yasno_outages/yasno-outages.zip 35 | asset_name: yasno-outages.zip 36 | tag: ${{ github.ref }} 37 | overwrite: true 38 | -------------------------------------------------------------------------------- /custom_components/yasno_outages/api/const.py: -------------------------------------------------------------------------------- 1 | """API constants for Yasno outages.""" 2 | 3 | from typing import Final 4 | 5 | # Event names 6 | EVENT_NAME_OUTAGE: Final = "Definite" 7 | EVENT_NAME_NOT_PLANNED: Final = "NotPlanned" 8 | 9 | # API Endpoints 10 | REGIONS_ENDPOINT: Final = ( 11 | "https://app.yasno.ua/api/blackout-service/public/shutdowns/addresses/v2/regions" 12 | ) 13 | PLANNED_OUTAGES_ENDPOINT: Final = "https://app.yasno.ua/api/blackout-service/public/shutdowns/regions/{region_id}/dsos/{dso_id}/planned-outages" 14 | PROBABLE_OUTAGES_ENDPOINT: Final = "https://app.yasno.ua/api/blackout-service/public/shutdowns/probable-outages?regionId={region_id}&dsoId={dso_id}" 15 | 16 | # API Status values 17 | API_STATUS_SCHEDULE_APPLIES: Final = "ScheduleApplies" 18 | API_STATUS_WAITING_FOR_SCHEDULE: Final = "WaitingForSchedule" 19 | API_STATUS_EMERGENCY_SHUTDOWNS: Final = "EmergencyShutdowns" 20 | 21 | # API Block names 22 | API_KEY_TODAY: Final = "today" 23 | API_KEY_TOMORROW: Final = "tomorrow" 24 | API_KEY_STATUS: Final = "status" 25 | API_KEY_DATE: Final = "date" 26 | API_KEY_UPDATED_ON: Final = "updatedOn" 27 | -------------------------------------------------------------------------------- /custom_components/yasno_outages/entity.py: -------------------------------------------------------------------------------- 1 | """Yasno Outages entity.""" 2 | 3 | from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo 4 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 5 | 6 | from .const import DOMAIN 7 | from .coordinator import YasnoOutagesCoordinator 8 | 9 | 10 | class YasnoOutagesEntity(CoordinatorEntity[YasnoOutagesCoordinator]): 11 | """Common logic for Yasno Outages entity.""" 12 | 13 | _attr_has_entity_name = True 14 | 15 | @property 16 | def device_info(self) -> DeviceInfo: 17 | """Return device information about this entity.""" 18 | return DeviceInfo( 19 | translation_key="yasno_outages", 20 | translation_placeholders={ 21 | "region": self.coordinator.region_name, 22 | "provider": self.coordinator.provider_name, 23 | "group": str(self.coordinator.group), 24 | }, 25 | identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)}, 26 | manufacturer="Yasno", 27 | entry_type=DeviceEntryType.SERVICE, 28 | ) 29 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Denys Dovhan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | If you plan to contribute back to this repo, please fork & open a PR. 4 | 5 | ## How to add translation 6 | 7 | Only native speaker can translate to specific language. 8 | 9 | 1. Copy `custom_components/yasno_outages/translations/en.json` file and name it with appropriate language code. 10 | 1. Translate only keys in this file, not values. 11 | 1. Open a PR. 12 | 1. Find someone to check and approve your PR. 13 | 14 | ## How to run locally 15 | 16 | 1. Clone this repo to wherever you want: 17 | ```sh 18 | git clone https://github.com/denysdovhan/ha-yasno-outages.git 19 | ``` 20 | 2. Go into the repo folder: 21 | ```sh 22 | cd ha-yasno-outages 23 | ``` 24 | 3. Open the project with [VSCode Dev Container](https://code.visualstudio.com/docs/devcontainers/containers) 25 | 4. Start a HA via `Run Home Assistant on port 8123` task or run a following command: 26 | ```sh 27 | scripts/develop 28 | ``` 29 | 30 | Now you you have a working Home Assistant instance with this integration installed. You can test your changes by editing the files in `custom_components/yasno_outages` folder and restarting your Home Assistant instance. 31 | -------------------------------------------------------------------------------- /custom_components/yasno_outages/helpers.py: -------------------------------------------------------------------------------- 1 | """Helper utilities for Yasno outages integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | from .api import OutageEvent 6 | 7 | 8 | def merge_consecutive_outages(events: list[OutageEvent]) -> list[OutageEvent]: 9 | """ 10 | Merge consecutive outage events with identical type/source. 11 | 12 | Expects `events` pre-sorted and already filtered to outage events. 13 | """ 14 | if not events: 15 | return [] 16 | 17 | merged: list[OutageEvent] = [] 18 | current_event = events[0] 19 | 20 | for next_event in events[1:]: 21 | if ( 22 | current_event.end == next_event.start 23 | and current_event.event_type == next_event.event_type 24 | and current_event.source == next_event.source 25 | ): 26 | current_event = OutageEvent( 27 | start=current_event.start, 28 | end=next_event.end, 29 | event_type=current_event.event_type, 30 | source=current_event.source, 31 | ) 32 | else: 33 | merged.append(current_event) 34 | current_event = next_event 35 | 36 | merged.append(current_event) 37 | 38 | return merged 39 | -------------------------------------------------------------------------------- /.devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ha-yasno-outages", 3 | "image": "mcr.microsoft.com/devcontainers/python:3.13", 4 | "postCreateCommand": "scripts/setup", 5 | "updateContentCommand": "uv sync --extra cli", 6 | "forwardPorts": [8123], 7 | "portsAttributes": { 8 | "8123": { 9 | "label": "Home Assistant", 10 | "onAutoForward": "notify" 11 | } 12 | }, 13 | "customizations": { 14 | "vscode": { 15 | "extensions": [ 16 | "ms-python.python", 17 | "ms-python.vscode-pylance", 18 | "ms-python.pylint", 19 | "github.vscode-pull-request-github", 20 | "ryanluker.vscode-coverage-gutters", 21 | "charliermarsh.ruff", 22 | "tamasfe.even-better-toml" 23 | ], 24 | "settings": { 25 | "python.pythonPath": "/usr/bin/python3", 26 | "[python]": { 27 | "editor.formatOnSave": true, 28 | "editor.defaultFormatter": "charliermarsh.ruff" 29 | } 30 | } 31 | } 32 | }, 33 | "remoteUser": "vscode", 34 | "features": { 35 | "ghcr.io/va-h/devcontainers-features/uv:1": {}, 36 | "ghcr.io/devcontainers/features/github-cli:1": {}, 37 | "ghcr.io/devcontainers-extra/features/apt-packages:1": { 38 | "packages": "ffmpeg,libturbojpeg0,libpcap-dev" 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: v$RESOLVED_VERSION ️⚡️ 2 | tag-template: v$RESOLVED_VERSION 3 | exclude-contributors: 4 | - github-actions[bot] 5 | - github-actions 6 | - dependabot[bot] 7 | - dependabot 8 | categories: 9 | - title: ❗️ Breaking Changes 10 | labels: 11 | - breaking 12 | - title: 🚀 Features 13 | labels: 14 | - feature 15 | - enhancement 16 | - title: 🐛 Bug Fixes 17 | labels: 18 | - fix 19 | - bug 20 | - title: 🧰 Maintenance 21 | label: 22 | - chore 23 | - dependencies 24 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)" 25 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 26 | version-resolver: 27 | major: 28 | labels: 29 | - major 30 | - breaking 31 | minor: 32 | labels: 33 | - minor 34 | - feature 35 | - enhancement 36 | patch: 37 | labels: 38 | - patch 39 | - dependencies 40 | default: patch 41 | template: | 42 | ## What's Changed 43 | 44 | $CHANGES 45 | 46 | ## ⭐️ Contributors 47 | 48 | $CONTRIBUTORS 49 | 50 | ## Sponsorship 51 | 52 | If you like this project, please consider supporting me: 53 | 54 | - 💖 [Sponsor on GitHub](https://github.com/sponsors/denysdovhan) 55 | - ☕️ [Buy Me A Coffee](https://buymeacoffee.com/denysdovhan) 56 | - Bitcoin: `bc1q7lfx6de8jrqt8mcds974l6nrsguhd6u30c6sg8` 57 | - Ethereum: `0x6aF39C917359897ae6969Ad682C14110afe1a0a1` 58 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "ha-yasno-outages" 3 | version = "0.0.0" 4 | description = "Home Assistant custom component: Yasno Outages" 5 | readme = "readme.md" 6 | requires-python = ">=3.13.2" 7 | authors = [{ name = "Denys Dovhan", email = "denysdovhan@gmail.com" }] 8 | dependencies = [ 9 | "homeassistant==2025.11.3", 10 | "python-dateutil>=2.8.2", 11 | ] 12 | 13 | [dependency-groups] 14 | dev = [ 15 | "pre-commit>=3.7.1", 16 | "pytest>=8.0.0", 17 | "pytest-asyncio>=0.23.0", 18 | "pytest-cov>=3.0.0", 19 | "pytest-homeassistant-custom-component>=0.13.0", 20 | "ruff==0.14.6", 21 | ] 22 | 23 | [tool.ruff] 24 | target-version = "py313" 25 | 26 | [tool.ruff.lint] 27 | select = ["ALL"] 28 | ignore = [ 29 | "ANN401", # Dynamically typed expressions (typing.Any) are disallowed 30 | "D203", # no-blank-line-before-class (incompatible with formatter) 31 | "D212", # multi-line-summary-first-line (incompatible with formatter) 32 | "COM812", # incompatible with formatter 33 | "ISC001", # incompatible with formatter 34 | "TD002", # todo require author tag 35 | "TD003", # todo should have a link to an issue 36 | "FIX002", # require todo to be resolved 37 | ] 38 | 39 | [tool.ruff.lint.per-file-ignores] 40 | "tests/*" = [ 41 | "S101", # Use of assert detected 42 | "PLR2004", # Magic value used in comparison 43 | "ANN", # Missing type annotations 44 | "SLF001", # Private member access (needed for testing) 45 | "D", # Docstring requirements 46 | "DTZ001", # Datetime without timezone (tests use naive datetimes) 47 | "ERA001", # Commented-out code (test comments are explanatory) 48 | ] 49 | 50 | [tool.ruff.lint.flake8-pytest-style] 51 | fixture-parentheses = false 52 | 53 | [tool.ruff.lint.pyupgrade] 54 | keep-runtime-typing = true 55 | 56 | [tool.ruff.lint.mccabe] 57 | max-complexity = 25 58 | 59 | [tool.pytest.ini_options] 60 | minversion = "8.0" 61 | addopts = "-ra -q" 62 | asyncio_mode = "auto" 63 | asyncio_default_fixture_loop_scope = "function" 64 | -------------------------------------------------------------------------------- /examples/automation.yaml: -------------------------------------------------------------------------------- 1 | alias: Power Outage Starts in 2 | description: "" 3 | triggers: 4 | - event: start 5 | offset: "-2:0:00" 6 | entity_id: calendar.yasno_group_3_1_outages_calendar 7 | trigger: calendar 8 | enabled: false 9 | - event: start 10 | offset: "-1:0:00" 11 | entity_id: calendar.yasno_group_3_1_outages_calendar 12 | trigger: calendar 13 | - event: start 14 | offset: "-0:30:00" 15 | entity_id: calendar.yasno_group_3_1_outages_calendar 16 | trigger: calendar 17 | - event: start 18 | offset: "-0:5:00" 19 | entity_id: calendar.yasno_group_3_1_outages_calendar 20 | trigger: calendar 21 | - event: start 22 | offset: "-0:0:00" 23 | entity_id: calendar.yasno_group_3_1_outages_calendar 24 | trigger: calendar 25 | conditions: 26 | - condition: and 27 | conditions: 28 | - condition: template 29 | value_template: "{{trigger.calendar_event.description == 'Definite'}}" 30 | actions: 31 | - action: tts.speak 32 | target: 33 | entity_id: tts.google_uk_com_ua 34 | data: 35 | media_player_entity_id: media_player.my_speaker 36 | language: ua 37 | message: >- 38 | {% set battery_level = states('sensor.delta_pro_battery_level') | default(-1) | float %} 39 | {% set battery_target = states('number.delta_pro_max_charge_level') | default(100) | float %} 40 | {% set start = trigger.calendar_event.start|as_datetime|as_local %} 41 | {% set start_minutes = (start - now()).seconds // 60 %} 42 | {% set end = trigger.calendar_event.end|as_datetime|as_local %} 43 | {% set duration_hours = (end - start).seconds // 60 // 60 %} 44 | {% set end_str = end|as_timestamp|timestamp_custom('%H:%M') %} 45 | 46 | Відключення електроенергії може відбутися за {{ start_minutes }} хвилин 47 | і продовжиться {{ duration_hours }} години до {{end_str}}. 48 | 49 | {% if start_minutes <= 5 -%} 50 | Не забудьте закип'ятити воду. 51 | {%- endif %} 52 | 53 | {% if battery_level >= 0 and battery_level < battery_target - 15 %} 54 | Рівень заряду екофлоу - {{battery_level | round(0) | int }} відсотків. 55 | {% endif %} 56 | enabled: true 57 | mode: single 58 | -------------------------------------------------------------------------------- /custom_components/yasno_outages/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Yasno Outages integration.""" 2 | 3 | from typing import Final 4 | 5 | DOMAIN: Final = "yasno_outages" 6 | NAME: Final = "Yasno Outages" 7 | 8 | # Configuration option 9 | CONF_REGION: Final = "region" 10 | CONF_PROVIDER: Final = "provider" 11 | CONF_GROUP: Final = "group" 12 | CONF_FILTER_PROBABLE: Final = "filter_probable" 13 | CONF_STATUS_ALL_DAY_EVENTS: Final = "status_all_day_events" 14 | CONF_CITY: Final = "city" # Deprecated, use CONF_REGION 15 | CONF_SERVICE: Final = "service" # Deprecated, use CONF_PROVIDER 16 | 17 | # Provider name simplification 18 | PROVIDER_DTEK_FULL: Final = "ДТЕК КИЇВСЬКІ ЕЛЕКТРОМЕРЕЖІ" 19 | PROVIDER_DTEK_SHORT: Final = "ДТЕК" 20 | 21 | # Consts 22 | UPDATE_INTERVAL: Final = 15 # minutes 23 | 24 | # Horizon constants for event lookahead 25 | PLANNED_OUTAGE_LOOKAHEAD = 1 # day 26 | PROBABLE_OUTAGE_LOOKAHEAD = 7 # days 27 | 28 | # Values 29 | STATE_NORMAL: Final = "normal" 30 | STATE_OUTAGE: Final = "outage" 31 | 32 | # Attribute keys 33 | ATTR_EVENT_TYPE: Final = "event_type" 34 | ATTR_EVENT_START: Final = "event_start" 35 | ATTR_EVENT_END: Final = "event_end" 36 | 37 | # Status states 38 | STATE_STATUS_SCHEDULE_APPLIES: Final = "schedule_applies" 39 | STATE_STATUS_WAITING_FOR_SCHEDULE: Final = "waiting_for_schedule" 40 | STATE_STATUS_EMERGENCY_SHUTDOWNS: Final = "emergency_shutdowns" 41 | 42 | # Keys 43 | TRANSLATION_KEY_EVENT_PLANNED_OUTAGE: Final = ( 44 | f"component.{DOMAIN}.common.planned_electricity_outage" 45 | ) 46 | TRANSLATION_KEY_EVENT_PROBABLE_OUTAGE: Final = ( 47 | f"component.{DOMAIN}.common.probable_electricity_outage" 48 | ) 49 | TRANSLATION_KEY_STATUS_SCHEDULE_APPLIES: Final = ( 50 | f"component.{DOMAIN}.common.status_schedule_applies" 51 | ) 52 | TRANSLATION_KEY_STATUS_WAITING_FOR_SCHEDULE: Final = ( 53 | f"component.{DOMAIN}.common.status_waiting_for_schedule" 54 | ) 55 | TRANSLATION_KEY_STATUS_EMERGENCY_SHUTDOWNS: Final = ( 56 | f"component.{DOMAIN}.common.status_emergency_shutdowns" 57 | ) 58 | # Text fallbacks 59 | PLANNED_OUTAGE_TEXT_FALLBACK: Final = "Planned Outage" 60 | PROBABLE_OUTAGE_TEXT_FALLBACK: Final = "Probable Outage" 61 | STATUS_SCHEDULE_APPLIES_TEXT_FALLBACK: Final = "Schedule Applies" 62 | STATUS_WAITING_FOR_SCHEDULE_TEXT_FALLBACK: Final = "Waiting for Schedule" 63 | STATUS_EMERGENCY_SHUTDOWNS_TEXT_FALLBACK: Final = "Emergency Shutdowns" 64 | -------------------------------------------------------------------------------- /custom_components/yasno_outages/api/__init__.py: -------------------------------------------------------------------------------- 1 | """Yasno Outages API package.""" 2 | 3 | from .models import OutageEvent, OutageEventType, OutageSlot 4 | from .planned import PlannedOutagesApi 5 | from .probable import ProbableOutagesApi 6 | 7 | 8 | class YasnoApi: 9 | """Facade for Yasno API providing access to planned and probable outages.""" 10 | 11 | def __init__( 12 | self, 13 | region_id: int | None = None, 14 | provider_id: int | None = None, 15 | group: str | None = None, 16 | ) -> None: 17 | """Initialize the YasnoApi facade.""" 18 | self._planned = PlannedOutagesApi(region_id, provider_id, group) 19 | self._probable = ProbableOutagesApi(region_id, provider_id, group) 20 | 21 | @property 22 | def planned(self) -> PlannedOutagesApi: 23 | """Get the planned outages API.""" 24 | return self._planned 25 | 26 | @property 27 | def probable(self) -> ProbableOutagesApi: 28 | """Get the probable outages API.""" 29 | return self._probable 30 | 31 | @property 32 | def regions_data(self) -> dict | None: 33 | """Get shared regions data.""" 34 | return self._planned.regions_data 35 | 36 | @regions_data.setter 37 | def regions_data(self, value: dict | None) -> None: 38 | """Set shared regions data for both APIs.""" 39 | self._planned.regions_data = value 40 | self._probable.regions_data = value 41 | 42 | async def fetch_regions(self) -> None: 43 | """Fetch regions data (shared between APIs).""" 44 | await self._planned.fetch_regions() 45 | # Share the regions data with probable API 46 | self._probable.regions_data = self._planned.regions_data 47 | 48 | def get_regions(self) -> list[dict]: 49 | """Get a list of available regions.""" 50 | return self._planned.get_regions() 51 | 52 | def get_region_by_name(self, region_name: str) -> dict | None: 53 | """Get region data by name.""" 54 | return self._planned.get_region_by_name(region_name) 55 | 56 | def get_providers_for_region(self, region_name: str) -> list[dict]: 57 | """Get providers (dsos) for a specific region.""" 58 | return self._planned.get_providers_for_region(region_name) 59 | 60 | def get_provider_by_name( 61 | self, 62 | region_name: str, 63 | provider_name: str, 64 | ) -> dict | None: 65 | """Get provider data by name.""" 66 | return self._planned.get_provider_by_name(region_name, provider_name) 67 | 68 | 69 | __all__ = [ 70 | "OutageEvent", 71 | "OutageEventType", 72 | "OutageSlot", 73 | "PlannedOutagesApi", 74 | "ProbableOutagesApi", 75 | "YasnoApi", 76 | ] 77 | -------------------------------------------------------------------------------- /custom_components/yasno_outages/repairs.py: -------------------------------------------------------------------------------- 1 | """Repairs for Yasno Outages integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING 7 | 8 | from homeassistant.components.repairs import RepairsFlow 9 | from homeassistant.helpers import issue_registry as ir 10 | 11 | from .const import CONF_PROVIDER, CONF_REGION, DOMAIN 12 | 13 | if TYPE_CHECKING: 14 | from homeassistant.config_entries import ConfigEntry 15 | from homeassistant.core import HomeAssistant 16 | from homeassistant.data_entry_flow import FlowResult 17 | 18 | LOGGER = logging.getLogger(__name__) 19 | 20 | 21 | async def async_check_and_create_repair( 22 | hass: HomeAssistant, 23 | entry: ConfigEntry, 24 | ) -> None: 25 | """Check if repair is needed and create issue.""" 26 | # Check for missing required configuration keys 27 | region = entry.options.get(CONF_REGION, entry.data.get(CONF_REGION)) 28 | provider = entry.options.get(CONF_PROVIDER, entry.data.get(CONF_PROVIDER)) 29 | 30 | if not region or not provider: 31 | LOGGER.info( 32 | "Missing required keys for entry %s, creating repair", 33 | entry.entry_id, 34 | ) 35 | LOGGER.debug("region=%s, provider=%s", region, provider) 36 | LOGGER.debug("data=%s, options=%s", entry.data, entry.options) 37 | 38 | ir.async_create_issue( 39 | hass, 40 | DOMAIN, 41 | f"missing_config_{entry.entry_id}", 42 | is_fixable=False, 43 | is_persistent=False, 44 | severity=ir.IssueSeverity.ERROR, 45 | translation_key="missing_config", 46 | translation_placeholders={ 47 | "entry_id": entry.entry_id, 48 | "entry_title": entry.title or "Yasno Outages", 49 | "edit": ( 50 | "/config/integrations/integration/yasno_outages" 51 | f"#config_entry={entry.entry_id}" 52 | ), 53 | }, 54 | ) 55 | else: 56 | # Delete the issue if it exists (config is now complete) 57 | ir.async_delete_issue(hass, DOMAIN, f"missing_config_{entry.entry_id}") 58 | 59 | 60 | class YasnoOutagesRepairsFlow(RepairsFlow): 61 | """Repairs flow placeholder; issues are informational only.""" 62 | 63 | def __init__(self, issue_id: str) -> None: 64 | """Store issue id.""" 65 | self._issue_id = issue_id 66 | 67 | async def async_step_init(self) -> FlowResult: 68 | """Abort because nothing to fix from UI side.""" 69 | return self.async_abort(reason="not_supported") 70 | 71 | 72 | async def async_create_fix_flow( 73 | hass: HomeAssistant, # noqa: ARG001 74 | issue_id: str, 75 | ) -> RepairsFlow: 76 | """Create a repairs flow for the given issue.""" 77 | return YasnoOutagesRepairsFlow(issue_id) 78 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Pytest configuration and fixtures.""" 2 | 3 | from datetime import timedelta 4 | 5 | import pytest 6 | from homeassistant.util import dt as dt_utils 7 | 8 | 9 | @pytest.fixture(name="today") 10 | def _today(): 11 | """Create a today datetime fixture.""" 12 | return dt_utils.as_local(dt_utils.now()).replace( 13 | hour=0, minute=0, second=0, microsecond=0 14 | ) 15 | 16 | 17 | @pytest.fixture(name="tomorrow") 18 | def _tomorrow(today): 19 | """Create a tomorrow datetime fixture.""" 20 | return today + timedelta(days=1) 21 | 22 | 23 | @pytest.fixture 24 | def regions_data(): 25 | """Sample regions data.""" 26 | return [ 27 | { 28 | "hasCities": False, 29 | "dsos": [{"id": 902, "name": "ПРАТ «ДТЕК КИЇВСЬКІ ЕЛЕКТРОМЕРЕЖІ»"}], 30 | "id": 25, 31 | "value": "Київ", 32 | }, 33 | { 34 | "hasCities": True, 35 | "dsos": [{"id": 301, "name": "ДнЕМ"}, {"id": 303, "name": "ЦЕК"}], 36 | "id": 3, 37 | "value": "Дніпро", 38 | }, 39 | ] 40 | 41 | 42 | @pytest.fixture 43 | def planned_outage_data(today, tomorrow): 44 | """Sample planned outage data.""" 45 | return { 46 | "3.1": { 47 | "today": { 48 | "slots": [ 49 | {"start": 0, "end": 960, "type": "NotPlanned"}, 50 | {"start": 960, "end": 1200, "type": "Definite"}, 51 | {"start": 1200, "end": 1440, "type": "NotPlanned"}, 52 | ], 53 | "date": today.isoformat(), 54 | "status": "ScheduleApplies", 55 | }, 56 | "tomorrow": { 57 | "slots": [ 58 | {"start": 0, "end": 900, "type": "NotPlanned"}, 59 | {"start": 900, "end": 1080, "type": "Definite"}, 60 | {"start": 1080, "end": 1440, "type": "NotPlanned"}, 61 | ], 62 | "date": tomorrow.isoformat(), 63 | "status": "ScheduleApplies", 64 | }, 65 | "updatedOn": today.isoformat(), 66 | } 67 | } 68 | 69 | 70 | @pytest.fixture 71 | def probable_outage_data(): 72 | """Sample probable outage data.""" 73 | return { 74 | "25": { 75 | "dsos": { 76 | "902": { 77 | "groups": { 78 | "3.1": { 79 | "slots": { 80 | "0": [ # Monday 81 | {"start": 480, "end": 720, "type": "Definite"}, 82 | ], 83 | "1": [ # Tuesday 84 | {"start": 600, "end": 900, "type": "Definite"}, 85 | ], 86 | "2": [], # Wednesday 87 | "3": [], # Thursday 88 | "4": [], # Friday 89 | "5": [], # Saturday 90 | "6": [], # Sunday 91 | } 92 | } 93 | } 94 | } 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /custom_components/yasno_outages/diagnostics.py: -------------------------------------------------------------------------------- 1 | """ 2 | Diagnostics support for Yasno Outages. 3 | 4 | Learn more about diagnostics: 5 | https://developers.home-assistant.io/docs/core/integration_diagnostics 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | from typing import TYPE_CHECKING, Any 11 | 12 | from .const import ( 13 | CONF_FILTER_PROBABLE, 14 | CONF_GROUP, 15 | CONF_PROVIDER, 16 | CONF_REGION, 17 | CONF_STATUS_ALL_DAY_EVENTS, 18 | ) 19 | 20 | if TYPE_CHECKING: 21 | from homeassistant.core import HomeAssistant 22 | 23 | from .data import YasnoOutagesConfigEntry 24 | 25 | 26 | async def async_get_config_entry_diagnostics( 27 | hass: HomeAssistant, # noqa: ARG001 28 | entry: YasnoOutagesConfigEntry, 29 | ) -> dict[str, Any]: 30 | """Return diagnostics for a config entry.""" 31 | coordinator = entry.runtime_data.coordinator 32 | api = entry.runtime_data.api 33 | data = entry.data 34 | 35 | return { 36 | "entry": { 37 | "entry_id": entry.entry_id, 38 | "version": entry.version, 39 | "minor_version": entry.minor_version, 40 | "domain": entry.domain, 41 | "title": entry.title, 42 | "state": str(entry.state), 43 | "data": { 44 | "region": data.get(CONF_REGION), 45 | "provider": data.get(CONF_PROVIDER), 46 | "group": data.get(CONF_GROUP), 47 | "filter_probable": data.get(CONF_FILTER_PROBABLE), 48 | "status_all_day_events": data.get(CONF_STATUS_ALL_DAY_EVENTS), 49 | }, 50 | "options": { 51 | "region": entry.options.get(CONF_REGION), 52 | "provider": entry.options.get(CONF_PROVIDER), 53 | "group": entry.options.get(CONF_GROUP), 54 | "filter_probable": entry.options.get(CONF_FILTER_PROBABLE), 55 | "status_all_day_events": entry.options.get(CONF_STATUS_ALL_DAY_EVENTS), 56 | }, 57 | }, 58 | "coordinator": { 59 | "last_update_success": coordinator.last_update_success, 60 | "update_interval": str(coordinator.update_interval), 61 | "region": coordinator.region, 62 | "region_id": coordinator.region_id, 63 | "provider": coordinator.provider, 64 | "provider_id": coordinator.provider_id, 65 | "provider_name": coordinator.provider_name, 66 | "group": coordinator.group, 67 | "filter_probable": coordinator.filter_probable, 68 | "status_all_day_events": coordinator.status_all_day_events, 69 | "current_state": coordinator.current_state, 70 | "status_today": coordinator.status_today, 71 | "status_tomorrow": coordinator.status_tomorrow, 72 | "schedule_updated_on": coordinator.schedule_updated_on.isoformat(), 73 | "next_planned_outage": coordinator.next_planned_outage.isoformat(), 74 | "next_probable_outage": coordinator.next_probable_outage.isoformat(), 75 | "next_connectivity": coordinator.next_connectivity.isoformat(), 76 | }, 77 | "api": { 78 | "region_id": api.planned.region_id, 79 | "provider_id": api.planned.provider_id, 80 | "group": api.planned.group, 81 | "regions_data": api.regions_data, 82 | "planned_outages_data": api.planned.planned_outages_data, 83 | "probable_outages_data": api.probable.probable_outages_data, 84 | }, 85 | "error": { 86 | "last_exception": ( 87 | str(coordinator.last_exception) if coordinator.last_exception else None 88 | ), 89 | }, 90 | } 91 | -------------------------------------------------------------------------------- /tests/api/test_models.py: -------------------------------------------------------------------------------- 1 | """Tests for Yasno Outages models.""" 2 | 3 | import datetime 4 | 5 | import pytest 6 | 7 | from custom_components.yasno_outages.api.models import ( 8 | OutageEvent, 9 | OutageEventType, 10 | OutageSlot, 11 | OutageSource, 12 | ) 13 | 14 | 15 | class TestOutageEventType: 16 | """Test OutageEventType enum.""" 17 | 18 | def test_definite(self): 19 | """Test DEFINITE type.""" 20 | assert OutageEventType.DEFINITE == "Definite" 21 | 22 | def test_not_planned(self): 23 | """Test NOT_PLANNED type.""" 24 | assert OutageEventType.NOT_PLANNED == "NotPlanned" 25 | 26 | 27 | class TestOutageSource: 28 | """Test OutageSource enum.""" 29 | 30 | def test_planned(self): 31 | """Test PLANNED source.""" 32 | assert OutageSource.PLANNED == "planned" 33 | 34 | def test_probable(self): 35 | """Test PROBABLE source.""" 36 | assert OutageSource.PROBABLE == "probable" 37 | 38 | 39 | class TestOutageEvent: 40 | """Test OutageEvent dataclass.""" 41 | 42 | def test_create_event(self): 43 | """Test creating an outage event.""" 44 | start = datetime.datetime(2025, 1, 27, 10, 0, 0) 45 | end = datetime.datetime(2025, 1, 27, 12, 0, 0) 46 | event = OutageEvent( 47 | event_type=OutageEventType.DEFINITE, 48 | start=start, 49 | end=end, 50 | source=OutageSource.PLANNED, 51 | ) 52 | assert event.start == start 53 | assert event.end == end 54 | assert event.event_type == OutageEventType.DEFINITE 55 | assert event.source == OutageSource.PLANNED 56 | 57 | def test_frozen(self): 58 | """Test that event is frozen.""" 59 | event = OutageEvent( 60 | event_type=OutageEventType.DEFINITE, 61 | start=datetime.datetime(2025, 1, 27, 10, 0, 0), 62 | end=datetime.datetime(2025, 1, 27, 12, 0, 0), 63 | source=OutageSource.PLANNED, 64 | ) 65 | with pytest.raises(AttributeError): 66 | # noinspection PyDataclass 67 | event.start = datetime.datetime(2025, 1, 28, 10, 0, 0) 68 | 69 | def test_event_with_probable_source(self): 70 | """Test creating event with probable source.""" 71 | start = datetime.datetime(2025, 1, 27, 10, 0, 0) 72 | end = datetime.datetime(2025, 1, 27, 12, 0, 0) 73 | event = OutageEvent( 74 | event_type=OutageEventType.DEFINITE, 75 | start=start, 76 | end=end, 77 | source=OutageSource.PROBABLE, 78 | ) 79 | assert event.source == OutageSource.PROBABLE 80 | 81 | 82 | class TestOutageSlot: 83 | """Test OutageSlot dataclass.""" 84 | 85 | def test_create_slot(self): 86 | """Test creating an outage slot.""" 87 | slot = OutageSlot( 88 | start=960, 89 | end=1200, 90 | event_type=OutageEventType.DEFINITE, 91 | ) 92 | assert slot.start == 960 93 | assert slot.end == 1200 94 | assert slot.event_type == OutageEventType.DEFINITE 95 | 96 | def test_frozen(self): 97 | """Test that slot is frozen.""" 98 | slot = OutageSlot( 99 | start=960, 100 | end=1200, 101 | event_type=OutageEventType.DEFINITE, 102 | ) 103 | with pytest.raises(AttributeError): 104 | # noinspection PyDataclass 105 | slot.start = 1000 106 | 107 | def test_slot_with_not_planned_type(self): 108 | """Test creating slot with NotPlanned type.""" 109 | slot = OutageSlot( 110 | start=0, 111 | end=960, 112 | event_type=OutageEventType.NOT_PLANNED, 113 | ) 114 | assert slot.event_type == OutageEventType.NOT_PLANNED 115 | -------------------------------------------------------------------------------- /custom_components/yasno_outages/__init__.py: -------------------------------------------------------------------------------- 1 | """Init file for Yasno Outages integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING 7 | 8 | from homeassistant.const import Platform 9 | from homeassistant.loader import async_get_loaded_integration 10 | 11 | from .api import YasnoApi 12 | from .const import CONF_PROVIDER, CONF_REGION, CONF_SERVICE 13 | from .coordinator import YasnoOutagesCoordinator 14 | from .data import YasnoOutagesData 15 | from .repairs import async_check_and_create_repair 16 | 17 | if TYPE_CHECKING: 18 | from homeassistant.config_entries import ConfigEntry 19 | from homeassistant.core import HomeAssistant 20 | 21 | from .data import YasnoOutagesConfigEntry 22 | 23 | LOGGER = logging.getLogger(__name__) 24 | 25 | PLATFORMS = [Platform.CALENDAR, Platform.SENSOR] 26 | 27 | 28 | async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 29 | """Migrate config entry to new version.""" 30 | LOGGER.info( 31 | "Migrating entry %s from version %s", 32 | entry.entry_id, 33 | entry.version, 34 | ) 35 | 36 | # Migration from version 1 to 2: rename service -> provider 37 | if entry.version == 1: 38 | updated_data = dict(entry.data) 39 | updated_options = dict(entry.options) 40 | 41 | # Migrate service to provider in data 42 | if CONF_SERVICE in updated_data: 43 | LOGGER.info("Migrating service to provider in data") 44 | updated_data[CONF_PROVIDER] = updated_data.pop(CONF_SERVICE) 45 | 46 | # Migrate service to provider in options 47 | if CONF_SERVICE in updated_options: 48 | LOGGER.info("Migrating service to provider in options") 49 | updated_options[CONF_PROVIDER] = updated_options.pop(CONF_SERVICE) 50 | 51 | # Update entry with new data and version 52 | hass.config_entries.async_update_entry( 53 | entry, 54 | data=updated_data, 55 | options=updated_options, 56 | version=2, 57 | ) 58 | 59 | LOGGER.info("Migration to version 2 complete") 60 | 61 | LOGGER.info("Entry %s now at version %s", entry.entry_id, entry.version) 62 | return True 63 | 64 | 65 | async def async_setup_entry( 66 | hass: HomeAssistant, 67 | entry: YasnoOutagesConfigEntry, 68 | ) -> bool: 69 | """Set up a new entry.""" 70 | LOGGER.info("Setup entry: %s", entry) 71 | 72 | # Validate required keys are present 73 | region = entry.options.get(CONF_REGION, entry.data.get(CONF_REGION)) 74 | provider = entry.options.get(CONF_PROVIDER, entry.data.get(CONF_PROVIDER)) 75 | 76 | # Check for other issues (like old API format) 77 | await async_check_and_create_repair(hass, entry) 78 | 79 | if not region or not provider: 80 | LOGGER.error( 81 | "Missing required keys for entry %s: region=%s, provider=%s", 82 | entry.entry_id, 83 | region, 84 | provider, 85 | ) 86 | return False 87 | 88 | api = YasnoApi() 89 | coordinator = YasnoOutagesCoordinator(hass, entry, api) 90 | entry.runtime_data = YasnoOutagesData( 91 | api=api, 92 | coordinator=coordinator, 93 | integration=async_get_loaded_integration(hass, entry.domain), 94 | ) 95 | 96 | # First refresh 97 | await coordinator.async_config_entry_first_refresh() 98 | 99 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 100 | entry.async_on_unload(entry.add_update_listener(async_reload_entry)) 101 | return True 102 | 103 | 104 | async def async_reload_entry( 105 | hass: HomeAssistant, 106 | entry: YasnoOutagesConfigEntry, 107 | ) -> None: 108 | """Reload config entry.""" 109 | await hass.config_entries.async_reload(entry.entry_id) 110 | 111 | 112 | async def async_unload_entry( 113 | hass: HomeAssistant, 114 | entry: YasnoOutagesConfigEntry, 115 | ) -> bool: 116 | """Handle removal of an entry.""" 117 | LOGGER.info("Unload entry: %s", entry) 118 | return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 119 | -------------------------------------------------------------------------------- /custom_components/yasno_outages/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Yasno Outages", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "title": "Yasno Outages Settings", 7 | "data": { 8 | "region": "Please select your region:" 9 | } 10 | }, 11 | "provider": { 12 | "title": "Yasno Outages Settings", 13 | "data": { 14 | "provider": "Please select your provider:" 15 | } 16 | }, 17 | "group": { 18 | "title": "Yasno Outages Settings", 19 | "data": { 20 | "group": "Please select your group:", 21 | "filter_probable": "Hide probable outages for planned days", 22 | "status_all_day_events": "Show status all-day events" 23 | }, 24 | "data_description": { 25 | "group": "You can find your group at: https://static.yasno.ua/kyiv/outages", 26 | "filter_probable": "Hides probable outages in the calendar for days when a planned schedule is available", 27 | "status_all_day_events": "Adds all-day calendar entries describing today's and tomorrow's outage status" 28 | } 29 | } 30 | } 31 | }, 32 | "options": { 33 | "step": { 34 | "init": { 35 | "title": "Yasno Outages Settings", 36 | "data": { 37 | "region": "Please select your region:" 38 | } 39 | }, 40 | "provider": { 41 | "title": "Yasno Outages Settings", 42 | "data": { 43 | "provider": "Please select your provider:" 44 | } 45 | }, 46 | "group": { 47 | "title": "Yasno Outages Settings", 48 | "data": { 49 | "group": "Please select your group:", 50 | "filter_probable": "Hide probable outages when planned schedule is available", 51 | "status_all_day_events": "Show status all-day events" 52 | }, 53 | "data_description": { 54 | "group": "You can find your group at: https://static.yasno.ua/kyiv/outages", 55 | "status_all_day_events": "Adds all-day calendar entries describing today's and tomorrow's status" 56 | } 57 | } 58 | } 59 | }, 60 | 61 | "device": { 62 | "yasno_outages": { 63 | "name": "Yasno {region} {provider} {group}" 64 | } 65 | }, 66 | "entity": { 67 | "calendar": { 68 | "planned_outages": { 69 | "name": "Planned Outages" 70 | }, 71 | "probable_outages": { 72 | "name": "Probable Outages" 73 | } 74 | }, 75 | "sensor": { 76 | "electricity": { 77 | "name": "Electricity", 78 | "state": { 79 | "normal": "Connected", 80 | "outage": "Outage" 81 | } 82 | }, 83 | "schedule_updated_on": { 84 | "name": "Schedule Updated On" 85 | }, 86 | "next_planned_outage": { 87 | "name": "Next Planned Outage" 88 | }, 89 | "next_probable_outage": { 90 | "name": "Next Probable Outage" 91 | }, 92 | "next_connectivity": { 93 | "name": "Next Connectivity" 94 | }, 95 | "status_today": { 96 | "name": "Status Today", 97 | "state": { 98 | "schedule_applies": "Schedule Applies", 99 | "waiting_for_schedule": "Waiting for Schedule", 100 | "emergency_shutdowns": "Emergency Shutdowns" 101 | } 102 | }, 103 | "status_tomorrow": { 104 | "name": "Status Tomorrow", 105 | "state": { 106 | "schedule_applies": "Schedule Applies", 107 | "waiting_for_schedule": "Waiting for Schedule", 108 | "emergency_shutdowns": "Emergency Shutdowns" 109 | } 110 | } 111 | } 112 | }, 113 | "common": { 114 | "planned_electricity_outage": "Outage", 115 | "probable_electricity_outage": "Probable Outage", 116 | "status_schedule_applies": "Schedule Applies", 117 | "status_waiting_for_schedule": "Waiting for Schedule", 118 | "status_emergency_shutdowns": "Emergency Shutdowns" 119 | }, 120 | "issues": { 121 | "missing_config": { 122 | "title": "{entry_title}: Configuration incomplete", 123 | "description": "This integration entry is missing required settings (region and/or provider).\n\nTo fix this error, [edit the integration entry]({edit}) and update your settings with the correct region and provider." 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /custom_components/yasno_outages/translations/uk.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Yasno Відключення", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "title": "Налаштування Yasno Відключення", 7 | "data": { 8 | "region": "Оберіть свій регіон:" 9 | } 10 | }, 11 | "provider": { 12 | "title": "Налаштування Yasno Відключення", 13 | "data": { 14 | "provider": "Оберіть свою систему розподілу (ОСР):" 15 | } 16 | }, 17 | "group": { 18 | "title": "Налаштування Yasno Відключення", 19 | "data": { 20 | "group": "Оберіть свою групу:", 21 | "filter_probable": "Приховати ймовірні відключення для запланованих днів", 22 | "status_all_day_events": "Показувати цілодобові події статусу" 23 | }, 24 | "data_description": { 25 | "group": "Знайдіть свою групу на: https://static.yasno.ua/kyiv/outages", 26 | "filter_probable": "Приховує у календарі ймовірні відключення для днів, коли доступний плановий графік", 27 | "status_all_day_events": "Додає цілодобові події про статуси відключень на сьогодні та завтра" 28 | } 29 | } 30 | } 31 | }, 32 | "options": { 33 | "step": { 34 | "init": { 35 | "title": "Налаштування Yasno Відключення", 36 | "data": { 37 | "region": "Оберіть свій регіон:" 38 | } 39 | }, 40 | "provider": { 41 | "title": "Налаштування Yasno Відключення", 42 | "data": { 43 | "provider": "Оберіть свою систему розподілу (ОСР):" 44 | } 45 | }, 46 | "group": { 47 | "title": "Налаштування Yasno Відключення", 48 | "data": { 49 | "group": "Оберіть свою групу:", 50 | "filter_probable": "Приховати ймовірні відключення, коли доступний плановий графік", 51 | "status_all_day_events": "Показувати цілодобові події для статусів" 52 | }, 53 | "data_description": { 54 | "group": "Знайдіть свою групу на: https://static.yasno.ua/kyiv/outages", 55 | "status_all_day_events": "Додавати у календар цілодобові події про статус відключень на сьогодні та завтра" 56 | } 57 | } 58 | } 59 | }, 60 | 61 | "device": { 62 | "yasno_outages": { 63 | "name": "Yasno {region} {provider} {group}" 64 | } 65 | }, 66 | "entity": { 67 | "calendar": { 68 | "planned_outages": { 69 | "name": "Заплановані відключення" 70 | }, 71 | "probable_outages": { 72 | "name": "Імовірні відключення" 73 | } 74 | }, 75 | "sensor": { 76 | "electricity": { 77 | "name": "Електрика", 78 | "state": { 79 | "normal": "Заживлено", 80 | "outage": "Відключення" 81 | } 82 | }, 83 | "schedule_updated_on": { 84 | "name": "Графік оновлено" 85 | }, 86 | "next_planned_outage": { 87 | "name": "Наступне заплановане відключення" 88 | }, 89 | "next_probable_outage": { 90 | "name": "Наступне імовірне відключення" 91 | }, 92 | "next_connectivity": { 93 | "name": "Наступна заживленість" 94 | }, 95 | "status_today": { 96 | "name": "Статус сьогодні", 97 | "state": { 98 | "schedule_applies": "Графік діє", 99 | "waiting_for_schedule": "Очікування графіка", 100 | "emergency_shutdowns": "Аварійні відключення" 101 | } 102 | }, 103 | "status_tomorrow": { 104 | "name": "Статус завтра", 105 | "state": { 106 | "schedule_applies": "Графік діє", 107 | "waiting_for_schedule": "Очікування графіка", 108 | "emergency_shutdowns": "Аварійні відключення" 109 | } 110 | } 111 | } 112 | }, 113 | "common": { 114 | "planned_electricity_outage": "Відключення", 115 | "probable_electricity_outage": "Імовірне відключення", 116 | "status_schedule_applies": "Графік діє", 117 | "status_waiting_for_schedule": "Очікування графіка", 118 | "status_emergency_shutdowns": "Аварійні відключення" 119 | }, 120 | "issues": { 121 | "missing_config": { 122 | "title": "{entry_title}: Неповна конфігурація", 123 | "description": "У цьому записі інтеграції відсутні обов'язкові налаштування (регіон та/або систему розподілу ОСР).\n\nЩоб виправити цю помилку, [відредагуйте запис інтеграції]({edit}) та оновіть налаштування, вказавши правильний регіон та систему розподілу." 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /custom_components/yasno_outages/translations/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Yasno Stroomonderbrekingen", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "title": "Yasno Stroomonderbrekingen Instellingen", 7 | "data": { 8 | "region": "Selecteer je regio:" 9 | } 10 | }, 11 | "provider": { 12 | "title": "Yasno Stroomonderbrekingen Instellingen", 13 | "data": { 14 | "provider": "Selecteer je provider:" 15 | } 16 | }, 17 | "group": { 18 | "title": "Yasno Stroomonderbrekingen Instellingen", 19 | "data": { 20 | "group": "Selecteer je groep:", 21 | "filter_probable": "Verberg waarschijnlijke onderbrekingen op geplande dagen", 22 | "status_all_day_events": "Toon status-evenementen voor de hele dag" 23 | }, 24 | "data_description": { 25 | "group": "Je kunt je groep vinden op: https://static.yasno.ua/kyiv/outages", 26 | "filter_probable": "Verbergt waarschijnlijke onderbrekingen in de kalender op dagen waarop een gepland schema beschikbaar is", 27 | "status_all_day_events": "Voegt kalenderitems voor de hele dag toe die de status van de onderbrekingen van vandaag en morgen beschrijven" 28 | } 29 | } 30 | } 31 | }, 32 | "options": { 33 | "step": { 34 | "init": { 35 | "title": "Yasno Stroomonderbrekingen Instellingen", 36 | "data": { 37 | "region": "Selecteer je regio:" 38 | } 39 | }, 40 | "provider": { 41 | "title": "Yasno Stroomonderbrekingen Instellingen", 42 | "data": { 43 | "provider": "Selecteer je provider:" 44 | } 45 | }, 46 | "group": { 47 | "title": "Yasno Stroomonderbrekingen Instellingen", 48 | "data": { 49 | "group": "Selecteer je groep:", 50 | "filter_probable": "Verberg waarschijnlijke onderbrekingen wanneer een gepland schema beschikbaar is", 51 | "status_all_day_events": "Toon status-evenementen voor de hele dag" 52 | }, 53 | "data_description": { 54 | "group": "Je kunt je groep vinden op: https://static.yasno.ua/kyiv/outages", 55 | "status_all_day_events": "Voegt kalenderitems voor de hele dag toe die de status van vandaag en morgen beschrijven" 56 | } 57 | } 58 | } 59 | }, 60 | 61 | "device": { 62 | "yasno_outages": { 63 | "name": "Yasno {region} {provider} {group}" 64 | } 65 | }, 66 | "entity": { 67 | "calendar": { 68 | "planned_outages": { 69 | "name": "Geplande Onderbrekingen" 70 | }, 71 | "probable_outages": { 72 | "name": "Waarschijnlijke Onderbrekingen" 73 | } 74 | }, 75 | "sensor": { 76 | "electricity": { 77 | "name": "Elektriciteit", 78 | "state": { 79 | "normal": "Verbonden", 80 | "outage": "Onderbreking" 81 | } 82 | }, 83 | "schedule_updated_on": { 84 | "name": "Schema Bijgewerkt Op" 85 | }, 86 | "next_planned_outage": { 87 | "name": "Volgende Geplande Onderbreking" 88 | }, 89 | "next_probable_outage": { 90 | "name": "Volgende Waarschijnlijke Onderbreking" 91 | }, 92 | "next_connectivity": { 93 | "name": "Volgende Herverbinding" 94 | }, 95 | "status_today": { 96 | "name": "Status Vandaag", 97 | "state": { 98 | "schedule_applies": "Schema van Toepassing", 99 | "waiting_for_schedule": "Wachten op Schema", 100 | "emergency_shutdowns": "Noodafschakelingen" 101 | } 102 | }, 103 | "status_tomorrow": { 104 | "name": "Status Morgen", 105 | "state": { 106 | "schedule_applies": "Schema van Toepassing", 107 | "waiting_for_schedule": "Wachten op Schema", 108 | "emergency_shutdowns": "Noodafschakelingen" 109 | } 110 | } 111 | } 112 | }, 113 | "common": { 114 | "planned_electricity_outage": "Onderbreking", 115 | "probable_electricity_outage": "Waarschijnlijke Onderbreking", 116 | "status_schedule_applies": "Schema van Toepassing", 117 | "status_waiting_for_schedule": "Wachten op Schema", 118 | "status_emergency_shutdowns": "Noodafschakelingen" 119 | }, 120 | "issues": { 121 | "missing_config": { 122 | "title": "{entry_title}: Configuratie onvolledig", 123 | "description": "Deze integratie mist vereiste instellingen (regio en/of provider).\n\nOm dit op te lossen, [bewerk de integratie]({edit}) en vul de juiste regio en provider in." 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | [![SWUbanner](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct-single.svg)](https://stand-with-ukraine.pp.ua/) 2 | 3 | ![HA Yasno Outages Logo](./icons/logo.png) 4 | 5 | # ⚡️ HA Yasno Outages 6 | 7 | [![GitHub Release][gh-release-image]][gh-release-url] 8 | [![GitHub Downloads][gh-downloads-image]][gh-downloads-url] 9 | [![hacs][hacs-image]][hacs-url] 10 | [![GitHub Sponsors][gh-sponsors-image]][gh-sponsors-url] 11 | [![Buy Me A Coffee][buymeacoffee-image]][buymeacoffee-url] 12 | [![Twitter][twitter-image]][twitter-url] 13 | 14 | > [!NOTE] 15 | > An integration for electricity outages plans by [Yasno][yasno]. 16 | > 17 | > This is not affiliated with [Yasno][yasno] in any way. This integration is developed by an individual. Information may vary from their official website. 18 | 19 | This integration for [Home Assistant][home-assistant] provides information about electricity outages plans by [Yasno][yasno]: calendar of planned outages, time sensors for the next planned outages, and more. 20 | 21 | > [!TIP] 22 | > Документація доступна [**українською мовою 🇺🇦**](./readme.uk.md) 23 | 24 | ## Sponsorship 25 | 26 | Your generosity will help me maintain and develop more projects like this one. 27 | 28 | - 💖 [Sponsor on GitHub][gh-sponsors-url] 29 | - ☕️ [Buy Me A Coffee][buymeacoffee-url] 30 | - Bitcoin: `bc1q7lfx6de8jrqt8mcds974l6nrsguhd6u30c6sg8` 31 | - Ethereum: `0x6aF39C917359897ae6969Ad682C14110afe1a0a1` 32 | 33 | ## Installation 34 | 35 | The quickest way to install this integration is via [HACS][hacs-url] by clicking the button below: 36 | 37 | [![Add to HACS via My Home Assistant][hacs-install-image]][hasc-install-url] 38 | 39 | If it doesn't work, adding this repository to HACS manually by adding this URL: 40 | 41 | 1. Visit **HACS** → **Integrations** → **...** (in the top right) → **Custom repositories** 42 | 2. Click **Add** 43 | 3. Paste `https://github.com/denysdovhan/ha-yasno-outages` into the **URL** field 44 | 4. Chose **Integration** as a **Category** 45 | 5. **Yasno Outages** will appear in the list of available integrations. Install it normally. 46 | 47 | ## Usage 48 | 49 | This integration is configurable via UI. On **Devices and Services** page, click **Add Integration** and search for **Yasno Outages**. 50 | 51 | Select your region: 52 | 53 | ![Region Selection](/media/1_region.png) 54 | 55 | Select your Service Provider 56 | 57 | ![Service Provider Selection](/media/2_provider.png) 58 | 59 | Select your Group 60 | 61 | ![Group Selection](/media/3_group.png) 62 | 63 | Here's how the devices look 64 | 65 | ![Devices page](/media/4_devices.png) 66 | 67 | ![Device page](/media/5_device.png) 68 | 69 | Then you can add the integration to your dashboard and see the information about the next planned outages. 70 | Integration also provides a calendar view of planned outages. You can add it to your dashboard as well via [Calendar card][calendar-card]. 71 | 72 | ![Calendars view](/media/6_calendars.png) 73 | 74 | Examples: 75 | 76 | - [Automation](/examples/automation.yaml) 77 | - [Dashboard](/examples/dashboard.yaml) 78 | 79 | Here's an example of a dashboard using this integration: 80 | 81 | ![Dashboard example](https://github.com/denysdovhan/ha-yasno-outages/assets/3459374/26c75595-8984-4a9f-893a-e4b6d838b7f2) 82 | 83 | ## Development 84 | 85 | Want to contribute to the project? 86 | 87 | First, thanks! Check [contributing guideline](/contributing.md) for more information. 88 | 89 | ## License 90 | 91 | MIT © [Denys Dovhan][denysdovhan] 92 | 93 | 94 | 95 | [gh-release-url]: https://github.com/denysdovhan/ha-yasno-outages/releases/latest 96 | [gh-release-image]: https://img.shields.io/github/v/release/denysdovhan/ha-yasno-outages?style=flat-square 97 | [gh-downloads-url]: https://github.com/denysdovhan/ha-yasno-outages/releases 98 | [gh-downloads-image]: https://img.shields.io/github/downloads/denysdovhan/ha-yasno-outages/total?style=flat-square 99 | [hacs-url]: https://github.com/hacs/integration 100 | [hacs-image]: https://img.shields.io/badge/hacs-default-orange.svg?style=flat-square 101 | [gh-sponsors-url]: https://github.com/sponsors/denysdovhan 102 | [gh-sponsors-image]: https://img.shields.io/github/sponsors/denysdovhan?style=flat-square 103 | [buymeacoffee-url]: https://buymeacoffee.com/denysdovhan 104 | [buymeacoffee-image]: https://img.shields.io/badge/support-buymeacoffee-222222.svg?style=flat-square 105 | [twitter-url]: https://twitter.com/denysdovhan 106 | [twitter-image]: https://img.shields.io/badge/twitter-%40denysdovhan-00ACEE.svg?style=flat-square 107 | 108 | 109 | 110 | [yasno]: https://yasno.com.ua/ 111 | [home-assistant]: https://www.home-assistant.io/ 112 | [denysdovhan]: https://github.com/denysdovhan 113 | [hasc-install-url]: https://my.home-assistant.io/redirect/hacs_repository/?owner=denysdovhan&repository=ha-yasno-outages&category=integration 114 | [hacs-install-image]: https://my.home-assistant.io/badges/hacs_repository.svg 115 | [add-translation]: https://github.com/denysdovhan/ha-yasno-outages/blob/master/contributing.md#how-to-add-translation 116 | [calendar-card]: https://www.home-assistant.io/dashboards/calendar/ 117 | -------------------------------------------------------------------------------- /readme.uk.md: -------------------------------------------------------------------------------- 1 | [![SWUbanner](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct-single.svg)](https://stand-with-ukraine.pp.ua/) 2 | 3 | ![HA Yasno Outages Logo](./icons/logo.png) 4 | 5 | # ⚡️ HA Yasno Outages 6 | 7 | [![GitHub Release][gh-release-image]][gh-release-url] 8 | [![GitHub Downloads][gh-downloads-image]][gh-downloads-url] 9 | [![hacs][hacs-image]][hacs-url] 10 | [![GitHub Sponsors][gh-sponsors-image]][gh-sponsors-url] 11 | [![Buy Me A Coffee][buymeacoffee-image]][buymeacoffee-url] 12 | [![Twitter][twitter-image]][twitter-url] 13 | 14 | Ця інтеграція надає інформацію про графіки відключень електроенергії від [Yasno][yasno]: календар запланованих відключень, датчики часу для наступних запланованих відключень тощо. 15 | 16 | > [!NOTE] 17 | > Інтеграція для графіків відключень електроенергії від [Yasno][yasno]. 18 | > 19 | > Цей проєкт не має жодного відношення до [Yasno][yasno]. Ця інтеграція розроблена ентузіастом. Інформація може відрізнятися від інфомації на офіційному сайті. 20 | 21 | ## Спонсорство 22 | 23 | Ваша щедрість допоможе мені підтримувати та розробляти більше таких проектів, як цей. 24 | 25 | - 💖 [Спонсорувати на GitHub][gh-sponsors-url] 26 | - ☕️ [Buy Me A Coffee][buymeacoffee-url] 27 | - 🤝 [Підтримати на Patreon][patreon-url] 28 | - Bitcoin: `bc1q7lfx6de8jrqt8mcds974l6nrsguhd6u30c6sg8` 29 | - Ethereum: `0x6aF39C917359897ae6969Ad682C14110afe1a0a1` 30 | 31 | ## Встановлення 32 | 33 | Найшвидший спосіб встановити цю інтеграцію — через [HACS][hacs-url], натиснувши кнопку нижче: 34 | 35 | [![Add to HACS via My Home Assistant][hacs-install-image]][hasc-install-url] 36 | 37 | Якщо це не працює, додайте цей репозиторій в HACS вручну, додавши цей URL: 38 | 39 | 1. Відвідайте **HACS** → **Інтеграції** → **...** (вгорі праворуч) → **Користувацькі репозиторії** 40 | 2. Натисніть **Додати** 41 | 3. Вставте `https://github.com/denysdovhan/ha-yasno-outages` у поле **URL** 42 | 4. Виберіть **Інтеграція** як **Категорію** 43 | 5. **Yasno Outages** з'явиться у списку доступних інтеграцій. Встановіть її звичайним способом. 44 | 45 | ## Використання 46 | 47 | Ця інтеграція налаштовується через інтерфейс користувача. На сторінці **Пристрої та сервіси** натисніть **Додати інтеграцію** і знайдіть **Yasno Відключення**. 48 | 49 | Виберіть регіон: 50 | 51 | ![Region Selection](/media/1_region.png) 52 | 53 | Виберіть систему розподілу: 54 | 55 | ![Service Provider Selection](/media/2_provider.png) 56 | 57 | Виберіть групу: 58 | 59 | ![Group Selection](/media/3_group.png) 60 | 61 | Ось як виглядає пристрій: 62 | 63 | ![Devices page](/media/4_devices.png) 64 | 65 | ![Device page](/media/5_device.png) 66 | 67 | Після цього ви можете додати інтеграцію до своєї панелі керування та переглянути інформацію про наступні заплановані відключення. 68 | Інтеграція також надає календарний вигляд запланованих відключень. Ви можете додати його до своєї панелі керування за допомогою [Calendar Card][calendar-card]. 69 | 70 | ![Перегляд календарів](/media/6_calendars.png) 71 | 72 | Приклади: 73 | 74 | - [Автоматизація](/examples/automation.yaml) 75 | - [Панель керування](/examples/dashboard.yaml) 76 | 77 | Приклад панелі керування з корисною інтеграцією: 78 | 79 | ![Приклад панелі керування](https://github.com/denysdovhan/ha-yasno-outages/assets/3459374/26c75595-8984-4a9f-893a-e4b6d838b7f2) 80 | 81 | ## Розробка 82 | 83 | Бажаєте зробити внесок у проект? 84 | 85 | По-перше, дякую! Перегляньте [керівництво по внеску](./contributing.md) для отримання додаткової інформації. 86 | 87 | ## Ліцензія 88 | 89 | MIT © [Денис Довгань][denysdovhan] 90 | 91 | 92 | 93 | [gh-release-url]: https://github.com/denysdovhan/ha-yasno-outages/releases/latest 94 | [gh-release-image]: https://img.shields.io/github/v/release/denysdovhan/ha-yasno-outages?style=flat-square 95 | [gh-downloads-url]: https://github.com/denysdovhan/ha-yasno-outages/releases 96 | [gh-downloads-image]: https://img.shields.io/github/downloads/denysdovhan/ha-yasno-outages/total?style=flat-square 97 | [hacs-url]: https://github.com/hacs/integration 98 | [hacs-image]: https://img.shields.io/badge/hacs-custom-orange.svg?style=flat-square 99 | [gh-sponsors-url]: https://github.com/sponsors/denysdovhan 100 | [gh-sponsors-image]: https://img.shields.io/github/sponsors/denysdovhan?style=flat-square 101 | [buymeacoffee-url]: https://buymeacoffee.com/denysdovhan 102 | [buymeacoffee-image]: https://img.shields.io/badge/support-buymeacoffee-222222.svg?style=flat-square 103 | [twitter-url]: https://twitter.com/denysdovhan 104 | [twitter-image]: https://img.shields.io/badge/twitter-%40denysdovhan-00ACEE.svg?style=flat-square 105 | 106 | 107 | 108 | [yasno]: https://yasno.com.ua/ 109 | [home-assistant]: https://www.home-assistant.io/ 110 | [denysdovhan]: https://github.com/denysdovhan 111 | [hasc-install-url]: https://my.home-assistant.io/redirect/hacs_repository/?owner=denysdovhan&repository=ha-yasno-outages&category=integration 112 | [hacs-install-image]: https://my.home-assistant.io/badges/hacs_repository.svg 113 | [add-translation]: https://github.com/denysdovhan/ha-yasno-outages/blob/master/contributing.md#how-to-add-translation 114 | [calendar-card]: https://www.home-assistant.io/dashboards/calendar/ 115 | -------------------------------------------------------------------------------- /custom_components/yasno_outages/api/probable.py: -------------------------------------------------------------------------------- 1 | """Probable outages API for Yasno.""" 2 | 3 | import datetime 4 | import logging 5 | 6 | import aiohttp 7 | from dateutil.rrule import WEEKLY, rrule 8 | 9 | from .base import BaseYasnoApi 10 | from .const import PROBABLE_OUTAGES_ENDPOINT 11 | from .models import OutageEvent, OutageSlot, OutageSource 12 | 13 | LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | class ProbableOutagesApi(BaseYasnoApi): 17 | """API for fetching probable outages data.""" 18 | 19 | def __init__( 20 | self, 21 | region_id: int | None = None, 22 | provider_id: int | None = None, 23 | group: str | None = None, 24 | ) -> None: 25 | """Initialize the ProbableOutagesApi.""" 26 | super().__init__(region_id, provider_id, group) 27 | self.probable_outages_data = None 28 | 29 | async def fetch_probable_outages_data(self) -> None: 30 | """Fetch probable outages data for the configured region and provider.""" 31 | if not self.region_id or not self.provider_id: 32 | LOGGER.warning( 33 | "Region and Provider ID must be set before fetching probable outages", 34 | ) 35 | return 36 | 37 | url = PROBABLE_OUTAGES_ENDPOINT.format( 38 | region_id=self.region_id, 39 | dso_id=self.provider_id, 40 | ) 41 | 42 | async with aiohttp.ClientSession() as session: 43 | self.probable_outages_data = await self._get_data(session, url) 44 | 45 | def get_probable_slots_for_weekday( 46 | self, 47 | weekday: int, 48 | ) -> list[OutageSlot]: 49 | """Get probable outage slots for a specific weekday.""" 50 | if not self.probable_outages_data: 51 | return [] 52 | 53 | region_data = self.probable_outages_data.get(str(self.region_id), {}) 54 | dsos_data = region_data.get("dsos", {}) 55 | dso_data = dsos_data.get(str(self.provider_id), {}) 56 | groups_data = dso_data.get("groups", {}) 57 | group_data = groups_data.get(self.group, {}) 58 | slots_data = group_data.get("slots", {}) 59 | weekday_slots = slots_data.get(str(weekday), []) 60 | 61 | return self._parse_raw_slots(weekday_slots) 62 | 63 | def get_current_event(self, at: datetime.datetime) -> OutageEvent | None: 64 | """Get the probable outage event at a given time.""" 65 | weekday = at.weekday() # 0=Monday, 6=Sunday 66 | slots = self.get_probable_slots_for_weekday(weekday) 67 | 68 | # Find slot that contains the current time 69 | minutes_since_midnight = at.hour * 60 + at.minute 70 | 71 | for slot in slots: 72 | if slot.start <= minutes_since_midnight < slot.end: 73 | # Found matching slot, create event for today 74 | date = at.replace(hour=0, minute=0, second=0, microsecond=0) 75 | event_start = self.minutes_to_time(slot.start, date) 76 | event_end = self.minutes_to_time(slot.end, date) 77 | 78 | return OutageEvent( 79 | start=event_start, 80 | end=event_end, 81 | event_type=slot.event_type, 82 | source=OutageSource.PROBABLE, 83 | ) 84 | 85 | return None 86 | 87 | def get_events_between( 88 | self, 89 | start_date: datetime.datetime, 90 | end_date: datetime.datetime, 91 | ) -> list[OutageEvent]: 92 | """Get all probable outage events within the date range using rrule.""" 93 | events = [] 94 | 95 | # Iterate through each day of the week (0=Monday, 6=Sunday) 96 | for weekday in range(7): 97 | slots = self.get_probable_slots_for_weekday(weekday) 98 | 99 | for slot in slots: 100 | # Find the first occurrence of this weekday >= start_date 101 | days_ahead = weekday - start_date.weekday() 102 | if days_ahead < 0: 103 | days_ahead += 7 104 | first_occurrence = start_date + datetime.timedelta(days=days_ahead) 105 | first_occurrence = first_occurrence.replace( 106 | hour=0, minute=0, second=0, microsecond=0 107 | ) 108 | 109 | # Generate recurring events for this slot using rrule 110 | # WEEKLY recurrence for this specific weekday 111 | for dt in rrule( 112 | freq=WEEKLY, 113 | dtstart=first_occurrence, 114 | until=end_date, 115 | byweekday=weekday, 116 | ): 117 | event_start = self.minutes_to_time(slot.start, dt) 118 | event_end = self.minutes_to_time(slot.end, dt) 119 | 120 | # Skip if event is completely outside the requested range 121 | if event_end < start_date or event_start > end_date: 122 | continue 123 | 124 | events.append( 125 | OutageEvent( 126 | start=event_start, 127 | end=event_end, 128 | event_type=slot.event_type, 129 | source=OutageSource.PROBABLE, 130 | ) 131 | ) 132 | 133 | # Sort by start time 134 | return sorted(events, key=lambda e: e.start) 135 | 136 | async def fetch_data(self) -> None: 137 | """Fetch all required data.""" 138 | await self.fetch_probable_outages_data() 139 | -------------------------------------------------------------------------------- /custom_components/yasno_outages/sensor.py: -------------------------------------------------------------------------------- 1 | """Calendar platform for Yasno outages integration.""" 2 | 3 | import logging 4 | from collections.abc import Callable 5 | from dataclasses import dataclass 6 | from typing import Any 7 | 8 | from homeassistant.components.sensor import ( 9 | SensorEntity, 10 | SensorEntityDescription, 11 | ) 12 | from homeassistant.components.sensor.const import SensorDeviceClass 13 | from homeassistant.const import STATE_UNKNOWN, EntityCategory 14 | from homeassistant.core import HomeAssistant 15 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 16 | 17 | from .const import ( 18 | ATTR_EVENT_END, 19 | ATTR_EVENT_START, 20 | ATTR_EVENT_TYPE, 21 | STATE_NORMAL, 22 | STATE_OUTAGE, 23 | STATE_STATUS_EMERGENCY_SHUTDOWNS, 24 | STATE_STATUS_SCHEDULE_APPLIES, 25 | STATE_STATUS_WAITING_FOR_SCHEDULE, 26 | ) 27 | from .coordinator import YasnoOutagesCoordinator 28 | from .data import YasnoOutagesConfigEntry 29 | from .entity import YasnoOutagesEntity 30 | 31 | LOGGER = logging.getLogger(__name__) 32 | 33 | 34 | @dataclass(frozen=True, kw_only=True) 35 | class YasnoOutagesSensorDescription(SensorEntityDescription): 36 | """Yasno Outages entity description.""" 37 | 38 | val_func: Callable[[YasnoOutagesCoordinator], Any] 39 | 40 | 41 | SENSOR_TYPES: tuple[YasnoOutagesSensorDescription, ...] = ( 42 | YasnoOutagesSensorDescription( 43 | key="electricity", 44 | translation_key="electricity", 45 | icon="mdi:transmission-tower", 46 | device_class=SensorDeviceClass.ENUM, 47 | options=[STATE_NORMAL, STATE_OUTAGE, STATE_UNKNOWN], 48 | val_func=lambda coordinator: coordinator.current_state, 49 | ), 50 | YasnoOutagesSensorDescription( 51 | key="next_planned_outage", 52 | translation_key="next_planned_outage", 53 | icon="mdi:calendar-remove", 54 | device_class=SensorDeviceClass.TIMESTAMP, 55 | val_func=lambda coordinator: coordinator.next_planned_outage, 56 | ), 57 | YasnoOutagesSensorDescription( 58 | key="next_probable_outage", 59 | translation_key="next_probable_outage", 60 | icon="mdi:calendar-question", 61 | device_class=SensorDeviceClass.TIMESTAMP, 62 | val_func=lambda coordinator: coordinator.next_probable_outage, 63 | ), 64 | YasnoOutagesSensorDescription( 65 | key="next_connectivity", 66 | translation_key="next_connectivity", 67 | icon="mdi:calendar-check", 68 | device_class=SensorDeviceClass.TIMESTAMP, 69 | val_func=lambda coordinator: coordinator.next_connectivity, 70 | ), 71 | YasnoOutagesSensorDescription( 72 | key="schedule_updated_on", 73 | translation_key="schedule_updated_on", 74 | icon="mdi:update", 75 | device_class=SensorDeviceClass.TIMESTAMP, 76 | entity_category=EntityCategory.DIAGNOSTIC, 77 | val_func=lambda coordinator: coordinator.schedule_updated_on, 78 | ), 79 | YasnoOutagesSensorDescription( 80 | key="status_today", 81 | translation_key="status_today", 82 | icon="mdi:calendar-today", 83 | device_class=SensorDeviceClass.ENUM, 84 | options=[ 85 | STATE_STATUS_SCHEDULE_APPLIES, 86 | STATE_STATUS_WAITING_FOR_SCHEDULE, 87 | STATE_STATUS_EMERGENCY_SHUTDOWNS, 88 | STATE_UNKNOWN, 89 | ], 90 | entity_category=EntityCategory.DIAGNOSTIC, 91 | val_func=lambda coordinator: coordinator.status_today, 92 | ), 93 | YasnoOutagesSensorDescription( 94 | key="status_tomorrow", 95 | translation_key="status_tomorrow", 96 | icon="mdi:calendar", 97 | device_class=SensorDeviceClass.ENUM, 98 | options=[ 99 | STATE_STATUS_SCHEDULE_APPLIES, 100 | STATE_STATUS_WAITING_FOR_SCHEDULE, 101 | STATE_STATUS_EMERGENCY_SHUTDOWNS, 102 | STATE_UNKNOWN, 103 | ], 104 | entity_category=EntityCategory.DIAGNOSTIC, 105 | val_func=lambda coordinator: coordinator.status_tomorrow, 106 | ), 107 | ) 108 | 109 | 110 | async def async_setup_entry( 111 | hass: HomeAssistant, # noqa: ARG001 112 | config_entry: YasnoOutagesConfigEntry, 113 | async_add_entities: AddEntitiesCallback, 114 | ) -> None: 115 | """Set up the Yasno outages calendar platform.""" 116 | LOGGER.debug("Setup new entry: %s", config_entry) 117 | coordinator = config_entry.runtime_data.coordinator 118 | async_add_entities( 119 | YasnoOutagesSensor(coordinator, description) for description in SENSOR_TYPES 120 | ) 121 | 122 | 123 | class YasnoOutagesSensor(YasnoOutagesEntity, SensorEntity): 124 | """Implementation of connection entity.""" 125 | 126 | entity_description: YasnoOutagesSensorDescription 127 | 128 | def __init__( 129 | self, 130 | coordinator: YasnoOutagesCoordinator, 131 | entity_description: YasnoOutagesSensorDescription, 132 | ) -> None: 133 | """Initialize the sensor.""" 134 | super().__init__(coordinator) 135 | self.entity_description = entity_description 136 | self._attr_unique_id = ( 137 | f"{coordinator.config_entry.entry_id}-" 138 | f"{coordinator.group}-" 139 | f"{self.entity_description.key}" 140 | ) 141 | 142 | @property 143 | def native_value(self) -> str | None: 144 | """Return the state of the sensor.""" 145 | return self.entity_description.val_func(self.coordinator) 146 | 147 | @property 148 | def extra_state_attributes(self) -> dict[str, Any] | None: 149 | """Return additional attributes for the electricity sensor.""" 150 | if self.entity_description.key != "electricity": 151 | return None 152 | # Get the current event to provide additional context 153 | event = self.coordinator.current_event 154 | return { 155 | ATTR_EVENT_TYPE: event.event_type.value if event else STATE_UNKNOWN, 156 | ATTR_EVENT_START: event.start.isoformat() if event else None, 157 | ATTR_EVENT_END: event.end.isoformat() if event else None, 158 | } 159 | -------------------------------------------------------------------------------- /custom_components/yasno_outages/api/base.py: -------------------------------------------------------------------------------- 1 | """Base API class for Yasno outages.""" 2 | 3 | import datetime 4 | import logging 5 | from abc import ABC, abstractmethod 6 | 7 | import aiohttp 8 | 9 | from .const import REGIONS_ENDPOINT 10 | from .models import OutageEvent, OutageEventType, OutageSlot, OutageSource 11 | 12 | LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | class BaseYasnoApi(ABC): 16 | """Base class for Yasno API interactions.""" 17 | 18 | def __init__( 19 | self, 20 | region_id: int | None = None, 21 | provider_id: int | None = None, 22 | group: str | None = None, 23 | ) -> None: 24 | """Initialize the BaseYasnoApi.""" 25 | self.region_id = region_id 26 | self.provider_id = provider_id 27 | self.group = group 28 | self.regions_data = None 29 | 30 | async def _get_data( 31 | self, 32 | session: aiohttp.ClientSession, 33 | url: str, 34 | timeout_secs: int = 60, 35 | ) -> dict | None: 36 | """Fetch data from the given URL.""" 37 | try: 38 | async with session.get( 39 | url, 40 | timeout=aiohttp.ClientTimeout(total=timeout_secs), 41 | ) as response: 42 | response.raise_for_status() 43 | return await response.json() 44 | except aiohttp.ClientError: 45 | LOGGER.exception("Error fetching data from %s", url) 46 | return None 47 | 48 | async def fetch_regions(self) -> None: 49 | """Fetch regions and providers data.""" 50 | async with aiohttp.ClientSession() as session: 51 | self.regions_data = await self._get_data(session, REGIONS_ENDPOINT) 52 | 53 | def get_regions(self) -> list[dict]: 54 | """Get a list of available regions.""" 55 | if not self.regions_data: 56 | return [] 57 | return self.regions_data 58 | 59 | def get_region_by_name(self, region_name: str) -> dict | None: 60 | """Get region data by name.""" 61 | for region in self.get_regions(): 62 | if region["value"] == region_name: 63 | return region 64 | return None 65 | 66 | def get_providers_for_region(self, region_name: str) -> list[dict]: 67 | """Get providers (dsos) for a specific region.""" 68 | region = self.get_region_by_name(region_name) 69 | if not region: 70 | return [] 71 | return region.get("dsos", []) 72 | 73 | def get_provider_by_name(self, region_name: str, provider_name: str) -> dict | None: 74 | """Get provider (dso) data by region and provider name.""" 75 | providers = self.get_providers_for_region(region_name) 76 | for provider in providers: 77 | if provider["name"] == provider_name: 78 | return provider 79 | return None 80 | 81 | def get_next_event( 82 | self, 83 | at: datetime.datetime, 84 | event_type: OutageEventType = OutageEventType.DEFINITE, 85 | lookahead_days: int = 1, 86 | ) -> OutageEvent | None: 87 | """Return outage event that starts after provided time.""" 88 | horizon = at + datetime.timedelta(days=lookahead_days) 89 | events = sorted( 90 | self.get_events_between(at, horizon), 91 | key=lambda event: event.start, 92 | ) 93 | 94 | for event in events: 95 | if event.event_type != event_type: 96 | continue 97 | if event.start > at: 98 | return event 99 | 100 | return None 101 | 102 | @staticmethod 103 | def minutes_to_time( 104 | minutes: int, 105 | date: datetime.datetime, 106 | ) -> datetime.datetime: 107 | """Convert minutes from start of day to datetime.""" 108 | hours = minutes // 60 109 | mins = minutes % 60 110 | # Handle end of day (24:00) - use midnight of next day 111 | if hours == 24: # noqa: PLR2004 112 | tomorrow = date + datetime.timedelta(days=1) 113 | return tomorrow.replace( 114 | hour=0, 115 | minute=0, 116 | second=0, 117 | microsecond=0, 118 | ) 119 | return date.replace(hour=hours, minute=mins, second=0, microsecond=0) 120 | 121 | @staticmethod 122 | def _parse_raw_slots(slots: list[dict]) -> list[OutageSlot]: 123 | """Parse raw slot dictionaries into OutageSlot objects.""" 124 | parsed_slots = [] 125 | for slot in slots: 126 | try: 127 | event_type = OutageEventType(slot["type"]) 128 | parsed_slots.append( 129 | OutageSlot( 130 | start=slot["start"], 131 | end=slot["end"], 132 | event_type=event_type, 133 | ), 134 | ) 135 | except (ValueError, KeyError) as err: 136 | LOGGER.warning("Failed to parse slot %s: %s", slot, err) 137 | continue 138 | return parsed_slots 139 | 140 | @staticmethod 141 | def _parse_slots_to_events( 142 | slots: list[OutageSlot], 143 | date: datetime.datetime, 144 | source: OutageSource, 145 | ) -> list[OutageEvent]: 146 | """Convert OutageSlot instances to OutageEvent instances for a given date.""" 147 | events = [] 148 | 149 | for slot in slots: 150 | event_start = BaseYasnoApi.minutes_to_time(slot.start, date) 151 | event_end = BaseYasnoApi.minutes_to_time(slot.end, date) 152 | 153 | events.append( 154 | OutageEvent( 155 | start=event_start, 156 | end=event_end, 157 | event_type=slot.event_type, 158 | source=source, 159 | ), 160 | ) 161 | 162 | return events 163 | 164 | @abstractmethod 165 | def get_current_event(self, at: datetime.datetime) -> OutageEvent | None: 166 | """Return outage event that is active at provided time.""" 167 | 168 | @abstractmethod 169 | def get_events_between( 170 | self, 171 | start_date: datetime.datetime, 172 | end_date: datetime.datetime, 173 | ) -> list[OutageEvent]: 174 | """Return outage events that intersect provided range.""" 175 | -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | """Tests for helpers module.""" 2 | 3 | import datetime 4 | 5 | from custom_components.yasno_outages.api import OutageEvent, OutageEventType 6 | from custom_components.yasno_outages.api.models import OutageSource 7 | from custom_components.yasno_outages.helpers import merge_consecutive_outages 8 | 9 | 10 | class TestMergeConsecutiveOutages: 11 | """Test merge_consecutive_outages function.""" 12 | 13 | def test_empty_list(self): 14 | """Test merging empty list returns empty list.""" 15 | result = merge_consecutive_outages([]) 16 | assert result == [] 17 | 18 | def test_single_event(self): 19 | """Test single event is returned as-is.""" 20 | event = OutageEvent( 21 | start=datetime.datetime(2025, 1, 27, 10, 0), 22 | end=datetime.datetime(2025, 1, 27, 12, 0), 23 | event_type=OutageEventType.DEFINITE, 24 | source=OutageSource.PLANNED, 25 | ) 26 | result = merge_consecutive_outages([event]) 27 | assert result == [event] 28 | 29 | def test_merge_consecutive_same_type(self): 30 | """Test merging consecutive events with same type and source.""" 31 | event1 = OutageEvent( 32 | start=datetime.datetime(2025, 1, 27, 10, 0), 33 | end=datetime.datetime(2025, 1, 27, 12, 0), 34 | event_type=OutageEventType.DEFINITE, 35 | source=OutageSource.PLANNED, 36 | ) 37 | event2 = OutageEvent( 38 | start=datetime.datetime(2025, 1, 27, 12, 0), 39 | end=datetime.datetime(2025, 1, 27, 14, 0), 40 | event_type=OutageEventType.DEFINITE, 41 | source=OutageSource.PLANNED, 42 | ) 43 | 44 | result = merge_consecutive_outages([event1, event2]) 45 | 46 | assert len(result) == 1 47 | assert result[0].start == event1.start 48 | assert result[0].end == event2.end 49 | assert result[0].event_type == OutageEventType.DEFINITE 50 | assert result[0].source == OutageSource.PLANNED 51 | 52 | def test_no_merge_non_consecutive(self): 53 | """Test non-consecutive events are not merged.""" 54 | event1 = OutageEvent( 55 | start=datetime.datetime(2025, 1, 27, 10, 0), 56 | end=datetime.datetime(2025, 1, 27, 12, 0), 57 | event_type=OutageEventType.DEFINITE, 58 | source=OutageSource.PLANNED, 59 | ) 60 | event2 = OutageEvent( 61 | start=datetime.datetime(2025, 1, 27, 13, 0), # Gap 62 | end=datetime.datetime(2025, 1, 27, 14, 0), 63 | event_type=OutageEventType.DEFINITE, 64 | source=OutageSource.PLANNED, 65 | ) 66 | 67 | result = merge_consecutive_outages([event1, event2]) 68 | 69 | assert len(result) == 2 70 | assert result[0] == event1 71 | assert result[1] == event2 72 | 73 | def test_no_merge_different_type(self): 74 | """Test consecutive events with different types are not merged.""" 75 | event1 = OutageEvent( 76 | start=datetime.datetime(2025, 1, 27, 10, 0), 77 | end=datetime.datetime(2025, 1, 27, 12, 0), 78 | event_type=OutageEventType.DEFINITE, 79 | source=OutageSource.PLANNED, 80 | ) 81 | event2 = OutageEvent( 82 | start=datetime.datetime(2025, 1, 27, 12, 0), 83 | end=datetime.datetime(2025, 1, 27, 14, 0), 84 | event_type=OutageEventType.NOT_PLANNED, 85 | source=OutageSource.PLANNED, 86 | ) 87 | 88 | result = merge_consecutive_outages([event1, event2]) 89 | 90 | assert len(result) == 2 91 | assert result[0] == event1 92 | assert result[1] == event2 93 | 94 | def test_no_merge_different_source(self): 95 | """Test consecutive events with different sources are not merged.""" 96 | event1 = OutageEvent( 97 | start=datetime.datetime(2025, 1, 27, 10, 0), 98 | end=datetime.datetime(2025, 1, 27, 12, 0), 99 | event_type=OutageEventType.DEFINITE, 100 | source=OutageSource.PLANNED, 101 | ) 102 | event2 = OutageEvent( 103 | start=datetime.datetime(2025, 1, 27, 12, 0), 104 | end=datetime.datetime(2025, 1, 27, 14, 0), 105 | event_type=OutageEventType.DEFINITE, 106 | source=OutageSource.PROBABLE, 107 | ) 108 | 109 | result = merge_consecutive_outages([event1, event2]) 110 | 111 | assert len(result) == 2 112 | assert result[0] == event1 113 | assert result[1] == event2 114 | 115 | def test_merge_multiple_consecutive(self): 116 | """Test merging multiple consecutive events.""" 117 | event1 = OutageEvent( 118 | start=datetime.datetime(2025, 1, 27, 10, 0), 119 | end=datetime.datetime(2025, 1, 27, 12, 0), 120 | event_type=OutageEventType.DEFINITE, 121 | source=OutageSource.PLANNED, 122 | ) 123 | event2 = OutageEvent( 124 | start=datetime.datetime(2025, 1, 27, 12, 0), 125 | end=datetime.datetime(2025, 1, 27, 14, 0), 126 | event_type=OutageEventType.DEFINITE, 127 | source=OutageSource.PLANNED, 128 | ) 129 | event3 = OutageEvent( 130 | start=datetime.datetime(2025, 1, 27, 14, 0), 131 | end=datetime.datetime(2025, 1, 27, 16, 0), 132 | event_type=OutageEventType.DEFINITE, 133 | source=OutageSource.PLANNED, 134 | ) 135 | 136 | result = merge_consecutive_outages([event1, event2, event3]) 137 | 138 | assert len(result) == 1 139 | assert result[0].start == event1.start 140 | assert result[0].end == event3.end 141 | 142 | def test_merge_mixed_consecutive_and_non_consecutive(self): 143 | """Test merging with mix of consecutive and non-consecutive events.""" 144 | event1 = OutageEvent( 145 | start=datetime.datetime(2025, 1, 27, 10, 0), 146 | end=datetime.datetime(2025, 1, 27, 12, 0), 147 | event_type=OutageEventType.DEFINITE, 148 | source=OutageSource.PLANNED, 149 | ) 150 | event2 = OutageEvent( 151 | start=datetime.datetime(2025, 1, 27, 12, 0), 152 | end=datetime.datetime(2025, 1, 27, 14, 0), 153 | event_type=OutageEventType.DEFINITE, 154 | source=OutageSource.PLANNED, 155 | ) 156 | event3 = OutageEvent( 157 | start=datetime.datetime(2025, 1, 27, 16, 0), # Gap 158 | end=datetime.datetime(2025, 1, 27, 18, 0), 159 | event_type=OutageEventType.DEFINITE, 160 | source=OutageSource.PLANNED, 161 | ) 162 | 163 | result = merge_consecutive_outages([event1, event2, event3]) 164 | 165 | assert len(result) == 2 166 | assert result[0].start == event1.start 167 | assert result[0].end == event2.end 168 | assert result[1] == event3 169 | -------------------------------------------------------------------------------- /tests/test_calendar.py: -------------------------------------------------------------------------------- 1 | """Tests for calendar functionality.""" 2 | 3 | import datetime 4 | from unittest.mock import MagicMock 5 | from zoneinfo import ZoneInfo 6 | 7 | import pytest 8 | from homeassistant.helpers.entity import EntityDescription 9 | 10 | from custom_components.yasno_outages.api import OutageEvent, OutageEventType 11 | from custom_components.yasno_outages.api.models import OutageSource 12 | from custom_components.yasno_outages.calendar import ( 13 | YasnoPlannedOutagesCalendar, 14 | async_setup_entry, 15 | to_all_day_calendar_event, 16 | to_calendar_event, 17 | ) 18 | 19 | UTC = ZoneInfo("UTC") 20 | 21 | 22 | @pytest.fixture 23 | def coordinator(): 24 | """Create a mock coordinator for testing.""" 25 | coordinator = MagicMock() 26 | coordinator.api = MagicMock() 27 | coordinator.config_entry = MagicMock() 28 | coordinator.config_entry.entry_id = "test_entry" 29 | coordinator.config_entry.data = { 30 | "region": "Київ", 31 | "provider": "ДТЕК", 32 | "group": "3.1", 33 | } 34 | coordinator.region_name = "Київ" 35 | coordinator.provider_name = "ДТЕК" 36 | coordinator.group = "3.1" 37 | coordinator.event_summary_map = { 38 | OutageSource.PLANNED: "Planned Outage", 39 | OutageSource.PROBABLE: "Probable Outage", 40 | } 41 | coordinator.status_event_summary_map = { 42 | "ScheduleApplies": "Schedule Applies", 43 | "EmergencyShutdowns": "Emergency Shutdowns", 44 | } 45 | coordinator.status_all_day_events_enabled = False 46 | 47 | # Mock methods to return specific values 48 | def get_planned_outage_at_mock(*_args, **_kwargs): 49 | return None 50 | 51 | def get_planned_events_between_mock(*_args, **_kwargs): 52 | return [] 53 | 54 | def get_status_mock(*_args, **_kwargs): 55 | return None 56 | 57 | def get_date_mock(*_args, **_kwargs): 58 | return None 59 | 60 | coordinator.get_planned_outage_at = get_planned_outage_at_mock 61 | coordinator.get_planned_events_between = get_planned_events_between_mock 62 | coordinator.get_status_today = get_status_mock 63 | coordinator.get_status_tomorrow = get_status_mock 64 | coordinator.get_today_date = get_date_mock 65 | coordinator.get_tomorrow_date = get_date_mock 66 | return coordinator 67 | 68 | 69 | class TestToCalendarEvent: 70 | """Test to_calendar_event function.""" 71 | 72 | def test_convert_planned_outage_event(self, coordinator): 73 | """Test converting planned outage event to calendar event.""" 74 | event = OutageEvent( 75 | start=datetime.datetime(2025, 1, 27, 10, 0, tzinfo=UTC), 76 | end=datetime.datetime(2025, 1, 27, 12, 0, tzinfo=UTC), 77 | event_type=OutageEventType.DEFINITE, 78 | source=OutageSource.PLANNED, 79 | ) 80 | 81 | calendar_event = to_calendar_event(coordinator, event) 82 | 83 | assert calendar_event.summary == "Planned Outage" 84 | assert calendar_event.start == event.start 85 | assert calendar_event.end == event.end 86 | assert calendar_event.description == "Definite" 87 | assert calendar_event.uid == f"planned-{event.start.isoformat()}" 88 | 89 | def test_convert_probable_outage_event(self, coordinator): 90 | """Test converting probable outage event to calendar event.""" 91 | event = OutageEvent( 92 | start=datetime.datetime(2025, 1, 27, 10, 0, tzinfo=UTC), 93 | end=datetime.datetime(2025, 1, 27, 12, 0, tzinfo=UTC), 94 | event_type=OutageEventType.DEFINITE, 95 | source=OutageSource.PROBABLE, 96 | ) 97 | 98 | calendar_event = to_calendar_event(coordinator, event) 99 | 100 | assert calendar_event.summary == "Probable Outage" 101 | assert calendar_event.start == event.start 102 | assert calendar_event.end == event.end 103 | assert calendar_event.description == "Definite" 104 | assert calendar_event.uid == f"probable-{event.start.isoformat()}" 105 | 106 | def test_event_without_source_defaults_to_planned(self, coordinator): 107 | """Test event without source defaults to planned.""" 108 | event = OutageEvent( 109 | start=datetime.datetime(2025, 1, 27, 10, 0, tzinfo=UTC), 110 | end=datetime.datetime(2025, 1, 27, 12, 0, tzinfo=UTC), 111 | event_type=OutageEventType.DEFINITE, 112 | source=None, 113 | ) 114 | 115 | calendar_event = to_calendar_event(coordinator, event) 116 | 117 | assert calendar_event.summary == "Planned Outage" 118 | assert calendar_event.uid.startswith("planned-") 119 | 120 | 121 | class TestToAllDayCalendarEvent: 122 | """Test to_all_day_calendar_event function.""" 123 | 124 | def test_convert_status_to_all_day_event(self, coordinator): 125 | """Test converting status to all-day calendar event.""" 126 | date = datetime.date(2025, 1, 27) 127 | status = "ScheduleApplies" 128 | 129 | calendar_event = to_all_day_calendar_event(coordinator, date, status) 130 | 131 | assert calendar_event.summary == "Schedule Applies" 132 | assert calendar_event.start == date 133 | assert calendar_event.end == date + datetime.timedelta(days=1) 134 | assert calendar_event.description == status 135 | assert calendar_event.uid == f"status-{date.isoformat()}" 136 | 137 | def test_unknown_status_uses_status_text(self, coordinator): 138 | """Test unknown status uses status text as summary.""" 139 | date = datetime.date(2025, 1, 27) 140 | status = "UnknownStatus" 141 | 142 | calendar_event = to_all_day_calendar_event(coordinator, date, status) 143 | 144 | assert calendar_event.summary == "UnknownStatus" 145 | assert calendar_event.description == "UnknownStatus" 146 | 147 | 148 | class TestYasnoPlannedOutagesCalendar: 149 | """Test YasnoPlannedOutagesCalendar entity. 150 | 151 | Note: Full entity tests are skipped due to complex Home Assistant 152 | mocking requirements. Entity behavior is tested through integration 153 | tests with real HA environment. 154 | """ 155 | 156 | def test_entity_description_properties(self): 157 | """Test entity description has correct properties.""" 158 | # Test we can create entity description 159 | desc = EntityDescription( 160 | key="planned_outages", 161 | name="Planned Outages", 162 | translation_key="planned_outages", 163 | ) 164 | 165 | assert desc.key == "planned_outages" 166 | assert desc.name == "Planned Outages" 167 | assert desc.translation_key == "planned_outages" 168 | 169 | 170 | class TestCalendarSetup: 171 | """Test calendar setup functionality.""" 172 | 173 | async def test_async_setup_entry(self, coordinator): 174 | """Test async_setup_entry creates calendar entity.""" 175 | config_entry = MagicMock() 176 | config_entry.runtime_data = MagicMock() 177 | config_entry.runtime_data.coordinator = coordinator 178 | 179 | hass = MagicMock() 180 | async_add_entities = MagicMock() 181 | 182 | await async_setup_entry(hass, config_entry, async_add_entities) 183 | 184 | assert async_add_entities.call_count == 1 185 | entities = async_add_entities.call_args[0][0] 186 | 187 | assert len(entities) == 2 188 | assert isinstance(entities[0], YasnoPlannedOutagesCalendar) 189 | assert entities[0].coordinator == coordinator 190 | -------------------------------------------------------------------------------- /custom_components/yasno_outages/api/planned.py: -------------------------------------------------------------------------------- 1 | """Planned outages API for Yasno.""" 2 | 3 | import datetime 4 | import logging 5 | from typing import Literal 6 | 7 | import aiohttp 8 | 9 | from .base import BaseYasnoApi 10 | from .const import ( 11 | API_KEY_DATE, 12 | API_KEY_STATUS, 13 | API_KEY_TODAY, 14 | API_KEY_TOMORROW, 15 | API_KEY_UPDATED_ON, 16 | PLANNED_OUTAGES_ENDPOINT, 17 | ) 18 | from .models import OutageEvent, OutageSource 19 | 20 | LOGGER = logging.getLogger(__name__) 21 | 22 | 23 | class PlannedOutagesApi(BaseYasnoApi): 24 | """API for fetching planned outages data.""" 25 | 26 | def __init__( 27 | self, 28 | region_id: int | None = None, 29 | provider_id: int | None = None, 30 | group: str | None = None, 31 | ) -> None: 32 | """Initialize the PlannedOutagesApi.""" 33 | super().__init__(region_id, provider_id, group) 34 | self.planned_outages_data = None 35 | 36 | async def fetch_planned_outages_data(self) -> None: 37 | """Fetch planned outages data for the configured region and provider.""" 38 | if not self.region_id or not self.provider_id: 39 | LOGGER.warning( 40 | "Region ID and Provider ID must be set before fetching planned outages", 41 | ) 42 | return 43 | 44 | url = PLANNED_OUTAGES_ENDPOINT.format( 45 | region_id=self.region_id, 46 | dso_id=self.provider_id, 47 | ) 48 | 49 | async with aiohttp.ClientSession() as session: 50 | self.planned_outages_data = await self._get_data(session, url) 51 | 52 | def get_groups(self) -> list[str]: 53 | """Get groups from planned outages data.""" 54 | if not self.planned_outages_data: 55 | return [] 56 | return list(self.planned_outages_data.keys()) 57 | 58 | def get_planned_outages_data(self) -> dict | None: 59 | """Get data for the configured group.""" 60 | if not self.planned_outages_data or self.group not in self.planned_outages_data: 61 | return None 62 | return self.planned_outages_data[self.group] 63 | 64 | def get_planned_dates(self) -> list[datetime.date]: 65 | """Get dates for which planned schedule is available.""" 66 | dates = [] 67 | group_data = self.get_planned_outages_data() 68 | if not group_data: 69 | return dates 70 | 71 | for day_key in (API_KEY_TODAY, API_KEY_TOMORROW): 72 | if day_key not in group_data: 73 | continue 74 | 75 | day_data = group_data[day_key] 76 | if API_KEY_DATE not in day_data: 77 | continue 78 | 79 | day_date = datetime.datetime.fromisoformat(day_data[API_KEY_DATE]).date() 80 | dates.append(day_date) 81 | 82 | return dates 83 | 84 | def _parse_day_schedule( 85 | self, 86 | day_data: dict, 87 | date: datetime.datetime, 88 | ) -> list[OutageEvent]: 89 | """Parse schedule for a single day.""" 90 | slots = self._parse_raw_slots(day_data.get("slots", [])) 91 | return self._parse_slots_to_events(slots, date, OutageSource.PLANNED) 92 | 93 | def _parse_day_events( 94 | self, 95 | group_data: dict, 96 | day_key: str, 97 | ) -> list[OutageEvent]: 98 | """Parse events for a specific day (today or tomorrow) from group data.""" 99 | if day_key not in group_data: 100 | return [] 101 | 102 | day_data = group_data[day_key] 103 | if API_KEY_DATE not in day_data: 104 | return [] 105 | 106 | day_date = datetime.datetime.fromisoformat(day_data[API_KEY_DATE]) 107 | return self._parse_day_schedule(day_data, day_date) 108 | 109 | def get_updated_on(self) -> datetime.datetime | None: 110 | """Get the updated on timestamp for the configured group.""" 111 | group_data = self.get_planned_outages_data() 112 | if not group_data or API_KEY_UPDATED_ON not in group_data: 113 | return None 114 | return datetime.datetime.fromisoformat(group_data[API_KEY_UPDATED_ON]) 115 | 116 | def get_data_by_day(self, day: Literal["today", "tomorrow"]) -> dict | None: 117 | """Get the data for a specific day.""" 118 | group_data = self.get_planned_outages_data() 119 | if not group_data or day not in group_data: 120 | return None 121 | 122 | return group_data[day] 123 | 124 | def get_status_by_day(self, day: Literal["today", "tomorrow"]) -> str | None: 125 | """Get the status for a specific day.""" 126 | day_data = self.get_data_by_day(day) 127 | if not day_data: 128 | return None 129 | 130 | return day_data.get(API_KEY_STATUS) 131 | 132 | def get_status_today(self) -> str | None: 133 | """Get the status for today.""" 134 | return self.get_status_by_day(API_KEY_TODAY) 135 | 136 | def get_status_tomorrow(self) -> str | None: 137 | """Get the status for tomorrow.""" 138 | return self.get_status_by_day(API_KEY_TOMORROW) 139 | 140 | def get_date_by_day( 141 | self, day: Literal["today", "tomorrow"] 142 | ) -> datetime.date | None: 143 | """Get the date for a specific day.""" 144 | day_data = self.get_data_by_day(day) 145 | if not day_data or API_KEY_DATE not in day_data: 146 | return None 147 | 148 | return datetime.datetime.fromisoformat(day_data[API_KEY_DATE]).date() 149 | 150 | def get_today_date(self) -> datetime.date | None: 151 | """Get today's date.""" 152 | return self.get_date_by_day(API_KEY_TODAY) 153 | 154 | def get_tomorrow_date(self) -> datetime.date | None: 155 | """Get tomorrow's date.""" 156 | return self.get_date_by_day(API_KEY_TOMORROW) 157 | 158 | def get_current_event(self, at: datetime.datetime) -> OutageEvent | None: 159 | """Get the current event.""" 160 | all_events = self.get_events_between(at, at + datetime.timedelta(days=1)) 161 | for event in all_events: 162 | if event.start <= at < event.end: 163 | return event 164 | return None 165 | 166 | def get_events_between( 167 | self, 168 | start_date: datetime.datetime, 169 | end_date: datetime.datetime, 170 | ) -> list[OutageEvent]: 171 | """Get all events within the date range.""" 172 | if not self.planned_outages_data or self.group not in self.planned_outages_data: 173 | return [] 174 | 175 | events = [] 176 | group_data = self.get_planned_outages_data() 177 | if not group_data: 178 | return events 179 | 180 | # Parse today and tomorrow events using the helper function 181 | events.extend(self._parse_day_events(group_data, API_KEY_TODAY)) 182 | events.extend(self._parse_day_events(group_data, API_KEY_TOMORROW)) 183 | 184 | # Sort events by start time and filter by date range 185 | events = sorted(events, key=lambda event: event.start) 186 | 187 | # Filter events that intersect with the requested range 188 | return [ 189 | event 190 | for event in events 191 | if ( 192 | start_date <= event.start <= end_date 193 | or start_date <= event.end <= end_date 194 | or event.start <= start_date <= event.end 195 | or event.start <= end_date <= event.end 196 | ) 197 | ] 198 | 199 | async def fetch_data(self) -> None: 200 | """Fetch all required data.""" 201 | # Regions are fetched by _resolve_ids, so only fetch planned outages 202 | await self.fetch_planned_outages_data() 203 | -------------------------------------------------------------------------------- /custom_components/yasno_outages/calendar.py: -------------------------------------------------------------------------------- 1 | """Calendar platform for Yasno outages integration.""" 2 | 3 | import datetime 4 | import logging 5 | 6 | from homeassistant.components.calendar import CalendarEntity, CalendarEvent 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers.entity import EntityDescription 9 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 10 | from homeassistant.util import dt as dt_utils 11 | 12 | from .api import OutageEvent 13 | from .api.models import OutageSource 14 | from .coordinator import YasnoOutagesCoordinator 15 | from .data import YasnoOutagesConfigEntry 16 | from .entity import YasnoOutagesEntity 17 | from .helpers import merge_consecutive_outages 18 | 19 | LOGGER = logging.getLogger(__name__) 20 | 21 | 22 | def to_calendar_event( 23 | coordinator: YasnoOutagesCoordinator, 24 | event: OutageEvent, 25 | ) -> CalendarEvent: 26 | """Convert OutageEvent into Home Assistant CalendarEvent.""" 27 | source = event.source or OutageSource.PLANNED 28 | summary = coordinator.event_summary_map.get(source, "Outage") 29 | calendar_event = CalendarEvent( 30 | summary=summary, 31 | start=event.start, 32 | end=event.end, 33 | description=event.event_type.value, 34 | uid=f"{source.value}-{event.start.isoformat()}", 35 | ) 36 | LOGGER.debug("Calendar Event: %s", calendar_event) 37 | return calendar_event 38 | 39 | 40 | def to_all_day_calendar_event( 41 | coordinator: YasnoOutagesCoordinator, 42 | date: datetime.date, 43 | status: str, 44 | ) -> CalendarEvent: 45 | """Convert status into Home Assistant all-day CalendarEvent.""" 46 | summary = coordinator.status_event_summary_map.get(status, status) 47 | calendar_event = CalendarEvent( 48 | summary=summary, 49 | start=date, 50 | end=date + datetime.timedelta(days=1), 51 | description=status, 52 | uid=f"status-{date.isoformat()}", 53 | ) 54 | LOGGER.debug("All-day event: %s", calendar_event) 55 | return calendar_event 56 | 57 | 58 | async def async_setup_entry( 59 | hass: HomeAssistant, # noqa: ARG001 60 | config_entry: YasnoOutagesConfigEntry, 61 | async_add_entities: AddEntitiesCallback, 62 | ) -> None: 63 | """Set up the Yasno outages calendar platform.""" 64 | LOGGER.debug("Setup new entry: %s", config_entry) 65 | coordinator = config_entry.runtime_data.coordinator 66 | async_add_entities( 67 | [ 68 | YasnoPlannedOutagesCalendar(coordinator), 69 | YasnoProbableOutagesCalendar(coordinator), 70 | ] 71 | ) 72 | 73 | 74 | class YasnoPlannedOutagesCalendar(YasnoOutagesEntity, CalendarEntity): 75 | """Implementation of planned outages calendar entity.""" 76 | 77 | def __init__( 78 | self, 79 | coordinator: YasnoOutagesCoordinator, 80 | ) -> None: 81 | """Initialize the YasnoPlannedOutagesCalendar entity.""" 82 | super().__init__(coordinator) 83 | self.entity_description = EntityDescription( 84 | key="planned_outages", 85 | name="Planned Outages", 86 | translation_key="planned_outages", 87 | ) 88 | self._attr_unique_id = ( 89 | f"{coordinator.config_entry.entry_id}-" 90 | f"{coordinator.group}-" 91 | f"{self.entity_description.key}" 92 | ) 93 | 94 | @property 95 | def event(self) -> CalendarEvent | None: 96 | """Return the current or next upcoming event or None.""" 97 | LOGGER.debug("Getting planned event at now") 98 | outage_event = self.coordinator.get_planned_outage_at(dt_utils.now()) 99 | if not outage_event: 100 | return None 101 | return to_calendar_event(self.coordinator, outage_event) 102 | 103 | def get_all_day_status_event( 104 | self, 105 | date: datetime.date | None, 106 | status: str | None, 107 | start_date: datetime.datetime, 108 | end_date: datetime.datetime, 109 | ) -> CalendarEvent | None: 110 | """Create a status event for a specific date.""" 111 | if date and status and start_date.date() <= date <= end_date.date(): 112 | return to_all_day_calendar_event(self.coordinator, date, status) 113 | return None 114 | 115 | async def async_get_events( 116 | self, 117 | hass: HomeAssistant, # noqa: ARG002 118 | start_date: datetime.datetime, 119 | end_date: datetime.datetime, 120 | ) -> list[CalendarEvent]: 121 | """Return calendar events within a datetime range.""" 122 | LOGGER.debug( 123 | 'Getting planned events between "%s" -> "%s"', start_date, end_date 124 | ) 125 | events = self.coordinator.get_planned_events_between(start_date, end_date) 126 | events = merge_consecutive_outages(events) 127 | 128 | calendar_events = [ 129 | to_calendar_event(self.coordinator, event) for event in events 130 | ] 131 | 132 | if self.coordinator.status_all_day_events: 133 | if today_status := self.get_all_day_status_event( 134 | self.coordinator.today_date, 135 | self.coordinator.status_today, 136 | start_date, 137 | end_date, 138 | ): 139 | calendar_events.append(today_status) 140 | 141 | if tomorrow_status := self.get_all_day_status_event( 142 | self.coordinator.tomorrow_date, 143 | self.coordinator.status_tomorrow, 144 | start_date, 145 | end_date, 146 | ): 147 | calendar_events.append(tomorrow_status) 148 | 149 | return calendar_events 150 | 151 | 152 | class YasnoProbableOutagesCalendar(YasnoOutagesEntity, CalendarEntity): 153 | """Implementation of probable outages calendar entity.""" 154 | 155 | def __init__( 156 | self, 157 | coordinator: YasnoOutagesCoordinator, 158 | ) -> None: 159 | """Initialize the YasnoProbableOutagesCalendar entity.""" 160 | super().__init__(coordinator) 161 | self.entity_description = EntityDescription( 162 | key="probable_outages", 163 | name="Probable Outages", 164 | translation_key="probable_outages", 165 | ) 166 | self._attr_unique_id = ( 167 | f"{coordinator.config_entry.entry_id}-" 168 | f"{coordinator.group}-" 169 | f"{self.entity_description.key}" 170 | ) 171 | 172 | @property 173 | def event(self) -> CalendarEvent | None: 174 | """Return the current or next upcoming probable event or None.""" 175 | LOGGER.debug("Getting probable event at now") 176 | outage_event = self.coordinator.get_probable_outage_at(dt_utils.now()) 177 | if not outage_event: 178 | return None 179 | 180 | if self.coordinator.filter_probable: 181 | planned_dates = self.coordinator.get_planned_dates() 182 | if outage_event.start.date() in planned_dates: 183 | return None 184 | 185 | return to_calendar_event(self.coordinator, outage_event) 186 | 187 | async def async_get_events( 188 | self, 189 | hass: HomeAssistant, # noqa: ARG002 190 | start_date: datetime.datetime, 191 | end_date: datetime.datetime, 192 | ) -> list[CalendarEvent]: 193 | """Return probable calendar events within a datetime range.""" 194 | LOGGER.debug( 195 | 'Getting probable events between "%s" -> "%s"', start_date, end_date 196 | ) 197 | events = self.coordinator.get_probable_events_between(start_date, end_date) 198 | 199 | # Filter out probable events on days with planned outages if configured 200 | if self.coordinator.filter_probable: 201 | planned_dates = self.coordinator.get_planned_dates() 202 | events = [ 203 | event for event in events if event.start.date() not in planned_dates 204 | ] 205 | 206 | events = merge_consecutive_outages(events) 207 | 208 | return [to_calendar_event(self.coordinator, event) for event in events] 209 | -------------------------------------------------------------------------------- /custom_components/yasno_outages/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Yasno Outages integration.""" 2 | 3 | import logging 4 | from typing import Any 5 | 6 | import voluptuous as vol 7 | from homeassistant.config_entries import ( 8 | ConfigEntry, 9 | ConfigFlow, 10 | ConfigFlowResult, 11 | OptionsFlow, 12 | ) 13 | from homeassistant.core import callback 14 | from homeassistant.helpers.selector import ( 15 | SelectSelector, 16 | SelectSelectorConfig, 17 | ) 18 | 19 | from .api import YasnoApi 20 | from .const import ( 21 | CONF_FILTER_PROBABLE, 22 | CONF_GROUP, 23 | CONF_PROVIDER, 24 | CONF_REGION, 25 | CONF_STATUS_ALL_DAY_EVENTS, 26 | DOMAIN, 27 | ) 28 | 29 | LOGGER = logging.getLogger(__name__) 30 | 31 | 32 | def get_config_value( 33 | entry: ConfigEntry | None, 34 | key: str, 35 | default: Any = None, 36 | ) -> Any: 37 | """Get a value from the config entry or default.""" 38 | if entry is not None: 39 | return entry.options.get(key, entry.data.get(key, default)) 40 | return default 41 | 42 | 43 | def build_entry_title(data: dict[str, Any]) -> str: 44 | """Build a descriptive title from region, provider, and group.""" 45 | return f"Yasno {data[CONF_REGION]} {data[CONF_PROVIDER]} {data[CONF_GROUP]}" 46 | 47 | 48 | def build_region_schema( 49 | api: YasnoApi, 50 | config_entry: ConfigEntry | None, 51 | ) -> vol.Schema: 52 | """Build the schema for the region selection step.""" 53 | regions = api.get_regions() 54 | region_options = [region["value"] for region in regions] 55 | return vol.Schema( 56 | { 57 | vol.Required( 58 | CONF_REGION, 59 | default=get_config_value(config_entry, CONF_REGION), 60 | ): SelectSelector( 61 | SelectSelectorConfig( 62 | options=region_options, 63 | translation_key="region", 64 | ), 65 | ), 66 | }, 67 | ) 68 | 69 | 70 | def build_provider_schema( 71 | api: YasnoApi, 72 | config_entry: ConfigEntry | None, 73 | data: dict, 74 | ) -> vol.Schema: 75 | """Build the schema for the provider selection step.""" 76 | region = data[CONF_REGION] 77 | providers = api.get_providers_for_region(region) 78 | provider_options = [provider["name"] for provider in providers] 79 | 80 | return vol.Schema( 81 | { 82 | vol.Required( 83 | CONF_PROVIDER, 84 | default=get_config_value(config_entry, CONF_PROVIDER), 85 | ): SelectSelector( 86 | SelectSelectorConfig( 87 | options=provider_options, 88 | translation_key="provider", 89 | ), 90 | ), 91 | }, 92 | ) 93 | 94 | 95 | def build_group_schema( 96 | groups: list[str], 97 | config_entry: ConfigEntry | None, 98 | ) -> vol.Schema: 99 | """Build the schema for the group selection step.""" 100 | return vol.Schema( 101 | { 102 | vol.Required( 103 | CONF_GROUP, 104 | default=get_config_value(config_entry, CONF_GROUP), 105 | ): SelectSelector( 106 | SelectSelectorConfig( 107 | options=groups, 108 | translation_key="group", 109 | ), 110 | ), 111 | vol.Required( 112 | CONF_FILTER_PROBABLE, 113 | default=get_config_value( 114 | config_entry, CONF_FILTER_PROBABLE, default=True 115 | ), 116 | ): bool, 117 | vol.Required( 118 | CONF_STATUS_ALL_DAY_EVENTS, 119 | default=get_config_value( 120 | config_entry, 121 | CONF_STATUS_ALL_DAY_EVENTS, 122 | default=True, 123 | ), 124 | ): bool, 125 | }, 126 | ) 127 | 128 | 129 | class YasnoOutagesOptionsFlow(OptionsFlow): 130 | """Handle options flow for Yasno Outages.""" 131 | 132 | def __init__(self) -> None: 133 | """Initialize options flow.""" 134 | self.api = YasnoApi() 135 | self.data: dict[str, Any] = {} 136 | 137 | async def async_step_init(self, user_input: dict | None = None) -> ConfigFlowResult: 138 | """Handle the region change.""" 139 | if user_input is not None: 140 | LOGGER.debug("Updating options: %s", user_input) 141 | self.data.update(user_input) 142 | return await self.async_step_provider() 143 | 144 | await self.api.fetch_regions() 145 | 146 | LOGGER.debug("Options: %s", self.config_entry.options) 147 | LOGGER.debug("Data: %s", self.config_entry.data) 148 | 149 | return self.async_show_form( 150 | step_id="init", 151 | data_schema=build_region_schema( 152 | api=self.api, config_entry=self.config_entry 153 | ), 154 | ) 155 | 156 | async def async_step_provider( 157 | self, 158 | user_input: dict | None = None, 159 | ) -> ConfigFlowResult: 160 | """Handle the provider change.""" 161 | if user_input is not None: 162 | LOGGER.debug("Provider selected: %s", user_input) 163 | self.data.update(user_input) 164 | return await self.async_step_group() 165 | 166 | return self.async_show_form( 167 | step_id="provider", 168 | data_schema=build_provider_schema( 169 | api=self.api, 170 | config_entry=self.config_entry, 171 | data=self.data, 172 | ), 173 | ) 174 | 175 | async def async_step_group( 176 | self, 177 | user_input: dict | None = None, 178 | ) -> ConfigFlowResult: 179 | """Handle the group change.""" 180 | if user_input is not None: 181 | LOGGER.debug("Group selected: %s", user_input) 182 | self.data.update(user_input) 183 | # Update entry title along with options 184 | self.hass.config_entries.async_update_entry( 185 | self.config_entry, 186 | title=build_entry_title(self.data), 187 | ) 188 | return self.async_create_entry(title="", data=self.data) 189 | 190 | # Fetch groups for the selected region/provider 191 | region = self.data[CONF_REGION] 192 | provider = self.data[CONF_PROVIDER] 193 | 194 | region_data = self.api.get_region_by_name(region) 195 | provider_data = self.api.get_provider_by_name(region, provider) 196 | groups = [] 197 | if region_data and provider_data: 198 | temp_api = YasnoApi( 199 | region_id=region_data["id"], 200 | provider_id=provider_data["id"], 201 | ) 202 | await temp_api.planned.fetch_planned_outages_data() 203 | groups = temp_api.planned.get_groups() 204 | 205 | return self.async_show_form( 206 | step_id="group", 207 | data_schema=build_group_schema(groups, self.config_entry), 208 | ) 209 | 210 | 211 | class YasnoOutagesConfigFlow(ConfigFlow, domain=DOMAIN): 212 | """Handle a config flow for Yasno Outages.""" 213 | 214 | VERSION = 2 215 | MINOR_VERSION = 0 216 | 217 | def __init__(self) -> None: 218 | """Initialize config flow.""" 219 | self.api = YasnoApi() 220 | self.data: dict[str, Any] = {} 221 | 222 | @staticmethod 223 | @callback 224 | def async_get_options_flow(config_entry: ConfigEntry) -> YasnoOutagesOptionsFlow: # noqa: ARG004 225 | """Get the options flow for this handler.""" 226 | return YasnoOutagesOptionsFlow() 227 | 228 | async def async_step_user(self, user_input: dict | None = None) -> ConfigFlowResult: 229 | """Handle the initial step.""" 230 | if user_input is not None: 231 | LOGGER.debug("Region selected: %s", user_input) 232 | self.data.update(user_input) 233 | return await self.async_step_provider() 234 | 235 | await self.api.fetch_regions() 236 | 237 | return self.async_show_form( 238 | step_id="user", 239 | data_schema=build_region_schema(api=self.api, config_entry=None), 240 | ) 241 | 242 | async def async_step_provider( 243 | self, 244 | user_input: dict | None = None, 245 | ) -> ConfigFlowResult: 246 | """Handle the provider step.""" 247 | if user_input is not None: 248 | LOGGER.debug("Provider selected: %s", user_input) 249 | self.data.update(user_input) 250 | return await self.async_step_group() 251 | 252 | region = self.data[CONF_REGION] 253 | providers = self.api.get_providers_for_region(region) 254 | 255 | # If only one provider available, auto-select it and proceed 256 | if len(providers) == 1: 257 | provider_name = providers[0]["name"] 258 | LOGGER.debug("Auto-selecting only available provider: %s", provider_name) 259 | self.data[CONF_PROVIDER] = provider_name 260 | return await self.async_step_group() 261 | 262 | return self.async_show_form( 263 | step_id="provider", 264 | data_schema=build_provider_schema( 265 | api=self.api, 266 | config_entry=None, 267 | data=self.data, 268 | ), 269 | ) 270 | 271 | async def async_step_group( 272 | self, 273 | user_input: dict | None = None, 274 | ) -> ConfigFlowResult: 275 | """Handle the group step.""" 276 | if user_input is not None: 277 | LOGGER.debug("User input: %s", user_input) 278 | self.data.update(user_input) 279 | title = build_entry_title(self.data) 280 | return self.async_create_entry(title=title, data=self.data) 281 | 282 | # Fetch groups for the selected region/provider 283 | region = self.data[CONF_REGION] 284 | provider = self.data[CONF_PROVIDER] 285 | 286 | region_data = self.api.get_region_by_name(region) 287 | provider_data = self.api.get_provider_by_name(region, provider) 288 | groups = [] 289 | if region_data and provider_data: 290 | temp_api = YasnoApi( 291 | region_id=region_data["id"], 292 | provider_id=provider_data["id"], 293 | ) 294 | await temp_api.planned.fetch_planned_outages_data() 295 | groups = temp_api.planned.get_groups() 296 | 297 | return self.async_show_form( 298 | step_id="group", 299 | data_schema=build_group_schema(groups, None), 300 | ) 301 | -------------------------------------------------------------------------------- /tests/api/test_base.py: -------------------------------------------------------------------------------- 1 | """Tests for Base Yasno API.""" 2 | 3 | import datetime 4 | from unittest.mock import AsyncMock, MagicMock, patch 5 | 6 | import aiohttp 7 | import pytest 8 | 9 | from custom_components.yasno_outages.api.base import BaseYasnoApi 10 | from custom_components.yasno_outages.api.models import ( 11 | OutageEvent, 12 | OutageEventType, 13 | OutageSlot, 14 | OutageSource, 15 | ) 16 | 17 | TEST_REGION_ID = 25 18 | TEST_PROVIDER_ID = 902 19 | TEST_GROUP = "3.1" 20 | 21 | 22 | # Create a concrete implementation for testing 23 | class ConcreteYasnoApi(BaseYasnoApi): 24 | """Concrete implementation of BaseYasnoApi for testing.""" 25 | 26 | def __init__( 27 | self, 28 | region_id: int | None = None, 29 | provider_id: int | None = None, 30 | group: str | None = None, 31 | ) -> None: 32 | """Initialize the TestableYasnoApi.""" 33 | super().__init__(region_id, provider_id, group) 34 | self._events = [] 35 | 36 | def get_current_event(self, at: datetime.datetime) -> OutageEvent | None: 37 | """Return outage event that is active at provided time.""" 38 | for event in self._events: 39 | if event.start <= at < event.end: 40 | return event 41 | return None 42 | 43 | def get_events_between( 44 | self, 45 | start_date: datetime.datetime, 46 | end_date: datetime.datetime, 47 | ) -> list[OutageEvent]: 48 | """Return outage events that intersect provided range.""" 49 | return [ 50 | event 51 | for event in self._events 52 | if ( 53 | start_date <= event.start <= end_date 54 | or start_date <= event.end <= end_date 55 | or event.start <= start_date <= event.end 56 | or event.start <= end_date <= event.end 57 | ) 58 | ] 59 | 60 | 61 | @pytest.fixture(name="api") 62 | def _api(): 63 | """Create an API instance.""" 64 | return ConcreteYasnoApi( 65 | region_id=TEST_REGION_ID, provider_id=TEST_PROVIDER_ID, group=TEST_GROUP 66 | ) 67 | 68 | 69 | class TestBaseYasnoApiInit: 70 | """Test BaseYasnoApi initialization.""" 71 | 72 | def test_init_with_params(self): 73 | """Test initialization with parameters.""" 74 | api = ConcreteYasnoApi( 75 | region_id=TEST_REGION_ID, provider_id=TEST_PROVIDER_ID, group=TEST_GROUP 76 | ) 77 | assert api.region_id == TEST_REGION_ID 78 | assert api.provider_id == TEST_PROVIDER_ID 79 | assert api.group == TEST_GROUP 80 | assert api.regions_data is None 81 | 82 | def test_init_without_params(self): 83 | """Test initialization without parameters.""" 84 | api = ConcreteYasnoApi() 85 | assert api.region_id is None 86 | assert api.provider_id is None 87 | assert api.group is None 88 | 89 | 90 | class TestBaseYasnoApiFetchData: 91 | """Test data fetching methods.""" 92 | 93 | async def test_fetch_regions_success(self, api, regions_data): 94 | """Test successful regions fetch.""" 95 | with patch("aiohttp.ClientSession.get") as mock_get: 96 | mock_response = AsyncMock() 97 | mock_response.json = AsyncMock(return_value=regions_data) 98 | mock_response.raise_for_status = MagicMock() 99 | mock_get.return_value.__aenter__.return_value = mock_response 100 | 101 | await api.fetch_regions() 102 | assert api.regions_data == regions_data 103 | 104 | async def test_fetch_regions_error(self, api): 105 | """Test regions fetch with error.""" 106 | with patch("aiohttp.ClientSession.get") as mock_get: 107 | mock_get.return_value.__aenter__.side_effect = aiohttp.ClientError() 108 | await api.fetch_regions() 109 | assert api.regions_data is None 110 | 111 | async def test_get_data_success(self, api): 112 | """Test successful data fetch.""" 113 | with patch("aiohttp.ClientSession.get") as mock_get: 114 | mock_response = AsyncMock() 115 | test_data = {"key": "value"} 116 | mock_response.json = AsyncMock(return_value=test_data) 117 | mock_response.raise_for_status = MagicMock() 118 | mock_get.return_value.__aenter__.return_value = mock_response 119 | 120 | async with aiohttp.ClientSession() as session: 121 | result = await api._get_data(session, "https://example.com") 122 | assert result == test_data 123 | 124 | async def test_get_data_error(self, api): 125 | """Test data fetch with error.""" 126 | with patch("aiohttp.ClientSession.get") as mock_get: 127 | mock_get.return_value.__aenter__.side_effect = aiohttp.ClientError() 128 | 129 | async with aiohttp.ClientSession() as session: 130 | result = await api._get_data(session, "https://example.com") 131 | assert result is None 132 | 133 | 134 | class TestBaseYasnoApiRegions: 135 | """Test region-related methods.""" 136 | 137 | def test_get_regions(self, api, regions_data): 138 | """Test getting regions list.""" 139 | api.regions_data = regions_data 140 | assert api.get_regions() == regions_data 141 | 142 | def test_get_regions_empty(self, api): 143 | """Test getting regions when none loaded.""" 144 | assert api.get_regions() == [] 145 | 146 | def test_get_region_by_name(self, api, regions_data): 147 | """Test getting region by name.""" 148 | api.regions_data = regions_data 149 | region = api.get_region_by_name("Київ") 150 | assert region is not None 151 | assert region["id"] == TEST_REGION_ID 152 | 153 | def test_get_region_by_name_not_found(self, api, regions_data): 154 | """Test getting non-existent region.""" 155 | api.regions_data = regions_data 156 | region = api.get_region_by_name("NonExistent") 157 | assert region is None 158 | 159 | def test_get_providers_for_region(self, api, regions_data): 160 | """Test getting providers for region.""" 161 | api.regions_data = regions_data 162 | providers = api.get_providers_for_region("Київ") 163 | assert len(providers) == 1 164 | assert providers[0]["id"] == TEST_PROVIDER_ID 165 | 166 | def test_get_providers_for_nonexistent_region(self, api, regions_data): 167 | """Test getting providers for non-existent region.""" 168 | api.regions_data = regions_data 169 | providers = api.get_providers_for_region("NonExistent") 170 | assert providers == [] 171 | 172 | def test_get_provider_by_name(self, api, regions_data): 173 | """Test getting provider by name.""" 174 | api.regions_data = regions_data 175 | provider = api.get_provider_by_name( 176 | "Київ", "ПРАТ «ДТЕК КИЇВСЬКІ ЕЛЕКТРОМЕРЕЖІ»" 177 | ) 178 | assert provider is not None 179 | assert provider["id"] == TEST_PROVIDER_ID 180 | 181 | def test_get_provider_by_name_not_found(self, api, regions_data): 182 | """Test getting non-existent provider.""" 183 | api.regions_data = regions_data 184 | provider = api.get_provider_by_name("Київ", "NonExistent") 185 | assert provider is None 186 | 187 | 188 | class TestBaseYasnoApiTimeConversion: 189 | """Test time conversion methods.""" 190 | 191 | def test_minutes_to_time(self, today): 192 | """Test converting minutes to time.""" 193 | result = BaseYasnoApi.minutes_to_time(960, today) 194 | assert result.hour == 16 195 | assert result.minute == 0 196 | 197 | def test_minutes_to_time_midnight(self, today): 198 | """Test converting 0 minutes to midnight.""" 199 | result = BaseYasnoApi.minutes_to_time(0, today) 200 | assert result.hour == 0 201 | assert result.minute == 0 202 | 203 | def test_minutes_to_time_end_of_day(self, today, tomorrow): 204 | """Test converting 24:00 to next day midnight.""" 205 | result = BaseYasnoApi.minutes_to_time(1440, today) 206 | assert result.hour == 0 207 | assert result.minute == 0 208 | assert result.date() == tomorrow.date() 209 | 210 | 211 | class TestBaseYasnoApiSlotParsing: 212 | """Test slot parsing methods.""" 213 | 214 | def test_parse_raw_slots(self): 215 | """Test parsing raw slot dictionaries.""" 216 | raw_slots = [ 217 | {"start": 960, "end": 1200, "type": "Definite"}, 218 | {"start": 1200, "end": 1440, "type": "NotPlanned"}, 219 | ] 220 | slots = BaseYasnoApi._parse_raw_slots(raw_slots) 221 | assert len(slots) == 2 222 | assert slots[0].start == 960 223 | assert slots[0].end == 1200 224 | assert slots[0].event_type == OutageEventType.DEFINITE 225 | 226 | def test_parse_raw_slots_invalid(self): 227 | """Test parsing raw slots with invalid data.""" 228 | raw_slots = [ 229 | {"start": 960, "end": 1200, "type": "Definite"}, 230 | {"start": 1200}, # Missing fields 231 | {"start": 1200, "end": 1440, "type": "InvalidType"}, 232 | ] 233 | slots = BaseYasnoApi._parse_raw_slots(raw_slots) 234 | assert len(slots) == 1 # Only valid slot parsed 235 | 236 | def test_parse_slots_to_events(self, today): 237 | """Test converting slots to events.""" 238 | slots = [ 239 | OutageSlot(start=960, end=1200, event_type=OutageEventType.DEFINITE), 240 | OutageSlot(start=1200, end=1440, event_type=OutageEventType.NOT_PLANNED), 241 | ] 242 | events = BaseYasnoApi._parse_slots_to_events(slots, today, OutageSource.PLANNED) 243 | assert len(events) == 2 244 | assert events[0].start.hour == 16 245 | assert events[0].end.hour == 20 246 | assert events[0].source == OutageSource.PLANNED 247 | 248 | 249 | class TestBaseYasnoApiNextEvent: 250 | """Test get_next_event method.""" 251 | 252 | def test_get_next_event(self, api, today): 253 | """Test getting next event.""" 254 | event1 = OutageEvent( 255 | start=today.replace(hour=10), 256 | end=today.replace(hour=12), 257 | event_type=OutageEventType.DEFINITE, 258 | source=OutageSource.PLANNED, 259 | ) 260 | event2 = OutageEvent( 261 | start=today.replace(hour=16), 262 | end=today.replace(hour=18), 263 | event_type=OutageEventType.DEFINITE, 264 | source=OutageSource.PLANNED, 265 | ) 266 | api._events = [event1, event2] 267 | 268 | at = today.replace(hour=8) 269 | next_event = api.get_next_event(at) 270 | assert next_event == event1 271 | 272 | def test_get_next_event_after_first(self, api, today): 273 | """Test getting next event when first has passed.""" 274 | event1 = OutageEvent( 275 | start=today.replace(hour=10), 276 | end=today.replace(hour=12), 277 | event_type=OutageEventType.DEFINITE, 278 | source=OutageSource.PLANNED, 279 | ) 280 | event2 = OutageEvent( 281 | start=today.replace(hour=16), 282 | end=today.replace(hour=18), 283 | event_type=OutageEventType.DEFINITE, 284 | source=OutageSource.PLANNED, 285 | ) 286 | api._events = [event1, event2] 287 | 288 | at = today.replace(hour=13) 289 | next_event = api.get_next_event(at) 290 | assert next_event == event2 291 | 292 | def test_get_next_event_none(self, api, today): 293 | """Test getting next event when none exist.""" 294 | event = OutageEvent( 295 | start=today.replace(hour=10), 296 | end=today.replace(hour=12), 297 | event_type=OutageEventType.DEFINITE, 298 | source=OutageSource.PLANNED, 299 | ) 300 | api._events = [event] 301 | 302 | at = today.replace(hour=20) 303 | next_event = api.get_next_event(at) 304 | assert next_event is None 305 | 306 | def test_get_next_event_filtered_by_type(self, api, today): 307 | """Test getting next event filtered by type.""" 308 | event1 = OutageEvent( 309 | start=today.replace(hour=10), 310 | end=today.replace(hour=12), 311 | event_type=OutageEventType.NOT_PLANNED, 312 | source=OutageSource.PLANNED, 313 | ) 314 | event2 = OutageEvent( 315 | start=today.replace(hour=16), 316 | end=today.replace(hour=18), 317 | event_type=OutageEventType.DEFINITE, 318 | source=OutageSource.PLANNED, 319 | ) 320 | api._events = [event1, event2] 321 | 322 | at = today.replace(hour=8) 323 | next_event = api.get_next_event(at, event_type=OutageEventType.DEFINITE) 324 | assert next_event == event2 325 | -------------------------------------------------------------------------------- /tests/api/test_planned.py: -------------------------------------------------------------------------------- 1 | """Tests for Planned Outages API.""" 2 | 3 | import datetime 4 | from unittest.mock import AsyncMock, MagicMock, patch 5 | 6 | import aiohttp 7 | import pytest 8 | 9 | from custom_components.yasno_outages.api.models import OutageEventType, OutageSource 10 | from custom_components.yasno_outages.api.planned import PlannedOutagesApi 11 | 12 | TEST_REGION_ID = 25 13 | TEST_PROVIDER_ID = 902 14 | TEST_GROUP = "3.1" 15 | 16 | 17 | @pytest.fixture(name="api") 18 | def _api(): 19 | """Create an API instance.""" 20 | return PlannedOutagesApi( 21 | region_id=TEST_REGION_ID, provider_id=TEST_PROVIDER_ID, group=TEST_GROUP 22 | ) 23 | 24 | 25 | class TestPlannedOutagesApiInit: 26 | """Test PlannedOutagesApi initialization.""" 27 | 28 | def test_init_with_params(self): 29 | """Test initialization with parameters.""" 30 | api = PlannedOutagesApi( 31 | region_id=TEST_REGION_ID, provider_id=TEST_PROVIDER_ID, group=TEST_GROUP 32 | ) 33 | assert api.region_id == TEST_REGION_ID 34 | assert api.provider_id == TEST_PROVIDER_ID 35 | assert api.group == TEST_GROUP 36 | assert api.planned_outages_data is None 37 | 38 | def test_init_without_params(self): 39 | """Test initialization without parameters.""" 40 | api = PlannedOutagesApi() 41 | assert api.region_id is None 42 | assert api.provider_id is None 43 | assert api.group is None 44 | 45 | 46 | class TestPlannedOutagesApiFetchData: 47 | """Test data fetching methods.""" 48 | 49 | async def test_fetch_planned_outages_success(self, api, planned_outage_data): 50 | """Test successful planned outage fetch.""" 51 | with patch("aiohttp.ClientSession.get") as mock_get: 52 | mock_response = AsyncMock() 53 | mock_response.json = AsyncMock(return_value=planned_outage_data) 54 | mock_response.raise_for_status = MagicMock() 55 | mock_get.return_value.__aenter__.return_value = mock_response 56 | 57 | await api.fetch_planned_outages_data() 58 | assert api.planned_outages_data == planned_outage_data 59 | 60 | async def test_fetch_planned_outages_no_config(self): 61 | """Test planned outage fetch without region/provider.""" 62 | api = PlannedOutagesApi() 63 | await api.fetch_planned_outages_data() 64 | assert api.planned_outages_data is None 65 | 66 | async def test_fetch_planned_outages_error(self, api): 67 | """Test planned outage fetch with error.""" 68 | with patch("aiohttp.ClientSession.get") as mock_get: 69 | mock_get.return_value.__aenter__.side_effect = aiohttp.ClientError() 70 | await api.fetch_planned_outages_data() 71 | assert api.planned_outages_data is None 72 | 73 | async def test_fetch_data(self, api, planned_outage_data): 74 | """Test fetch_data method.""" 75 | with patch("aiohttp.ClientSession.get") as mock_get: 76 | mock_response = AsyncMock() 77 | mock_response.json = AsyncMock(return_value=planned_outage_data) 78 | mock_response.raise_for_status = MagicMock() 79 | mock_get.return_value.__aenter__.return_value = mock_response 80 | 81 | await api.fetch_data() 82 | assert api.planned_outages_data == planned_outage_data 83 | 84 | 85 | class TestPlannedOutagesApiGroups: 86 | """Test group-related methods.""" 87 | 88 | def test_get_groups(self, api, planned_outage_data): 89 | """Test getting groups list.""" 90 | api.planned_outages_data = planned_outage_data 91 | assert api.get_groups() == [TEST_GROUP] 92 | 93 | def test_get_groups_empty(self, api): 94 | """Test getting groups when none loaded.""" 95 | assert api.get_groups() == [] 96 | 97 | def test_get_planned_outages_data(self, api, planned_outage_data): 98 | """Test getting data for configured group.""" 99 | api.planned_outages_data = planned_outage_data 100 | group_data = api.get_planned_outages_data() 101 | assert group_data is not None 102 | assert "today" in group_data 103 | assert "tomorrow" in group_data 104 | 105 | def test_get_planned_outages_data_no_data(self, api): 106 | """Test getting data when none loaded.""" 107 | assert api.get_planned_outages_data() is None 108 | 109 | def test_get_planned_outages_data_wrong_group(self, api, planned_outage_data): 110 | """Test getting data for non-existent group.""" 111 | api.planned_outages_data = planned_outage_data 112 | api.group = "99.9" 113 | assert api.get_planned_outages_data() is None 114 | 115 | 116 | class TestPlannedOutagesApiDates: 117 | """Test date-related methods.""" 118 | 119 | def test_get_planned_dates(self, api, planned_outage_data, today, tomorrow): 120 | """Test getting planned dates.""" 121 | api.planned_outages_data = planned_outage_data 122 | dates = api.get_planned_dates() 123 | assert len(dates) == 2 124 | assert today.date() in dates 125 | assert tomorrow.date() in dates 126 | 127 | def test_get_planned_dates_no_data(self, api): 128 | """Test getting dates when no data.""" 129 | assert api.get_planned_dates() == [] 130 | 131 | def test_get_today_date(self, api, planned_outage_data, today): 132 | """Test getting today's date.""" 133 | api.planned_outages_data = planned_outage_data 134 | today_date = api.get_today_date() 135 | assert today_date == today.date() 136 | 137 | def test_get_tomorrow_date(self, api, planned_outage_data, tomorrow): 138 | """Test getting tomorrow's date.""" 139 | api.planned_outages_data = planned_outage_data 140 | tomorrow_date = api.get_tomorrow_date() 141 | assert tomorrow_date == tomorrow.date() 142 | 143 | def test_get_date_by_day_today(self, api, planned_outage_data, today): 144 | """Test getting date by day key.""" 145 | api.planned_outages_data = planned_outage_data 146 | date = api.get_date_by_day("today") 147 | assert date == today.date() 148 | 149 | def test_get_date_by_day_no_data(self, api): 150 | """Test getting date when no data.""" 151 | assert api.get_date_by_day("today") is None 152 | 153 | 154 | class TestPlannedOutagesApiStatus: 155 | """Test status-related methods.""" 156 | 157 | def test_get_status_today(self, api, planned_outage_data): 158 | """Test getting today's status.""" 159 | api.planned_outages_data = planned_outage_data 160 | status = api.get_status_today() 161 | assert status == "ScheduleApplies" 162 | 163 | def test_get_status_tomorrow(self, api, planned_outage_data): 164 | """Test getting tomorrow's status.""" 165 | api.planned_outages_data = planned_outage_data 166 | status = api.get_status_tomorrow() 167 | assert status == "ScheduleApplies" 168 | 169 | def test_get_status_by_day(self, api, planned_outage_data): 170 | """Test getting status by day key.""" 171 | api.planned_outages_data = planned_outage_data 172 | status = api.get_status_by_day("today") 173 | assert status == "ScheduleApplies" 174 | 175 | def test_get_status_no_data(self, api): 176 | """Test getting status when no data.""" 177 | assert api.get_status_today() is None 178 | 179 | def test_get_data_by_day(self, api, planned_outage_data): 180 | """Test getting data by day.""" 181 | api.planned_outages_data = planned_outage_data 182 | day_data = api.get_data_by_day("today") 183 | assert day_data is not None 184 | assert "slots" in day_data 185 | assert "status" in day_data 186 | 187 | 188 | class TestPlannedOutagesApiScheduleParsing: 189 | """Test schedule parsing methods.""" 190 | 191 | def test_parse_day_schedule(self, api, today): 192 | """Test parsing day schedule.""" 193 | day_data = { 194 | "slots": [ 195 | {"start": 960, "end": 1200, "type": "Definite"}, 196 | {"start": 1200, "end": 1440, "type": "NotPlanned"}, 197 | ], 198 | "date": today.isoformat(), 199 | } 200 | events = api._parse_day_schedule(day_data, today) 201 | assert len(events) == 2 202 | assert events[0].event_type == OutageEventType.DEFINITE 203 | assert events[0].source == OutageSource.PLANNED 204 | assert events[0].start.hour == 16 205 | 206 | def test_parse_day_schedule_empty_slots(self, api, today): 207 | """Test parsing day schedule with empty slots.""" 208 | day_data = { 209 | "slots": [], 210 | "date": today.isoformat(), 211 | } 212 | events = api._parse_day_schedule(day_data, today) 213 | assert len(events) == 0 214 | 215 | def test_parse_day_events(self, api, planned_outage_data): 216 | """Test parsing day events from group data.""" 217 | api.planned_outages_data = planned_outage_data 218 | group_data = api.get_planned_outages_data() 219 | events = api._parse_day_events(group_data, "today") 220 | assert len(events) == 3 221 | 222 | def test_parse_day_events_no_day(self, api, planned_outage_data): 223 | """Test parsing day events when day key missing.""" 224 | api.planned_outages_data = planned_outage_data 225 | group_data = api.get_planned_outages_data() 226 | events = api._parse_day_events(group_data, "next_week") 227 | assert len(events) == 0 228 | 229 | 230 | class TestPlannedOutagesApiUpdatedOn: 231 | """Test updated on timestamp methods.""" 232 | 233 | def test_get_updated_on(self, api, planned_outage_data): 234 | """Test getting updated timestamp.""" 235 | api.planned_outages_data = planned_outage_data 236 | updated = api.get_updated_on() 237 | assert updated is not None 238 | 239 | def test_get_updated_on_no_data(self, api): 240 | """Test getting updated timestamp without data.""" 241 | assert api.get_updated_on() is None 242 | 243 | 244 | class TestPlannedOutagesApiEvents: 245 | """Test event retrieval methods.""" 246 | 247 | def test_get_events_between(self, api, planned_outage_data, today, tomorrow): 248 | """Test getting events within date range.""" 249 | api.planned_outages_data = planned_outage_data 250 | events = api.get_events_between(today, tomorrow.replace(hour=23, minute=59)) 251 | # Should have 2 Definite events from the test data 252 | definite_events = [ 253 | e for e in events if e.event_type == OutageEventType.DEFINITE 254 | ] 255 | assert len(definite_events) == 2 256 | 257 | def test_get_events_between_no_data(self, api, today, tomorrow): 258 | """Test getting events when no data.""" 259 | events = api.get_events_between(today, tomorrow) 260 | assert events == [] 261 | 262 | def test_get_events_between_wrong_group( 263 | self, api, planned_outage_data, today, tomorrow 264 | ): 265 | """Test getting events for wrong group.""" 266 | api.planned_outages_data = planned_outage_data 267 | api.group = "99.9" 268 | events = api.get_events_between(today, tomorrow) 269 | assert events == [] 270 | 271 | def test_get_events_between_filtered(self, api, planned_outage_data, today): 272 | """Test that events are filtered by date range.""" 273 | api.planned_outages_data = planned_outage_data 274 | # Request only first hour of today 275 | events = api.get_events_between(today, today.replace(hour=1)) 276 | # Should only get NotPlanned event that starts at midnight 277 | assert len(events) >= 1 278 | 279 | def test_get_current_event(self, api, planned_outage_data, today): 280 | """Test getting current event.""" 281 | api.planned_outages_data = planned_outage_data 282 | # 17:00 should be in the Definite event (16:00-20:00 / 960-1200 minutes) 283 | at = today.replace(hour=17) 284 | event = api.get_current_event(at) 285 | assert event is not None 286 | assert event.event_type == OutageEventType.DEFINITE 287 | 288 | def test_get_current_event_none(self, api, planned_outage_data, today): 289 | """Test getting current event when none active.""" 290 | api.planned_outages_data = planned_outage_data 291 | # 8:00 should be in NotPlanned period 292 | at = today.replace(hour=8) 293 | event = api.get_current_event(at) 294 | # Event exists but is NOT_PLANNED type 295 | if event: 296 | assert event.event_type == OutageEventType.NOT_PLANNED 297 | 298 | def test_get_current_event_no_data(self, api, today): 299 | """Test getting current event with no data.""" 300 | event = api.get_current_event(today) 301 | assert event is None 302 | 303 | 304 | class TestPlannedOutagesApiEdgeCases: 305 | """Test edge cases and special scenarios.""" 306 | 307 | def test_midnight_spanning_events(self, api, today, tomorrow): 308 | """Test events that span across midnight.""" 309 | api.planned_outages_data = { 310 | TEST_GROUP: { 311 | "today": { 312 | "slots": [ 313 | {"start": 0, "end": 1320, "type": "NotPlanned"}, # 00:00-22:00 314 | {"start": 1320, "end": 1440, "type": "Definite"}, # 22:00-24:00 315 | ], 316 | "date": today.isoformat(), 317 | "status": "ScheduleApplies", 318 | }, 319 | "tomorrow": { 320 | "slots": [ 321 | {"start": 0, "end": 120, "type": "Definite"}, # 00:00-02:00 322 | {"start": 120, "end": 1440, "type": "NotPlanned"}, 323 | ], 324 | "date": tomorrow.isoformat(), 325 | "status": "ScheduleApplies", 326 | }, 327 | "updatedOn": today.isoformat(), 328 | } 329 | } 330 | 331 | events = api.get_events_between(today, tomorrow + datetime.timedelta(days=1)) 332 | # Find the Definite events 333 | definite_events = [ 334 | e for e in events if e.event_type == OutageEventType.DEFINITE 335 | ] 336 | assert len(definite_events) == 2 337 | 338 | # Verify times 339 | evening_event = [e for e in definite_events if e.start.hour == 22] 340 | morning_event = [e for e in definite_events if e.start.hour == 0] 341 | assert len(evening_event) == 1 342 | assert len(morning_event) == 1 343 | 344 | def test_event_at_midnight(self, api, today, tomorrow): 345 | """Test getting event exactly at midnight.""" 346 | api.planned_outages_data = { 347 | TEST_GROUP: { 348 | "today": { 349 | "slots": [ 350 | {"start": 1320, "end": 1440, "type": "Definite"}, 351 | ], 352 | "date": today.isoformat(), 353 | "status": "ScheduleApplies", 354 | }, 355 | "tomorrow": { 356 | "slots": [ 357 | {"start": 0, "end": 120, "type": "Definite"}, 358 | ], 359 | "date": tomorrow.isoformat(), 360 | "status": "ScheduleApplies", 361 | }, 362 | "updatedOn": today.isoformat(), 363 | } 364 | } 365 | 366 | # Check at exactly midnight of tomorrow 367 | event = api.get_current_event(tomorrow) 368 | assert event is not None 369 | assert event.event_type == OutageEventType.DEFINITE 370 | -------------------------------------------------------------------------------- /custom_components/yasno_outages/coordinator.py: -------------------------------------------------------------------------------- 1 | """Data coordinator for Yasno Outages integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import datetime 6 | import logging 7 | from typing import TYPE_CHECKING 8 | 9 | from homeassistant.const import STATE_UNKNOWN 10 | from homeassistant.helpers.translation import async_get_translations 11 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 12 | from homeassistant.util import dt as dt_utils 13 | 14 | from .api import OutageEvent, OutageEventType, YasnoApi 15 | from .api.const import ( 16 | API_STATUS_EMERGENCY_SHUTDOWNS, 17 | API_STATUS_SCHEDULE_APPLIES, 18 | API_STATUS_WAITING_FOR_SCHEDULE, 19 | ) 20 | from .api.models import OutageSource 21 | from .const import ( 22 | CONF_FILTER_PROBABLE, 23 | CONF_GROUP, 24 | CONF_PROVIDER, 25 | CONF_REGION, 26 | CONF_STATUS_ALL_DAY_EVENTS, 27 | DOMAIN, 28 | PLANNED_OUTAGE_LOOKAHEAD, 29 | PLANNED_OUTAGE_TEXT_FALLBACK, 30 | PROBABLE_OUTAGE_LOOKAHEAD, 31 | PROBABLE_OUTAGE_TEXT_FALLBACK, 32 | PROVIDER_DTEK_FULL, 33 | PROVIDER_DTEK_SHORT, 34 | STATE_NORMAL, 35 | STATE_OUTAGE, 36 | STATE_STATUS_EMERGENCY_SHUTDOWNS, 37 | STATE_STATUS_SCHEDULE_APPLIES, 38 | STATE_STATUS_WAITING_FOR_SCHEDULE, 39 | STATUS_EMERGENCY_SHUTDOWNS_TEXT_FALLBACK, 40 | STATUS_SCHEDULE_APPLIES_TEXT_FALLBACK, 41 | STATUS_WAITING_FOR_SCHEDULE_TEXT_FALLBACK, 42 | TRANSLATION_KEY_EVENT_PLANNED_OUTAGE, 43 | TRANSLATION_KEY_EVENT_PROBABLE_OUTAGE, 44 | TRANSLATION_KEY_STATUS_EMERGENCY_SHUTDOWNS, 45 | TRANSLATION_KEY_STATUS_SCHEDULE_APPLIES, 46 | TRANSLATION_KEY_STATUS_WAITING_FOR_SCHEDULE, 47 | UPDATE_INTERVAL, 48 | ) 49 | from .helpers import merge_consecutive_outages 50 | 51 | if TYPE_CHECKING: 52 | from homeassistant.config_entries import ConfigEntry 53 | from homeassistant.core import HomeAssistant 54 | 55 | from .api.base import BaseYasnoApi 56 | 57 | LOGGER = logging.getLogger(__name__) 58 | 59 | EVENT_TYPE_STATE_MAP: dict[OutageEventType, str] = { 60 | OutageEventType.DEFINITE: STATE_OUTAGE, 61 | OutageEventType.NOT_PLANNED: STATE_NORMAL, 62 | } 63 | 64 | STATUS_STATE_MAP: dict[str, str] = { 65 | API_STATUS_SCHEDULE_APPLIES: STATE_STATUS_SCHEDULE_APPLIES, 66 | API_STATUS_WAITING_FOR_SCHEDULE: STATE_STATUS_WAITING_FOR_SCHEDULE, 67 | API_STATUS_EMERGENCY_SHUTDOWNS: STATE_STATUS_EMERGENCY_SHUTDOWNS, 68 | } 69 | 70 | 71 | def is_outage_event(event: OutageEvent | None) -> bool: 72 | """Return True for outage events that should create calendar entries.""" 73 | LOGGER.debug("Checking if event is an outage: %s", event) 74 | return bool(event and event.event_type != OutageEventType.NOT_PLANNED) 75 | 76 | 77 | def find_next_outage( 78 | events: list[OutageEvent], 79 | now: datetime.datetime, 80 | ) -> OutageEvent | None: 81 | """Find the next outage event that starts after the given time.""" 82 | for event in events: 83 | if event.start > now: 84 | return event 85 | return None 86 | 87 | 88 | def simplify_provider_name(provider_name: str) -> str: 89 | """Simplify provider names for cleaner display in device names.""" 90 | # Replace long DTEK provider names with just "ДТЕК" 91 | if PROVIDER_DTEK_FULL in provider_name.upper(): 92 | return PROVIDER_DTEK_SHORT 93 | 94 | # Add more provider simplifications here as needed 95 | return provider_name 96 | 97 | 98 | class YasnoOutagesCoordinator(DataUpdateCoordinator): 99 | """Class to manage fetching Yasno outages data.""" 100 | 101 | config_entry: ConfigEntry 102 | 103 | def __init__( 104 | self, 105 | hass: HomeAssistant, 106 | config_entry: ConfigEntry, 107 | api: YasnoApi, 108 | ) -> None: 109 | """Initialize the coordinator.""" 110 | super().__init__( 111 | hass, 112 | LOGGER, 113 | name=DOMAIN, 114 | update_interval=datetime.timedelta(minutes=UPDATE_INTERVAL), 115 | ) 116 | self.hass = hass 117 | self.config_entry = config_entry 118 | self.translations = {} 119 | 120 | # Get configuration values 121 | self.region = config_entry.options.get( 122 | CONF_REGION, 123 | config_entry.data.get(CONF_REGION), 124 | ) 125 | self.provider = config_entry.options.get( 126 | CONF_PROVIDER, 127 | config_entry.data.get(CONF_PROVIDER), 128 | ) 129 | self.group = config_entry.options.get( 130 | CONF_GROUP, 131 | config_entry.data.get(CONF_GROUP), 132 | ) 133 | self.filter_probable = config_entry.options.get( 134 | CONF_FILTER_PROBABLE, 135 | config_entry.data.get(CONF_FILTER_PROBABLE, True), 136 | ) 137 | self.status_all_day_events = config_entry.options.get( 138 | CONF_STATUS_ALL_DAY_EVENTS, 139 | config_entry.data.get(CONF_STATUS_ALL_DAY_EVENTS, True), 140 | ) 141 | 142 | if not self.region: 143 | region_required_msg = ( 144 | "Region not set in configuration - this should not happen " 145 | "with proper config flow" 146 | ) 147 | region_error = "Region configuration is required" 148 | LOGGER.error(region_required_msg) 149 | raise ValueError(region_error) 150 | 151 | if not self.provider: 152 | provider_required_msg = ( 153 | "Provider not set in configuration - this should not happen " 154 | "with proper config flow" 155 | ) 156 | provider_error = "Provider configuration is required" 157 | LOGGER.error(provider_required_msg) 158 | raise ValueError(provider_error) 159 | 160 | if not self.group: 161 | group_required_msg = ( 162 | "Group not set in configuration - this should not happen " 163 | "with proper config flow" 164 | ) 165 | group_error = "Group configuration is required" 166 | LOGGER.error(group_required_msg) 167 | raise ValueError(group_error) 168 | 169 | # Initialize with names first, then we'll update with IDs when we fetch data 170 | self.region_id = None 171 | self.provider_id = None 172 | self._provider_name = "" # Cache the provider name 173 | 174 | # Use the provided API instance 175 | self.api = api 176 | 177 | async def _async_update_data(self) -> None: 178 | """Fetch data from new Yasno API.""" 179 | await self.async_fetch_translations() 180 | 181 | # Resolve IDs if not already resolved 182 | if self.region_id is None or self.provider_id is None: 183 | try: 184 | await self._resolve_ids() 185 | except Exception as err: 186 | msg = f"Failed to resolve IDs: {err}" 187 | raise UpdateFailed(msg) from err 188 | 189 | # Update API with resolved IDs 190 | self.api = YasnoApi( 191 | region_id=self.region_id, 192 | provider_id=self.provider_id, 193 | group=self.group, 194 | ) 195 | 196 | # Fetch planned outages data 197 | try: 198 | await self.api.planned.fetch_data() 199 | except Exception as err: 200 | msg = f"Failed to fetch planned outages data: {err}" 201 | raise UpdateFailed(msg) from err 202 | 203 | # Fetch probable outages data 204 | try: 205 | await self.api.probable.fetch_data() 206 | except Exception: # noqa: BLE001 207 | LOGGER.warning("Failed to fetch probable outages data", exc_info=True) 208 | 209 | async def _resolve_ids(self) -> None: 210 | """Resolve region and provider IDs from names.""" 211 | if not self.api.regions_data: 212 | await self.api.fetch_regions() 213 | 214 | if self.region: 215 | region_data = self.api.get_region_by_name(self.region) 216 | if region_data: 217 | self.region_id = region_data["id"] 218 | if self.provider: 219 | provider_data = self.api.get_provider_by_name( 220 | self.region, 221 | self.provider, 222 | ) 223 | if provider_data: 224 | self.provider_id = provider_data["id"] 225 | # Cache the provider name for device naming 226 | self._provider_name = provider_data["name"] 227 | 228 | def _event_to_state(self, event: OutageEvent | None) -> str: 229 | """Map outage event to electricity state.""" 230 | return ( 231 | EVENT_TYPE_STATE_MAP.get(event.event_type, STATE_UNKNOWN) 232 | if event 233 | else STATE_UNKNOWN 234 | ) 235 | 236 | async def async_fetch_translations(self) -> None: 237 | """Fetch translations.""" 238 | self.translations = await async_get_translations( 239 | self.hass, 240 | self.hass.config.language, 241 | "common", 242 | [DOMAIN], 243 | ) 244 | 245 | @property 246 | def event_summary_map(self) -> dict[OutageSource, str]: 247 | """Return localized summaries by source with fallbacks.""" 248 | return { 249 | OutageSource.PLANNED: self.translations.get( 250 | TRANSLATION_KEY_EVENT_PLANNED_OUTAGE, PLANNED_OUTAGE_TEXT_FALLBACK 251 | ), 252 | OutageSource.PROBABLE: self.translations.get( 253 | TRANSLATION_KEY_EVENT_PROBABLE_OUTAGE, PROBABLE_OUTAGE_TEXT_FALLBACK 254 | ), 255 | } 256 | 257 | @property 258 | def status_event_summary_map(self) -> dict[str, str]: 259 | """Return localized summaries for planned status events.""" 260 | return { 261 | STATE_STATUS_SCHEDULE_APPLIES: self.translations.get( 262 | TRANSLATION_KEY_STATUS_SCHEDULE_APPLIES, 263 | STATUS_SCHEDULE_APPLIES_TEXT_FALLBACK, 264 | ), 265 | STATE_STATUS_WAITING_FOR_SCHEDULE: self.translations.get( 266 | TRANSLATION_KEY_STATUS_WAITING_FOR_SCHEDULE, 267 | STATUS_WAITING_FOR_SCHEDULE_TEXT_FALLBACK, 268 | ), 269 | STATE_STATUS_EMERGENCY_SHUTDOWNS: self.translations.get( 270 | TRANSLATION_KEY_STATUS_EMERGENCY_SHUTDOWNS, 271 | STATUS_EMERGENCY_SHUTDOWNS_TEXT_FALLBACK, 272 | ), 273 | } 274 | 275 | @property 276 | def region_name(self) -> str: 277 | """Get the configured region name.""" 278 | return self.region or "" 279 | 280 | @property 281 | def provider_name(self) -> str: 282 | """Get the configured provider name.""" 283 | # Return cached name if available (but apply simplification first) 284 | if self._provider_name: 285 | return simplify_provider_name(self._provider_name) 286 | 287 | # Fallback to lookup if not cached yet 288 | if not self.api.regions_data: 289 | return "" 290 | 291 | region_data = self.api.get_region_by_name(self.region) 292 | if not region_data: 293 | return "" 294 | 295 | providers = region_data.get("dsos", []) 296 | for provider in providers: 297 | if (provider_name := provider.get("name", "")) == self.provider: 298 | # Cache the simplified name 299 | self._provider_name = provider_name 300 | return simplify_provider_name(provider_name) 301 | 302 | return "" 303 | 304 | @property 305 | def current_event(self) -> OutageEvent | None: 306 | """Get the current planned event (including NotPlanned events).""" 307 | try: 308 | return self.api.planned.get_current_event(dt_utils.now()) 309 | except Exception: # noqa: BLE001 310 | LOGGER.warning( 311 | "Failed to get current event, sensors will show unknown state", 312 | exc_info=True, 313 | ) 314 | return None 315 | 316 | @property 317 | def current_state(self) -> str: 318 | """Get the current state.""" 319 | return self._event_to_state(self.current_event) 320 | 321 | @property 322 | def schedule_updated_on(self) -> datetime.datetime | None: 323 | """Get the schedule last updated timestamp.""" 324 | return self.api.planned.get_updated_on() 325 | 326 | @property 327 | def today_date(self) -> datetime.date | None: 328 | """Get today's date.""" 329 | return self.api.planned.get_today_date() 330 | 331 | @property 332 | def tomorrow_date(self) -> datetime.date | None: 333 | """Get tomorrow's date.""" 334 | return self.api.planned.get_tomorrow_date() 335 | 336 | @property 337 | def status_today(self) -> str | None: 338 | """Get the status for today.""" 339 | return STATUS_STATE_MAP.get(self.api.planned.get_status_today(), STATE_UNKNOWN) 340 | 341 | @property 342 | def status_tomorrow(self) -> str | None: 343 | """Get the status for tomorrow.""" 344 | return STATUS_STATE_MAP.get( 345 | self.api.planned.get_status_tomorrow(), STATE_UNKNOWN 346 | ) 347 | 348 | @property 349 | def next_planned_outage(self) -> datetime.date | datetime.datetime | None: 350 | """Get the next planned outage time.""" 351 | now = dt_utils.now() 352 | events = self.get_merged_outages( 353 | self.api.planned, 354 | now, 355 | PLANNED_OUTAGE_LOOKAHEAD, 356 | ) 357 | 358 | if event := find_next_outage(events, now): 359 | LOGGER.debug("Next planned outage: %s", event) 360 | return event.start 361 | 362 | return None 363 | 364 | @property 365 | def next_probable_outage(self) -> datetime.date | datetime.datetime | None: 366 | """Get the next probable outage time.""" 367 | now = dt_utils.now() 368 | events = self.get_merged_outages( 369 | self.api.probable, 370 | now, 371 | PROBABLE_OUTAGE_LOOKAHEAD, 372 | ) 373 | 374 | if event := find_next_outage(events, now): 375 | LOGGER.debug("Next probable outage: %s", event) 376 | return event.start 377 | 378 | return None 379 | 380 | @property 381 | def next_connectivity(self) -> datetime.date | datetime.datetime | None: 382 | """ 383 | Get next connectivity time. 384 | 385 | Only planned events determine connectivity. 386 | Probable events are forecasts and do not affect connectivity calculation. 387 | """ 388 | now = dt_utils.now() 389 | events = self.get_merged_outages( 390 | self.api.planned, 391 | now, 392 | PLANNED_OUTAGE_LOOKAHEAD, 393 | ) 394 | 395 | # Check if we are in an outage 396 | for event in events: 397 | if event.start <= now < event.end: 398 | return event.end 399 | 400 | # Find next outage 401 | if event := find_next_outage(events, now): 402 | LOGGER.debug("Next connectivity event: %s", event) 403 | return event.end 404 | 405 | return None 406 | 407 | def get_outage_at( 408 | self, 409 | api: BaseYasnoApi, 410 | at: datetime.datetime, 411 | ) -> OutageEvent | None: 412 | """Get an outage event at a given time from provided API.""" 413 | try: 414 | event = api.get_current_event(at) 415 | except Exception: # noqa: BLE001 416 | LOGGER.warning("Failed to get current outage", exc_info=True) 417 | return None 418 | if not is_outage_event(event): 419 | return None 420 | return event 421 | 422 | def get_planned_outage_at(self, at: datetime.datetime) -> OutageEvent | None: 423 | """Get the planned outage event at a given time.""" 424 | return self.get_outage_at(self.api.planned, at) 425 | 426 | def get_probable_outage_at(self, at: datetime.datetime) -> OutageEvent | None: 427 | """Get the probable outage event at a given time.""" 428 | return self.get_outage_at(self.api.probable, at) 429 | 430 | def get_events_between( 431 | self, 432 | api: BaseYasnoApi, 433 | start_date: datetime.datetime, 434 | end_date: datetime.datetime, 435 | ) -> list[OutageEvent]: 436 | """Get outage events within the date range for provided API.""" 437 | try: 438 | events = api.get_events_between(start_date, end_date) 439 | except Exception: # noqa: BLE001 440 | LOGGER.warning( 441 | 'Failed to get events between "%s" -> "%s"', 442 | start_date, 443 | end_date, 444 | exc_info=True, 445 | ) 446 | return [] 447 | 448 | filtered_events = [event for event in events if is_outage_event(event)] 449 | return sorted(filtered_events, key=lambda event: event.start) 450 | 451 | def get_planned_events_between( 452 | self, 453 | start_date: datetime.datetime, 454 | end_date: datetime.datetime, 455 | ) -> list[OutageEvent]: 456 | """Get all planned events (filtering out NOT_PLANNED).""" 457 | return self.get_events_between(self.api.planned, start_date, end_date) 458 | 459 | def get_probable_events_between( 460 | self, 461 | start_date: datetime.datetime, 462 | end_date: datetime.datetime, 463 | ) -> list[OutageEvent]: 464 | """Get all probable outage events within the date range.""" 465 | return self.get_events_between(self.api.probable, start_date, end_date) 466 | 467 | def get_planned_dates(self) -> list[datetime.date]: 468 | """Get dates with planned outages.""" 469 | return self.api.planned.get_planned_dates() 470 | 471 | def get_merged_outages( 472 | self, 473 | api: BaseYasnoApi, 474 | start_date: datetime.datetime, 475 | lookahead_days: int, 476 | ) -> list[OutageEvent]: 477 | """Get merged outage events for a lookahead period.""" 478 | end_date = start_date + datetime.timedelta(days=lookahead_days) 479 | events = self.get_events_between(api, start_date, end_date) 480 | return merge_consecutive_outages(events) 481 | -------------------------------------------------------------------------------- /tests/api/test_probable.py: -------------------------------------------------------------------------------- 1 | """Tests for Probable Outages API.""" 2 | 3 | import datetime 4 | from unittest.mock import AsyncMock, MagicMock, patch 5 | 6 | import aiohttp 7 | import pytest 8 | 9 | from custom_components.yasno_outages.api.models import OutageEventType, OutageSource 10 | from custom_components.yasno_outages.api.probable import ProbableOutagesApi 11 | 12 | TEST_REGION_ID = 25 13 | TEST_PROVIDER_ID = 902 14 | TEST_GROUP = "3.1" 15 | 16 | 17 | @pytest.fixture(name="api") 18 | def _api(): 19 | """Create an API instance.""" 20 | return ProbableOutagesApi( 21 | region_id=TEST_REGION_ID, provider_id=TEST_PROVIDER_ID, group=TEST_GROUP 22 | ) 23 | 24 | 25 | class TestProbableOutagesApiInit: 26 | """Test ProbableOutagesApi initialization.""" 27 | 28 | def test_init_with_params(self): 29 | """Test initialization with parameters.""" 30 | api = ProbableOutagesApi( 31 | region_id=TEST_REGION_ID, provider_id=TEST_PROVIDER_ID, group=TEST_GROUP 32 | ) 33 | assert api.region_id == TEST_REGION_ID 34 | assert api.provider_id == TEST_PROVIDER_ID 35 | assert api.group == TEST_GROUP 36 | assert api.probable_outages_data is None 37 | 38 | def test_init_without_params(self): 39 | """Test initialization without parameters.""" 40 | api = ProbableOutagesApi() 41 | assert api.region_id is None 42 | assert api.provider_id is None 43 | assert api.group is None 44 | 45 | 46 | class TestProbableOutagesApiFetchData: 47 | """Test data fetching methods.""" 48 | 49 | async def test_fetch_probable_outages_success(self, api, probable_outage_data): 50 | """Test successful probable outage fetch.""" 51 | with patch("aiohttp.ClientSession.get") as mock_get: 52 | mock_response = AsyncMock() 53 | mock_response.json = AsyncMock(return_value=probable_outage_data) 54 | mock_response.raise_for_status = MagicMock() 55 | mock_get.return_value.__aenter__.return_value = mock_response 56 | 57 | await api.fetch_probable_outages_data() 58 | assert api.probable_outages_data == probable_outage_data 59 | 60 | async def test_fetch_probable_outages_no_config(self): 61 | """Test probable outage fetch without region/provider.""" 62 | api = ProbableOutagesApi() 63 | await api.fetch_probable_outages_data() 64 | assert api.probable_outages_data is None 65 | 66 | async def test_fetch_probable_outages_error(self, api): 67 | """Test probable outage fetch with error.""" 68 | with patch("aiohttp.ClientSession.get") as mock_get: 69 | mock_get.return_value.__aenter__.side_effect = aiohttp.ClientError() 70 | await api.fetch_probable_outages_data() 71 | assert api.probable_outages_data is None 72 | 73 | async def test_fetch_data(self, api, probable_outage_data): 74 | """Test fetch_data method.""" 75 | with patch("aiohttp.ClientSession.get") as mock_get: 76 | mock_response = AsyncMock() 77 | mock_response.json = AsyncMock(return_value=probable_outage_data) 78 | mock_response.raise_for_status = MagicMock() 79 | mock_get.return_value.__aenter__.return_value = mock_response 80 | 81 | await api.fetch_data() 82 | assert api.probable_outages_data == probable_outage_data 83 | 84 | 85 | class TestProbableOutagesApiSlots: 86 | """Test slot retrieval methods.""" 87 | 88 | def test_get_probable_slots_for_weekday(self, api, probable_outage_data): 89 | """Test getting slots for a specific weekday.""" 90 | api.probable_outages_data = probable_outage_data 91 | # Monday = 0 92 | slots = api.get_probable_slots_for_weekday(0) 93 | assert len(slots) == 1 94 | assert slots[0].start == 480 95 | assert slots[0].end == 720 96 | assert slots[0].event_type == OutageEventType.DEFINITE 97 | 98 | def test_get_probable_slots_for_tuesday(self, api, probable_outage_data): 99 | """Test getting slots for Tuesday.""" 100 | api.probable_outages_data = probable_outage_data 101 | # Tuesday = 1 102 | slots = api.get_probable_slots_for_weekday(1) 103 | assert len(slots) == 1 104 | assert slots[0].start == 600 105 | assert slots[0].end == 900 106 | 107 | def test_get_probable_slots_for_empty_weekday(self, api, probable_outage_data): 108 | """Test getting slots for weekday with no slots.""" 109 | api.probable_outages_data = probable_outage_data 110 | # Wednesday = 2 (empty in test data) 111 | slots = api.get_probable_slots_for_weekday(2) 112 | assert len(slots) == 0 113 | 114 | def test_get_probable_slots_no_data(self, api): 115 | """Test getting slots when no data loaded.""" 116 | slots = api.get_probable_slots_for_weekday(0) 117 | assert slots == [] 118 | 119 | def test_get_probable_slots_wrong_region(self, api, probable_outage_data): 120 | """Test getting slots for non-existent region.""" 121 | api.probable_outages_data = probable_outage_data 122 | api.region_id = 999 123 | slots = api.get_probable_slots_for_weekday(0) 124 | assert slots == [] 125 | 126 | def test_get_probable_slots_wrong_provider(self, api, probable_outage_data): 127 | """Test getting slots for non-existent provider.""" 128 | api.probable_outages_data = probable_outage_data 129 | api.provider_id = 999 130 | slots = api.get_probable_slots_for_weekday(0) 131 | assert slots == [] 132 | 133 | def test_get_probable_slots_wrong_group(self, api, probable_outage_data): 134 | """Test getting slots for non-existent group.""" 135 | api.probable_outages_data = probable_outage_data 136 | api.group = "99.9" 137 | slots = api.get_probable_slots_for_weekday(0) 138 | assert slots == [] 139 | 140 | 141 | class TestProbableOutagesApiCurrentEvent: 142 | """Test current event retrieval.""" 143 | 144 | def test_get_current_event_monday_morning(self, api, probable_outage_data): 145 | """Test getting current event on Monday morning during outage.""" 146 | api.probable_outages_data = probable_outage_data 147 | # Create a Monday at 9:00 (540 minutes) - should be in 480-720 slot 148 | monday = datetime.datetime(2025, 1, 27, 9, 0, 0) # This is a Monday 149 | # Adjust to ensure it's Monday 150 | while monday.weekday() != 0: 151 | monday += datetime.timedelta(days=1) 152 | 153 | event = api.get_current_event(monday) 154 | assert event is not None 155 | assert event.event_type == OutageEventType.DEFINITE 156 | assert event.source == OutageSource.PROBABLE 157 | assert event.start.hour == 8 # 480 minutes = 8:00 158 | assert event.end.hour == 12 # 720 minutes = 12:00 159 | 160 | def test_get_current_event_outside_slot(self, api, probable_outage_data): 161 | """Test getting current event outside any slot.""" 162 | api.probable_outages_data = probable_outage_data 163 | # Monday at 14:00 - no slot at this time 164 | monday = datetime.datetime(2025, 1, 27, 14, 0, 0) 165 | while monday.weekday() != 0: 166 | monday += datetime.timedelta(days=1) 167 | 168 | event = api.get_current_event(monday) 169 | assert event is None 170 | 171 | def test_get_current_event_tuesday(self, api, probable_outage_data): 172 | """Test getting current event on Tuesday.""" 173 | api.probable_outages_data = probable_outage_data 174 | # Tuesday at 11:00 (660 minutes) - should be in 600-900 slot 175 | tuesday = datetime.datetime(2025, 1, 28, 11, 0, 0) 176 | while tuesday.weekday() != 1: 177 | tuesday += datetime.timedelta(days=1) 178 | 179 | event = api.get_current_event(tuesday) 180 | assert event is not None 181 | assert event.event_type == OutageEventType.DEFINITE 182 | assert event.start.hour == 10 # 600 minutes = 10:00 183 | assert event.end.hour == 15 # 900 minutes = 15:00 184 | 185 | def test_get_current_event_no_data(self, api): 186 | """Test getting current event with no data.""" 187 | monday = datetime.datetime(2025, 1, 27, 9, 0, 0) 188 | event = api.get_current_event(monday) 189 | assert event is None 190 | 191 | def test_get_current_event_empty_weekday(self, api, probable_outage_data): 192 | """Test getting current event on weekday with no slots.""" 193 | api.probable_outages_data = probable_outage_data 194 | # Wednesday (2) has no slots 195 | wednesday = datetime.datetime(2025, 1, 29, 10, 0, 0) 196 | while wednesday.weekday() != 2: 197 | wednesday += datetime.timedelta(days=1) 198 | 199 | event = api.get_current_event(wednesday) 200 | assert event is None 201 | 202 | 203 | class TestProbableOutagesApiEventsBetween: 204 | """Test events between date range.""" 205 | 206 | def test_get_events_between_single_week(self, api, probable_outage_data): 207 | """Test getting events for a single week.""" 208 | api.probable_outages_data = probable_outage_data 209 | # Get a full week starting from Monday 210 | monday = datetime.datetime(2025, 1, 27, 0, 0, 0) 211 | while monday.weekday() != 0: 212 | monday += datetime.timedelta(days=1) 213 | 214 | sunday = monday + datetime.timedelta(days=7) 215 | events = api.get_events_between(monday, sunday) 216 | 217 | # Should have events for Monday and Tuesday (2 events) 218 | assert len(events) == 2 219 | assert all(e.source == OutageSource.PROBABLE for e in events) 220 | 221 | def test_get_events_between_multiple_weeks(self, api, probable_outage_data): 222 | """Test getting events across multiple weeks.""" 223 | api.probable_outages_data = probable_outage_data 224 | # Get 3 weeks 225 | monday = datetime.datetime(2025, 1, 27, 0, 0, 0) 226 | while monday.weekday() != 0: 227 | monday += datetime.timedelta(days=1) 228 | 229 | end_date = monday + datetime.timedelta(days=21) 230 | events = api.get_events_between(monday, end_date) 231 | 232 | # Should have 2 events per week * 3 weeks = 6 events 233 | assert len(events) == 6 234 | 235 | def test_get_events_between_single_day(self, api, probable_outage_data): 236 | """Test getting events for a single day.""" 237 | api.probable_outages_data = probable_outage_data 238 | # Monday only 239 | monday = datetime.datetime(2025, 1, 27, 0, 0, 0) 240 | while monday.weekday() != 0: 241 | monday += datetime.timedelta(days=1) 242 | 243 | tuesday = monday + datetime.timedelta(days=1) 244 | events = api.get_events_between(monday, tuesday) 245 | 246 | # Should have 1 event for Monday 247 | assert len(events) == 1 248 | assert events[0].start.weekday() == 0 # Monday 249 | 250 | def test_get_events_between_no_data(self, api): 251 | """Test getting events with no data.""" 252 | start = datetime.datetime(2025, 1, 27, 0, 0, 0) 253 | end = start + datetime.timedelta(days=7) 254 | events = api.get_events_between(start, end) 255 | assert events == [] 256 | 257 | def test_get_events_between_sorted(self, api, probable_outage_data): 258 | """Test that events are sorted by start time.""" 259 | api.probable_outages_data = probable_outage_data 260 | monday = datetime.datetime(2025, 1, 27, 0, 0, 0) 261 | while monday.weekday() != 0: 262 | monday += datetime.timedelta(days=1) 263 | 264 | end_date = monday + datetime.timedelta(days=7) 265 | events = api.get_events_between(monday, end_date) 266 | 267 | # Verify events are sorted 268 | for i in range(len(events) - 1): 269 | assert events[i].start <= events[i + 1].start 270 | 271 | def test_get_events_between_partial_week(self, api, probable_outage_data): 272 | """Test getting events for partial week (Wednesday to Friday).""" 273 | api.probable_outages_data = probable_outage_data 274 | # Start from Wednesday 275 | wednesday = datetime.datetime(2025, 1, 29, 0, 0, 0) 276 | while wednesday.weekday() != 2: 277 | wednesday += datetime.timedelta(days=1) 278 | 279 | saturday = wednesday + datetime.timedelta(days=3) 280 | events = api.get_events_between(wednesday, saturday) 281 | 282 | # No events on Wed, Thu, Fri in test data 283 | assert len(events) == 0 284 | 285 | def test_get_events_between_includes_monday_tuesday( 286 | self, api, probable_outage_data 287 | ): 288 | """Test that events on Monday and Tuesday are included.""" 289 | api.probable_outages_data = probable_outage_data 290 | # Start from Monday 291 | monday = datetime.datetime(2025, 1, 27, 0, 0, 0) 292 | while monday.weekday() != 0: 293 | monday += datetime.timedelta(days=1) 294 | 295 | wednesday = monday + datetime.timedelta(days=2) 296 | events = api.get_events_between(monday, wednesday) 297 | 298 | # Should have Monday and Tuesday events 299 | assert len(events) == 2 300 | weekdays = {e.start.weekday() for e in events} 301 | assert 0 in weekdays # Monday 302 | assert 1 in weekdays # Tuesday 303 | 304 | 305 | class TestProbableOutagesApiEdgeCases: 306 | """Test edge cases and special scenarios.""" 307 | 308 | def test_weekday_boundary(self, api, probable_outage_data): 309 | """Test event at weekday boundary.""" 310 | api.probable_outages_data = probable_outage_data 311 | # Monday at 23:59 312 | monday = datetime.datetime(2025, 1, 27, 23, 59, 59) 313 | while monday.weekday() != 0: 314 | monday += datetime.timedelta(days=1) 315 | 316 | event = api.get_current_event(monday) 317 | # Should not be in any slot (slot ends at 12:00) 318 | assert event is None 319 | 320 | def test_slot_start_boundary(self, api, probable_outage_data): 321 | """Test event at exact slot start time.""" 322 | api.probable_outages_data = probable_outage_data 323 | # Monday at exactly 8:00 (480 minutes) 324 | monday = datetime.datetime(2025, 1, 27, 8, 0, 0) 325 | while monday.weekday() != 0: 326 | monday += datetime.timedelta(days=1) 327 | 328 | event = api.get_current_event(monday) 329 | assert event is not None 330 | assert event.start.hour == 8 331 | 332 | def test_slot_end_boundary(self, api, probable_outage_data): 333 | """Test event at exact slot end time.""" 334 | api.probable_outages_data = probable_outage_data 335 | # Monday at exactly 12:00 (720 minutes) - should NOT be in slot (exclusive end) 336 | monday = datetime.datetime(2025, 1, 27, 12, 0, 0) 337 | while monday.weekday() != 0: 338 | monday += datetime.timedelta(days=1) 339 | 340 | event = api.get_current_event(monday) 341 | assert event is None # End is exclusive 342 | 343 | def test_before_slot(self, api, probable_outage_data): 344 | """Test time before slot starts.""" 345 | api.probable_outages_data = probable_outage_data 346 | # Monday at 7:00 (420 minutes) - before 480 347 | monday = datetime.datetime(2025, 1, 27, 7, 0, 0) 348 | while monday.weekday() != 0: 349 | monday += datetime.timedelta(days=1) 350 | 351 | event = api.get_current_event(monday) 352 | assert event is None 353 | 354 | def test_after_slot(self, api, probable_outage_data): 355 | """Test time after slot ends.""" 356 | api.probable_outages_data = probable_outage_data 357 | # Monday at 13:00 (780 minutes) - after 720 358 | monday = datetime.datetime(2025, 1, 27, 13, 0, 0) 359 | while monday.weekday() != 0: 360 | monday += datetime.timedelta(days=1) 361 | 362 | event = api.get_current_event(monday) 363 | assert event is None 364 | 365 | def test_midnight_event(self, api): 366 | """Test event at midnight.""" 367 | # Add slot that starts at midnight 368 | api.probable_outages_data = { 369 | str(TEST_REGION_ID): { 370 | "dsos": { 371 | str(TEST_PROVIDER_ID): { 372 | "groups": { 373 | TEST_GROUP: { 374 | "slots": { 375 | "0": [ # Monday 376 | {"start": 0, "end": 120, "type": "Definite"}, 377 | ], 378 | } 379 | } 380 | } 381 | } 382 | } 383 | } 384 | } 385 | 386 | monday = datetime.datetime(2025, 1, 27, 0, 0, 0) 387 | while monday.weekday() != 0: 388 | monday += datetime.timedelta(days=1) 389 | 390 | event = api.get_current_event(monday) 391 | assert event is not None 392 | assert event.start.hour == 0 393 | assert event.end.hour == 2 394 | 395 | 396 | class TestProbableOutagesApiRecurrence: 397 | """Test recurring event generation.""" 398 | 399 | def test_weekly_recurrence(self, api, probable_outage_data): 400 | """Test that events recur weekly.""" 401 | api.probable_outages_data = probable_outage_data 402 | # Get 2 weeks 403 | monday1 = datetime.datetime(2025, 1, 27, 0, 0, 0) 404 | while monday1.weekday() != 0: 405 | monday1 += datetime.timedelta(days=1) 406 | 407 | monday3 = monday1 + datetime.timedelta(days=14) 408 | 409 | events = api.get_events_between(monday1, monday3) 410 | 411 | # Filter Monday events 412 | monday_events = [e for e in events if e.start.weekday() == 0] 413 | assert len(monday_events) == 2 414 | 415 | # Check they're exactly 7 days apart 416 | assert (monday_events[1].start - monday_events[0].start).days == 7 417 | 418 | def test_event_times_consistent_across_weeks(self, api, probable_outage_data): 419 | """Test that event times are consistent across weeks.""" 420 | api.probable_outages_data = probable_outage_data 421 | monday = datetime.datetime(2025, 1, 27, 0, 0, 0) 422 | while monday.weekday() != 0: 423 | monday += datetime.timedelta(days=1) 424 | 425 | end_date = monday + datetime.timedelta(days=21) 426 | events = api.get_events_between(monday, end_date) 427 | 428 | # Get all Monday events 429 | monday_events = [e for e in events if e.start.weekday() == 0] 430 | 431 | # All should have same time of day 432 | for event in monday_events: 433 | assert event.start.hour == 8 434 | assert event.start.minute == 0 435 | assert event.end.hour == 12 436 | assert event.end.minute == 0 437 | -------------------------------------------------------------------------------- /agents.md: -------------------------------------------------------------------------------- 1 | # AI Coding Agents Guide 2 | 3 | ## Purpose 4 | 5 | Agents act as senior Python collaborators. Keep responses concise, 6 | clarify uncertainty before coding, and align suggestions with the rules linked below. 7 | 8 | ## Important directives 9 | 10 | - In all interactions and commit messages, be extremely concise and sacrifice grammar for the sake of concision. 11 | - If anything here is unclear, tell me what you want to do and I'll expand these instructions. 12 | - If you struggle to find a solution, suggest to add logger statements and ask for output to get more context and understand the flow better. When logger output is provided, analyze it to understand what is going on. 13 | - When updating this file (`agents.md`), DON'T CHANGE the structure, formatting or style of the document. Just add relevant information, without restructuring: add list items, new sections, etc. NEVER REMOVE tags, like or . 14 | - At the end of each plan, give me a list of unresolved questions to answer, if any. Make the questions extremely concise. Sacrifice grammar for the sake of concision. 15 | - Always use conventional commits format for commit messages. 16 | - ALWAYS run `scripts/lint` after any changes to the code. 17 | - ALWAYS run `scripts/test` before committing changes. 18 | 19 | ## Project Overview 20 | 21 | This repository is a Home Assistant custom integration providing electricity outage schedules for Ukraine using the [Yasno API](https://yasno.ua). Main codebase lives under `custom_components/yasno_outages`. 22 | 23 | ### Code structure 24 | 25 | - `translations/` - folder containing translations (en.json, uk.json). 26 | - `__init__.py` - init file of the integration, creates entries, sets up platforms, handles entry reload/unload. Stores runtime data (API, coordinator, integration) in `entry.runtime_data` as a `YasnoOutagesData` dataclass. 27 | - `api/` - package containing API classes for fetching data. Should be Home Assistant agnostic. Uses `aiohttp` for async HTTP requests. 28 | - `__init__.py` - exports `YasnoApi` facade providing unified access to planned and probable APIs. 29 | - `models.py` - data models: `OutageEvent`, `OutageSlot`, `OutageEventType` enum (DEFINITE, NOT_PLANNED), `OutageSource` enum (PLANNED, PROBABLE). 30 | - `base.py` - `BaseYasnoApi` with shared functionality (regions, providers, slot parsing). 31 | - `planned.py` - `PlannedOutagesApi` for fetching planned outages (today/tomorrow). 32 | - `probable.py` - `ProbableOutagesApi` for fetching probable outages (weekly recurring). 33 | - `const.py` - API-specific constants (endpoints, status values). 34 | - `config_flow.py` - a file describing a flow to create new entries and options flow for reconfiguration. Multi-step flow: region → service (DSO) → group. 35 | - `const.py` - a file containing constants used throughout the project. Use `homeassistant.const` for commonly used constants. 36 | - `coordinator.py` - a data fetching coordinator (`DataUpdateCoordinator`). Fetches data from API facade, filters NOT_PLANNED events, transforms to CalendarEvents. Polls API every 15 minutes. Takes API instance as a parameter. 37 | - `data.py` - defines runtime data types: `YasnoOutagesData` dataclass holding API, coordinator, and integration instances, and `YasnoOutagesConfigEntry` type alias for typed config entries. 38 | - `entity.py` - a base entity class (`YasnoOutagesEntity`) that is used as a template when creating sensors and calendar. Contains important `DeviceInfo` joining different entities into a single device. 39 | - `repairs.py` - repair flow for detecting and notifying users about deprecated configuration (API v1 → v2 migration). 40 | - `manifest.json` - a file declaring an integration manifest. 41 | - `sensor.py` - declares sensors using entity descriptors. Implements sensors: electricity state (enum), schedule updated timestamp, next planned outage, next probable outage, next connectivity. Retrieves coordinator from `entry.runtime_data.coordinator`. 42 | - `calendar.py` - implements calendar entity showing outage events in a timeline format. Retrieves coordinator from `entry.runtime_data.coordinator`. 43 | - `helpers.py` - shared helpers, e.g., `merge_consecutive_outages` used to merge adjacent outage slots before creating calendar events (always applied before status all-day events are added). 44 | - `diagnostics.py` - exposes `async_get_config_entry_diagnostics` with coordinator/api snapshots (states, ids, raw data) for HA diagnostics download. 45 | 46 | Fill in by LLM assistant memory 47 | 48 | ### Using Coordinator to Fetch Data 49 | 50 | We use a single `DataUpdateCoordinator` per config entry that polls the Yasno API every 15 minutes. The coordinator is created in `__init__.py` during setup and stored in `entry.runtime_data` as part of the `YasnoOutagesData` dataclass. Platforms (sensors, calendar) retrieve the coordinator from `config_entry.runtime_data.coordinator`. 51 | 52 | The coordinator: 53 | 54 | - Receives the API instance as a parameter (dependency injection) 55 | - Resolves region/service names to IDs on first refresh 56 | - Fetches outage schedules for the configured region, service (DSO), and group 57 | - Transforms API data into `CalendarEvent` objects 58 | - Computes derived values (current state, next outage times, etc.) 59 | - `current_event` wraps `planned.get_current_event` in try/except; use it instead of direct calls. 60 | - `next_connectivity` uses `current_event/current_state`; returns end of current outage, otherwise end of next planned outage; logs and returns None on errors. 61 | 62 | The runtime data pattern follows Home Assistant best practices: 63 | 64 | - `data.py` defines `YasnoOutagesData` dataclass with API, coordinator, and integration 65 | - `YasnoOutagesConfigEntry` type alias provides type safety for config entries 66 | - API instance is created in `__init__.py` and passed to coordinator (decoupled initialization) 67 | - Platforms access coordinator via `entry.runtime_data.coordinator` 68 | 69 | Documentation: https://developers.home-assistant.io/docs/integration_fetching_data 70 | 71 | ### Decouple API Data from Coordinator 72 | 73 | Coordinator should not rely on API response structure. API layer transforms raw JSON into typed Python objects (`OutageEvent`, `OutageSlot` dataclasses) before returning to coordinator. 74 | 75 | API architecture: 76 | 77 | - `YasnoApi` facade exposes `planned` and `probable` properties 78 | - Each API (`PlannedOutagesApi`, `ProbableOutagesApi`) inherits from `BaseYasnoApi` 79 | - APIs return `OutageEvent` objects with `source` field (OutageSource.PLANNED or PROBABLE) 80 | - Coordinator filters NOT_PLANNED events and transforms to CalendarEvents 81 | - API returns all events as-is; filtering happens at coordinator level 82 | - Coordinator is responsible for catching/handling API parsing/runtime errors; API layers may raise on malformed data so coordinator must log+fallback 83 | 84 | ## API 85 | 86 | External API: 87 | 88 | - Regions: `https://app.yasno.ua/api/blackout-service/public/shutdowns/addresses/v2/regions` 89 | - Planned Outages: 90 | - Path: `https://app.yasno.ua/api/blackout-service/public/shutdowns/regions/{region_id}/dsos/{dso_id}/planned-outages` 91 | - Example: `https://app.yasno.ua/api/blackout-service/public/shutdowns/regions/25/dsos/902/planned-outages` 92 | - Probable Outages: 93 | - Path: `https://app.yasno.ua/api/blackout-service/public/shutdowns/probable-outages?regionId={region_id}&dsoId={dso_id}` 94 | - Example: `https://app.yasno.ua/api/blackout-service/public/shutdowns/probable-outages?regionId=25&dsoId=902` 95 | 96 | All HTTP requests use `aiohttp` (async, non-blocking). No authentication required. 97 | 98 | Both planned and probable outages are implemented. 99 | 100 | ### Planned Outages 101 | 102 | Planned outages response have this scructure: 103 | 104 | ```json 105 | { 106 | "1.1": { 107 | "today": { 108 | "slots": [ 109 | { 110 | "start": 0, 111 | "end": 840, 112 | "type": "NotPlanned" 113 | }, 114 | { 115 | "start": 840, 116 | "end": 1080, 117 | "type": "Definite" 118 | }, 119 | { 120 | "start": 1080, 121 | "end": 1440, 122 | "type": "NotPlanned" 123 | } 124 | ], 125 | "date": "2025-11-05T00:00:00+02:00", 126 | "status": "ScheduleApplies" 127 | }, 128 | "tomorrow": { 129 | "slots": [ 130 | { 131 | "start": 0, 132 | "end": 1020, 133 | "type": "NotPlanned" 134 | }, 135 | { 136 | "start": 1020, 137 | "end": 1200, 138 | "type": "Definite" 139 | }, 140 | { 141 | "start": 1200, 142 | "end": 1440, 143 | "type": "NotPlanned" 144 | } 145 | ], 146 | "date": "2025-11-06T00:00:00+02:00", 147 | "status": "WaitingForSchedule" 148 | }, 149 | "updatedOn": "2025-11-05T11:57:32+00:00" 150 | }, 151 | "1.2": {}, // same structure 152 | "2.1": {}, // same structure 153 | "3.1": {}, // same structure 154 | "3.2": {}, // same structure 155 | "2.2": {}, // same structure 156 | "4.1": {}, // same structure 157 | "4.2": {}, // same structure 158 | "5.1": {}, // same structure 159 | "5.2": {}, // same structure 160 | "6.1": {}, // same structure 161 | "6.2": {} // same structure 162 | } 163 | ``` 164 | 165 | #### Groups 166 | 167 | Each group is coded as two digins `x.y`, `x` means group, `y` means subgroup. In planned outages each group have two properties `today` and `tomorrow`, describing tyime slots for outages. 168 | 169 | #### Slots 170 | 171 | Slots describe events. `start` and `end` are minutes in a day (from 0 to 1440). Slots can have these types: 172 | 173 | - `NotPlaned` - no outages planned. Do not create any events from this type of slot. 174 | - `Definite` - outage event. Event should be created for this time. This event should use date from `date` property. 175 | 176 | #### Updated on 177 | 178 | `updatedOn` property reflects when the schedule was updated by service provider (not the last time intergation fetched the data). There should be a sensor in `sensor.py` reflecting this value. 179 | 180 | #### Status 181 | 182 | Status property describes the type of the events and how to deal with them. There should be a sensor in `sensor.py` with corresponding status for `today`. 183 | 184 | Here are types of statuses: 185 | 186 | - `ScheduleApplies` - slots are applied. Events should be added to the calendar. 187 | - `WaitingForSchedule` - slots are up for a changes. Created events, but they may be changed. 188 | - `EmergencyShutdowns` - slots should be displayed in the calendar, but they are not active. Emmergency is happening. 189 | 190 | ### Probable Outages 191 | 192 | Probable outages reflect the permanent schedule, that is active at all time. This integration creates recurring events for DEFINITE slots described in specified group. 193 | 194 | Planned outages is a specific clarification of how schedule looks today and tomorrow. Planned outages are kind of subset of probable outages. 195 | 196 | Calendar entity shows both planned and probable events in a unified timeline. Events are distinguished by source (OutageSource.PLANNED/PROBABLE). 197 | 198 | Here is an example of response: 199 | 200 | ```json 201 | { 202 | "25": { 203 | "dsos": { 204 | "902": { 205 | "groups": { 206 | "1.1": { 207 | "slots": { 208 | "0": [ 209 | { 210 | "start": 0, 211 | "end": 300, 212 | "type": "Definite" 213 | }, 214 | { 215 | "start": 300, 216 | "end": 510, 217 | "type": "NotPlanned" 218 | }, 219 | { 220 | "start": 510, 221 | "end": 930, 222 | "type": "Definite" 223 | }, 224 | { 225 | "start": 930, 226 | "end": 1140, 227 | "type": "NotPlanned" 228 | }, 229 | { 230 | "start": 1140, 231 | "end": 1440, 232 | "type": "Definite" 233 | } 234 | ], 235 | "1": [], // similar structure 236 | "2": [], // similar structure 237 | "3": [], // similar structure 238 | "4": [], // similar structure 239 | "5": [], // similar structure 240 | "6": [] // similar structure 241 | } 242 | }, 243 | "1.2": {}, // similar structures 244 | "2.1": {}, // similar structures 245 | "2.2": {}, // similar structures 246 | "3.1": {}, // similar structures 247 | "3.2": {}, // similar structures 248 | "4.1": {}, // similar structures 249 | "4.2": {}, // similar structures 250 | "5.1": {}, // similar structures 251 | "5.2": {}, // similar structures 252 | "6.1": {}, // similar structures 253 | "6.2": {} // similar structures 254 | } 255 | } 256 | } 257 | } 258 | } 259 | ``` 260 | 261 | Response contains region and service provider. `groups` property describes all available groups. Each group describes slots for each day of the week (from 0 to 6, meaning from monday to sunday). Each day has time slots for events with the same structure as planned outages. 262 | 263 | `Definite` slots for probable outages create recurring weekly events. API uses `dateutil.rrule` for generating recurrences. 264 | 265 | ## Workflow 266 | 267 | Fill in by LLM assistant in memory 268 | 269 | This project is developed from Devcontainer described in `.devcontainer.json` file. 270 | 271 | - **Adding/changing data fetching** 272 | - Extend API classes in `api/` package first; return Python objects (OutageEvent dataclasses) independent of raw JSON. 273 | - API should return all data as-is without filtering (e.g., return both DEFINITE and NOT_PLANNED events) 274 | - Use/extend `coordinator.py` to filter NOT_PLANNED events and compute derived values (current state, next outage times). 275 | - Keep it simple: coordinator stored directly in `entry.runtime_data`. 276 | - CalendarEvent conversion happens in `calendar.py` via `to_calendar_event(coordinator, event)`. 277 | - `to_calendar_event` never returns `None`; callers must guard `None`/irrelevant events. 278 | - Event `uid` format: `{source.value}-{start.isoformat()}` (e.g., `planned-2025-11-15T07:30:00+02:00`) or `status-{date.isoformat()}` for status events. 279 | - OutageSource enum (in models.py) distinguishes PLANNED vs PROBABLE events 280 | - OutageEvent.source field indicates calendar origin (OutageSource enum) 281 | - Summaries come from `event_summary_map` (outages) or `status_event_summary_map` (statuses) property with translation fallbacks 282 | - CalendarEvent.description contains event.event_type.value for state mapping 283 | - State and connectivity determined only by planned events (settled schedule) 284 | - Horizon constants (`PLANNED_OUTAGE_LOOKAHEAD`, `PROBABLE_OUTAGE_LOOKAHEAD`) in `const.py` 285 | - Electricity sensor attributes use raw planned `get_current_event` (may be NotPlanned); attributes must reflect active slot even when power is normal. 286 | - **Entities and platforms** 287 | - Add new sensor descriptors in `sensor.py` (use `translation_key`). 288 | - Unique ID format: `{entry_id}-{group}-{sensor_key}`; do not hardcode unique IDs in config flow. 289 | - Device naming uses `DeviceInfo` with translation placeholders: `{region}`, `{provider}`, `{group}`. 290 | - Calendar: Single calendar entity per entry showing all outage events with translated event names. 291 | - Calendar events are always sorted by `start` before returning from coordinator getters. 292 | - Calendar can optionally show all-day events for today/tomorrow statuses if enabled in config. 293 | - **Config flow** 294 | - Multi-step: Region → Service (DSO) → Group 295 | - Auto-skip: If only one service available, auto-select it and skip to group step 296 | - Options flow: Same steps as config flow, allows reconfiguration 297 | - No duplicate detection needed: Each entry is unique (users may want multiple groups) 298 | - API calls in flow: Fetch regions list, then services for region, then groups for service 299 | - **Repairs** 300 | - Purpose: Notify users about deprecated configuration (API v1 → v2 migration) 301 | - Detection: Check for `CONF_CITY` key (old format) in `entry.data` or `entry.options` 302 | - Action: Create non-fixable warning issue asking user to remove and re-add integration 303 | - **Translations** 304 | - Edit `translations/*.json` directly (en.json, uk.json). 305 | - Translate values only; keep keys the same. Preserve placeholders: `{region}`, `{provider}`, `{group}`. 306 | - Structure: `config.step.*` (flows), `entity.{platform}.{key}` (entities), `device.*` (device naming), `common.*` (shared strings), `issues.*` (repairs). 307 | - **Testing** 308 | - Tests in `tests/` cover API and integration modules. 309 | - API tests in `tests/api/`: `test_models.py`, `test_base.py`, `test_planned.py`, `test_probable.py` (104 tests, 94% API coverage). 310 | - Integration tests: `test_helpers.py`, `test_calendar.py`, `test_coordinator.py` (46 tests). 311 | - Run `scripts/test` for full test suite with coverage. 312 | - Run `uv run pytest tests/api/test_models.py` for specific test file. 313 | - Run `uv run pytest tests/test_base.py::TestBaseYasnoApiInit` for specific test class. 314 | - Fixtures in `tests/conftest.py`: `today`, `tomorrow`, `regions_data`, `planned_outage_data`, `probable_outage_data`. 315 | - Use async/await for async tests; pytest-asyncio handles automatically. 316 | - Mock aiohttp responses with `AsyncMock` for API fetch tests. 317 | - Mock coordinator/HA components with `MagicMock` for integration tests. 318 | - When adding features, write tests. Maintain >90% API coverage. 319 | - **When unsure** 320 | - Prefer adding debug logs and ask for the output to reason about runtime state. 321 | 322 | ### Tooling 323 | 324 | - Python deps tracked in `pyproject.toml` and `uv.lock`; use `scripts/bootstrap` for dev installs. 325 | - CI workflows (`lint.yml`, `validate.yml`) install uv via `astral-sh/setup-uv` and run tooling with `uv run`. 326 | - Run python tools via `uv run ` to ensure consistent environment. 327 | - Each time you make changes to Python code, run `scripts/lint` to check for errors and formatting issues. Fix any issues reported by the linter. 328 | - Run `scripts/test` to execute tests with coverage reporting. 329 | 330 | ### Develompent Scripts 331 | 332 | Use these scripts for common development tasks. When you make changes and want to validate your work, use these scripts. 333 | 334 | - `scripts/bootstrap` - sets up dev environment (creates venv, installs dependencies). 335 | - `scripts/bump_version` - bumps version in manifest.json. 336 | - `scripts/develop` - starts a development Home Assistant server instance on port 8123. Use this script for checking changes in the browser. 337 | - `scripts/lint` - runs linter/formatter. Always use this script for checking for errors and formatting. 338 | - `scripts/test` - runs test suite with coverage reporting. Use for validating changes. 339 | - `scripts/setup` - installs dependencies and installs pre-commit. 340 | 341 | ### Development Process 342 | 343 | - Ask for clarification when requirements are ambiguous; surface 2–3 options when trade-offs matter. 344 | - Update documentation and related rules when introducing new patterns or services. 345 | - When unsure or need to make a significant decision ASK the user for guidance 346 | - Commit only when directly asked to do so. Write descriptive commit messages. 347 | 348 | ## Code Style 349 | 350 | Use code style described in `.ruff.toml` configuration. Standard Python. 2-spaces indentation. 351 | 352 | Never import modules in functions. All imports must be located on top of the file. 353 | 354 | ## Translations 355 | 356 | - Translations: copy `translations/en.json` to add locales; translate values only where appropriate per HA guidelines. 357 | - Entities: Use the `translation_key` defined in sensor/calendar entity descriptions. 358 | - Placeholders: Reference `{region}`, `{provider}`, and `{group}` from `translation_placeholders` supplied by `device_info` when rendering device names. 359 | - Add locales by copying `translations/en.json` and translating values per HA guidelines. 360 | 361 | ## Home Assistant API 362 | 363 | Carefully read links to the Home Assistant Developer documentation for guidance. 364 | 365 | Use these code quality guidelines by Home Assistant developers: 366 | https://github.com/home-assistant/core/raw/refs/heads/dev/.github/copilot-instructions.md 367 | 368 | Fetch these links to get more information about specific Home Assistant APIs directly from its documentation: 369 | 370 | - File structure: https://developers.home-assistant.io/docs/creating_integration_file_structure 371 | - Config Flow: https://developers.home-assistant.io/docs/config_entries_config_flow_handler 372 | - Fetching data: https://developers.home-assistant.io/docs/integration_fetching_data 373 | - Repairs: https://developers.home-assistant.io/docs/core/platform/repairs 374 | - Sensor: https://developers.home-assistant.io/docs/core/entity/sensor 375 | - Calendar: https://developers.home-assistant.io/docs/core/entity/calendar 376 | - Config Entries: https://developers.home-assistant.io/docs/config_entries_index 377 | - Data Entry Flow: https://developers.home-assistant.io/docs/data_entry_flow_index 378 | - Manifest: https://developers.home-assistant.io/docs/creating_integration_manifest 379 | 380 | ## Commit messages 381 | 382 | When generating commit messages, always use this format: 383 | 384 | ``` 385 | (): summary up to 40 characters 386 | 387 | Longer multiline description only for bigger changes that require additional explanations. 388 | ``` 389 | 390 | Summary should be concise and descriptive. Summary should not contain implicit or generic words like (enhance, improve, etc), instead it should clearly specify what is changed. 391 | 392 | Use longer descriptions ocasionally to describe complex changes, only when it's really necessary. 393 | --------------------------------------------------------------------------------