├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── c_blank.md │ ├── b_feature_request.md │ └── a_bug_report.yml ├── workflows │ ├── hacs-validate.yml │ ├── count_lines.yml │ └── release.yml ├── scripts │ └── update_hacs_manifest.py └── bug_report.md ├── .gitignore ├── scripts ├── lint ├── setup └── develop ├── hacs.json ├── crowdin.yml ├── custom_components └── webuntis │ ├── manifest.json │ ├── utils │ ├── errors.py │ ├── web_untis.py │ ├── utils.py │ ├── web_untis_extended.py │ ├── exams.py │ └── homework.py │ ├── services.yaml │ ├── const.py │ ├── repairs.py │ ├── event.py │ ├── services.py │ ├── sensor.py │ ├── calendar.py │ ├── notify.py │ ├── translations │ ├── nl.json │ ├── en.json │ └── de.json │ ├── config_flow.py │ └── __init__.py ├── LICENSE ├── .devcontainer.json ├── docs ├── ENTITIES_AND_SERVICES.md ├── SETUP.md ├── OPTIONAL_CONFIGURATIONS.md └── EXAMPLES_AND_AUTOMATIONS.md └── README.md /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | custom_components/webuntis/homeassistant-WebUntis.code-workspace 3 | config -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | ruff format . 8 | ruff check . --fix 9 | -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | python3 -m pip install colorlog homeassistant pip ruff 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/c_blank.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Blank 3 | about: Need help? Use this? 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Web Untis", 3 | "render_readme": true, 4 | "zip_release": true, 5 | "filename": "webuntis.zip", 6 | "homeassistant": "2021.11.0" 7 | } 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/b_feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | files: 2 | - source: /custom_components/webuntis/translations/en.json 3 | translation: /custom_components/webuntis/translations/%two_letters_code%.json 4 | api_token: CROWDIN_PROJECT_ID 5 | project_id: CROWDIN_PERSONAL_TOKEN 6 | -------------------------------------------------------------------------------- /custom_components/webuntis/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "webuntis", 3 | "name": "Web Untis", 4 | "codeowners": [ 5 | "@JonasJoKuJonas" 6 | ], 7 | "config_flow": true, 8 | "documentation": "https://github.com/JonasJoKuJonas/homeassistant-WebUntis/", 9 | "integration_type": "device", 10 | "iot_class": "cloud_polling", 11 | "issue_tracker": "https://github.com/JonasJoKuJonas/homeassistant-WebUntis/issues", 12 | "loggers": [ 13 | "webuntis" 14 | ], 15 | "quality_scale": "silver", 16 | "requirements": [ 17 | "webuntis==0.1.24" 18 | ], 19 | "version": "v0.0.0" 20 | } 21 | -------------------------------------------------------------------------------- /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 lets us have the structure we want /custom_components/webuntis 15 | ## while at the same time have Home Assistant configuration inside /config 16 | ## without resulting to symlinks. 17 | export PYTHONPATH="${PWD}:${PYTHONPATH}" 18 | 19 | # Start Home Assistant 20 | hass --config "${PWD}/config" --debug 21 | 22 | -------------------------------------------------------------------------------- /.github/workflows/hacs-validate.yml: -------------------------------------------------------------------------------- 1 | name: validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | #schedule: 7 | # - cron: "0 0 * * *" 8 | 9 | workflow_dispatch: 10 | 11 | jobs: 12 | validate-hassfest: 13 | name: Hassfest validation 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: checkout 17 | uses: actions/checkout@v3 18 | - name: validation 19 | uses: home-assistant/actions/hassfest@master 20 | 21 | validate-hacs: 22 | name: HACS validation 23 | runs-on: "ubuntu-latest" 24 | steps: 25 | - name: checkout 26 | uses: actions/checkout@v3 27 | - name: validation 28 | uses: hacs/action@main 29 | with: 30 | category: "integration" 31 | -------------------------------------------------------------------------------- /.github/scripts/update_hacs_manifest.py: -------------------------------------------------------------------------------- 1 | """Update the manifest file.""" 2 | import json 3 | import os 4 | import sys 5 | 6 | 7 | def update_manifest(): 8 | """Update the manifest file.""" 9 | version = "0.0.0" 10 | for index, value in enumerate(sys.argv): 11 | if value in ["--version", "-V"]: 12 | version = sys.argv[index + 1] 13 | 14 | with open( 15 | f"{os.getcwd()}/custom_components/webuntis/manifest.json" 16 | ) as manifestfile: 17 | manifest = json.load(manifestfile) 18 | 19 | manifest["version"] = version 20 | 21 | with open( 22 | f"{os.getcwd()}/custom_components/webuntis/manifest.json", "w" 23 | ) as manifestfile: 24 | manifestfile.write(json.dumps(manifest, indent=4, sort_keys=True)) 25 | 26 | 27 | update_manifest() 28 | -------------------------------------------------------------------------------- /.github/workflows/count_lines.yml: -------------------------------------------------------------------------------- 1 | name: Count Lines of Code 2 | 3 | # Controls when the action will run. Triggers the workflow on push or pull request 4 | # events but only for the main branch 5 | on: 6 | workflow_dispatch: 7 | 8 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 9 | jobs: 10 | # This workflow contains a single job called "build" 11 | cloc: 12 | # The type of runner that the job will run on 13 | runs-on: ubuntu-latest 14 | 15 | # Steps represent a sequence of tasks that will be executed as part of the job 16 | steps: 17 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 18 | - uses: actions/checkout@v3 19 | 20 | # Runs djdefi/cloc-action 21 | - name: Count Lines of Code (cloc) 22 | uses: djdefi/cloc-action@5 23 | -------------------------------------------------------------------------------- /.github/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## The problem 11 | 15 | 16 | 17 | ## Environment 18 | 23 | 24 | - Home Assistant version: 25 | - Operating environment (OS/Container/Supervised/Core): 26 | 27 | - WebUntis version: 28 | - Last working WebUntis version (if known): 29 | 30 | 31 | 32 | ## Traceback/Error logs 33 | 36 | 37 | ``` 38 | 39 | ``` 40 | 41 | ## Additional information 42 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | release_zip_file: 9 | name: Prepare release asset 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out repository 13 | uses: actions/checkout@v2 14 | 15 | - name: Get version 16 | id: version 17 | uses: home-assistant/actions/helpers/version@master 18 | 19 | - name: "Set manifest version number" 20 | run: | 21 | python3 ${{ github.workspace }}/.github/scripts/update_hacs_manifest.py --version ${{ steps.version.outputs.version }} 22 | - name: Create zip 23 | run: | 24 | cd custom_components/webuntis 25 | zip webuntis.zip -r ./ 26 | - name: Upload zip to release 27 | uses: svenstaro/upload-release-action@v1-release 28 | with: 29 | repo_token: ${{ secrets.GITHUB_TOKEN }} 30 | file: ./custom_components/webuntis/webuntis.zip 31 | asset_name: webuntis.zip 32 | tag: ${{ github.ref }} 33 | overwrite: true 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Jonas 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 | -------------------------------------------------------------------------------- /custom_components/webuntis/utils/errors.py: -------------------------------------------------------------------------------- 1 | from homeassistant.exceptions import HomeAssistantError 2 | 3 | 4 | class CannotConnect(HomeAssistantError): 5 | """Error to indicate we cannot connect.""" 6 | 7 | 8 | class InvalidAuth(HomeAssistantError): 9 | """Error to indicate there is invalid auth.""" 10 | 11 | 12 | class BadCredentials(HomeAssistantError): 13 | """Error to indicate there are bad credentials.""" 14 | 15 | 16 | class SchoolNotFound(HomeAssistantError): 17 | """Error to indicate the school is not found.""" 18 | 19 | 20 | class NameSplitError(HomeAssistantError): 21 | """Error to indicate the name format is wrong.""" 22 | 23 | 24 | class StudentNotFound(HomeAssistantError): 25 | """Error to indicate there is no student with this name.""" 26 | 27 | 28 | class TeacherNotFound(HomeAssistantError): 29 | """Error to indicate there is no teacher with this name.""" 30 | 31 | 32 | class ClassNotFound(HomeAssistantError): 33 | """Error to indicate there is no class with this name.""" 34 | 35 | 36 | class NoRightsForTimetable(HomeAssistantError): 37 | """Error to indicate there is no right for timetable.""" 38 | -------------------------------------------------------------------------------- /custom_components/webuntis/services.yaml: -------------------------------------------------------------------------------- 1 | get_timetable: 2 | description: Get WebUntis timetable 3 | fields: 4 | device_id: 5 | required: true 6 | selector: 7 | device: 8 | filter: 9 | - integration: webuntis 10 | start: 11 | required: true 12 | selector: 13 | date: 14 | end: 15 | required: true 16 | selector: 17 | date: 18 | apply_filter: 19 | required: true 20 | default: true 21 | selector: 22 | boolean: 23 | show_cancelled: 24 | required: true 25 | default: true 26 | selector: 27 | boolean: 28 | compact_result: 29 | required: true 30 | default: true 31 | selector: 32 | boolean: 33 | 34 | count_lessons: 35 | description: Get WebUntis timetable 36 | fields: 37 | device_id: 38 | required: true 39 | selector: 40 | device: 41 | filter: 42 | - integration: webuntis 43 | start: 44 | required: true 45 | selector: 46 | date: 47 | end: 48 | required: true 49 | selector: 50 | date: 51 | apply_filter: 52 | required: true 53 | default: true 54 | selector: 55 | boolean: 56 | count_cancelled: 57 | required: true 58 | default: false 59 | selector: 60 | boolean: 61 | 62 | get_schoolyears: 63 | description: Get WebUntis schoolyears 64 | fields: 65 | device_id: 66 | required: true 67 | selector: 68 | device: 69 | filter: 70 | - integration: webuntis 71 | -------------------------------------------------------------------------------- /.devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ludeeus/integration_blueprint", 3 | "image": "mcr.microsoft.com/devcontainers/python:3.13", 4 | "postCreateCommand": "scripts/setup", 5 | "runArgs": [ 6 | "--network=host" 7 | ], 8 | "forwardPorts": [ 9 | 8123 10 | ], 11 | "portsAttributes": { 12 | "8123": { 13 | "label": "Home Assistant", 14 | "onAutoForward": "notify" 15 | } 16 | }, 17 | "customizations": { 18 | "vscode": { 19 | "extensions": [ 20 | "charliermarsh.ruff", 21 | "github.vscode-pull-request-github", 22 | "ms-python.python", 23 | "ms-python.vscode-pylance", 24 | "ryanluker.vscode-coverage-gutters" 25 | ], 26 | "settings": { 27 | "files.eol": "\n", 28 | "editor.tabSize": 4, 29 | "editor.formatOnPaste": true, 30 | "editor.formatOnSave": true, 31 | "editor.formatOnType": false, 32 | "files.trimTrailingWhitespace": true, 33 | "python.analysis.typeCheckingMode": "basic", 34 | "python.analysis.autoImportCompletions": true, 35 | "python.defaultInterpreterPath": "/usr/local/bin/python", 36 | "[python]": { 37 | "editor.defaultFormatter": "charliermarsh.ruff" 38 | } 39 | } 40 | } 41 | }, 42 | "remoteUser": "vscode", 43 | "features": { 44 | "ghcr.io/devcontainers-extra/features/apt-packages:1": { 45 | "packages": "ffmpeg,libturbojpeg0,libpcap-dev" 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /custom_components/webuntis/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Web Untis integration.""" 2 | 3 | DOMAIN = "webuntis" 4 | 5 | CONFIG_ENTRY_VERSION = 18 6 | 7 | DEFAULT_OPTIONS = { 8 | "lesson_long_name": True, 9 | "calendar_show_cancelled_lessons": False, 10 | "keep_loged_in": False, 11 | "filter_mode": "None", 12 | "filter_subjects": [], 13 | "generate_json": False, 14 | "exclude_data": [], 15 | "filter_description": [], 16 | "calendar_description": "none", 17 | "calendar_room": "Room long name", 18 | "calendar_show_room_change": False, 19 | "notify_config": {}, 20 | "invalid_subjects": False, 21 | } 22 | 23 | NOTIFY_OPTIONS = ["homework", "cancelled", "rooms", "lesson_change", "teachers", "code"] 24 | 25 | TEMPLATE_OPTIONS = ["message_title", "message", "discord", "telegram"] 26 | 27 | 28 | ICON_SENSOR_NEXT_CLASS = "mdi:table-clock" 29 | ICON_SENSOR_NEXT_LESSON_TO_WAKE_UP = "mdi:clock-start" 30 | ICON_SENSOR_TODAY_START = "mdi:calendar-start" 31 | ICON_SENSOR_TODAY_END = "mdi:calendar-end" 32 | ICON_CALENDAR = "mdi:school-outline" 33 | ICON_CALENDAR_HOMEWORK = "mdi:home-edit" 34 | ICON_CALENDAR_EXAM = "mdi:pen" 35 | ICON_EVENT_LESSNON_CHANGE = "mdi:calendar-clock" 36 | ICON_EVENT_HOMEWORK = "mdi:home-edit" 37 | 38 | NAME_SENSOR_NEXT_CLASS = "next_class" 39 | NAME_SENSOR_NEXT_LESSON_TO_WAKE_UP = "next_lesson_to_wake_up" 40 | NAME_SENSOR_TODAY_START = "today_school_start" 41 | NAME_SENSOR_TODAY_END = "today_school_end" 42 | NAME_CALENDAR = "calendar" 43 | NAME_CALENDAR_HOMEWORK = "homework" 44 | NAME_CALENDAR_EXAM = "exam" 45 | NAME_EVENT_LESSON_CHANGE = "lesson_change" 46 | NAME_EVENT_HOMEWORK = "new_homework" 47 | 48 | 49 | SCAN_INTERVAL = 10 * 60 # 10min 50 | 51 | SIGNAL_NAME_PREFIX = f"signal_{DOMAIN}" 52 | 53 | DAYS_TO_FUTURE = 30 54 | 55 | # Homework 56 | DAYS_TO_CHECK = 30 57 | -------------------------------------------------------------------------------- /custom_components/webuntis/repairs.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import voluptuous as vol 4 | 5 | from homeassistant import data_entry_flow 6 | from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow 7 | from homeassistant.core import HomeAssistant 8 | 9 | 10 | from .config_flow import ConfigFlow 11 | 12 | 13 | class IssueChangePassword(RepairsFlow): 14 | """Handler for an issue fixing flow.""" 15 | 16 | def __init__(self, hass, data) -> None: 17 | """Create flow.""" 18 | 19 | self._hass: HomeAssistant = hass 20 | self._config = data["config_data"] 21 | self._entry_id = data["entry_id"] 22 | super().__init__() 23 | 24 | async def async_step_init( 25 | self, user_input: dict[str, str] | None = None 26 | ) -> data_entry_flow.FlowResult: 27 | """Handle the first step of a fix flow.""" 28 | 29 | return await self.async_step_confirm() 30 | 31 | async def async_step_confirm( 32 | self, user_input: dict[str, str] | None = None 33 | ) -> data_entry_flow.FlowResult: 34 | """Handle the confirm step of a fix flow.""" 35 | errors = {} 36 | if user_input is not None: 37 | data = self._config 38 | data["password"] = user_input["password"] 39 | flow = ConfigFlow() 40 | flow.hass = self._hass 41 | errors, _session_temp = await flow.validate_login(data) 42 | 43 | if not errors: 44 | entry = self.hass.config_entries.async_get_entry(self._entry_id) 45 | self.hass.config_entries.async_update_entry(entry, data=data) 46 | return self.async_create_entry(title="", data={}) 47 | 48 | errors["base"] = next(iter(errors.values())) 49 | 50 | return self.async_show_form( 51 | step_id="confirm", 52 | data_schema=vol.Schema( 53 | { 54 | vol.Required("password"): str, 55 | } 56 | ), 57 | errors=errors, 58 | ) 59 | 60 | 61 | async def async_create_fix_flow( 62 | hass: HomeAssistant, 63 | issue_id: str, 64 | data: dict[str, str | int | float | None] | None, 65 | ) -> RepairsFlow: 66 | """Create flow.""" 67 | return IssueChangePassword(hass, data) 68 | -------------------------------------------------------------------------------- /custom_components/webuntis/utils/web_untis.py: -------------------------------------------------------------------------------- 1 | def get_timetable_object(timetable_source_id, timetable_source, session): 2 | """return the object to request the timetable""" 3 | 4 | source = None 5 | 6 | if timetable_source == "student": 7 | source = session.get_student(timetable_source_id[1], timetable_source_id[0]) 8 | elif timetable_source == "klasse": 9 | klassen = session.klassen() 10 | 11 | source = klassen.filter(name=timetable_source_id)[0] 12 | elif timetable_source == "teacher": 13 | source = session.get_teacher(timetable_source_id[1], timetable_source_id[0]) 14 | elif timetable_source == "subject": 15 | pass 16 | elif timetable_source == "room": 17 | pass 18 | 19 | return {timetable_source: source} 20 | 21 | 22 | from datetime import datetime 23 | 24 | 25 | def get_lesson_name(server, lesson): 26 | 27 | def get_attr(obj, attr, default=None): 28 | """Versucht obj.attr oder obj[attr] zu holen""" 29 | if hasattr(obj, attr): 30 | return getattr(obj, attr, default) 31 | if isinstance(obj, dict): 32 | return obj.get(attr, default) 33 | return default 34 | 35 | try: 36 | subjects = get_attr(lesson, "subjects", []) 37 | first_subject = subjects[0] if subjects else None 38 | if first_subject: 39 | if server.lesson_long_name: 40 | subject = get_attr(first_subject, "long_name", None) 41 | else: 42 | subject = get_attr(first_subject, "name", None) 43 | else: 44 | subject = None 45 | except IndexError: 46 | subject = None 47 | 48 | if not subject: 49 | subject = get_attr(lesson, "lstext", get_attr(lesson, "substText", "Noneasd")) 50 | 51 | name = server.lesson_replace_name.get(subject, subject) 52 | 53 | if subject in server.lesson_add_teacher: 54 | 55 | teachers = get_attr(lesson, "teachers", []) 56 | if teachers: 57 | teacher = get_attr(teachers[0], "name", None) 58 | 59 | name += f" - {teacher}" 60 | 61 | return name 62 | 63 | 64 | def get_lesson_name_str(server, subject, teacher): 65 | 66 | name = server.lesson_replace_name.get(subject, subject) 67 | 68 | if subject in server.lesson_add_teacher: 69 | name += f" - {teacher}" 70 | 71 | return name 72 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/a_bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a report to help us improve. 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | This issue form is for reporting bugs only! 8 | 9 | - type: textarea 10 | validations: 11 | required: true 12 | attributes: 13 | label: The problem 14 | description: >- 15 | Describe the issue you are experiencing here 16 | 17 | - type: markdown 18 | attributes: 19 | value: | 20 | ## Environment 21 | - type: input 22 | id: version 23 | validations: 24 | required: true 25 | attributes: 26 | label: Version of Home Assistant? 27 | description: > 28 | Can be found in: [Settings -> About](https://my.home-assistant.io/redirect/info/). 29 | 30 | [![Open your Home Assistant instance and show your Home Assistant version information.](https://my.home-assistant.io/badges/info.svg)](https://my.home-assistant.io/redirect/info/) 31 | 32 | - type: dropdown 33 | validations: 34 | required: true 35 | attributes: 36 | label: What type of installation are you running? 37 | description: > 38 | Can be found in: [Settings -> System-> Repairs -> Three Dots in Upper Right -> System information](https://my.home-assistant.io/redirect/system_health/). 39 | 40 | [![Open your Home Assistant instance and show health information about your system.](https://my.home-assistant.io/badges/system_health.svg)](https://my.home-assistant.io/redirect/system_health/) 41 | options: 42 | - Home Assistant OS 43 | - Home Assistant Container 44 | - Home Assistant Supervised 45 | - Home Assistant Core 46 | 47 | - type: input 48 | validations: 49 | required: true 50 | id: webuntis_version 51 | attributes: 52 | label: Version of WebUntis 53 | placeholder: v0. 54 | - type: input 55 | id: webuntis_version_working 56 | attributes: 57 | label: Last working WebUntis version 58 | placeholder: v0. 59 | description: | 60 | (if known) 61 | 62 | - type: markdown 63 | attributes: 64 | value: | 65 | # Details 66 | - type: textarea 67 | attributes: 68 | label: Traceback/Error logs 69 | description: >- 70 | If you come across any trace or error logs, please provide them. 71 | - type: textarea 72 | attributes: 73 | label: Additional information 74 | description: >- 75 | Additional information that might help to solve your problem 76 | -------------------------------------------------------------------------------- /custom_components/webuntis/event.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from homeassistant.components.event import EventEntity 4 | from homeassistant.config_entries import ConfigEntry 5 | from homeassistant.core import HomeAssistant, callback 6 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 7 | 8 | from . import WebUntisEntity 9 | from .const import ( 10 | DOMAIN, 11 | ICON_EVENT_LESSNON_CHANGE, 12 | NAME_EVENT_LESSON_CHANGE, 13 | ICON_EVENT_HOMEWORK, 14 | NAME_EVENT_HOMEWORK, 15 | ) 16 | 17 | 18 | async def async_setup_entry( 19 | hass: HomeAssistant, 20 | config_entry: ConfigEntry, 21 | async_add_entities: AddEntitiesCallback, 22 | ) -> None: 23 | """Set up Example sensor based on a config entry.""" 24 | server = hass.data[DOMAIN][config_entry.unique_id] 25 | entities = [LessonChangeEventEntity(server)] 26 | if server.timetable_source != "teacher": 27 | entities.append(HomeworkEventEntity(server)) 28 | async_add_entities( 29 | entities, 30 | True, 31 | ) 32 | 33 | 34 | class BaseUntisEventEntity(WebUntisEntity, EventEntity): 35 | """Base class for WebUntis event entities.""" 36 | 37 | def __init__( 38 | self, 39 | server, 40 | name: str, 41 | icon: str, 42 | event_types: list[str], 43 | ) -> None: 44 | """Initialize the base event entity.""" 45 | super().__init__(server=server, name=name, icon=icon, device_class=None) 46 | self._server = server 47 | self._attr_event_types = event_types 48 | self.id = name 49 | 50 | @callback 51 | def _async_handle_event(self, event: str, data: dict) -> None: 52 | """Handle incoming event and update state.""" 53 | self._trigger_event(event, data) 54 | self.async_write_ha_state() 55 | 56 | async def async_added_to_hass(self) -> None: 57 | """Register event listener with the server.""" 58 | self._server.event_entity_listen(self._async_handle_event, self.id) 59 | 60 | @property 61 | def available(self) -> bool: 62 | """Return sensor availability.""" 63 | return True 64 | 65 | 66 | class LessonChangeEventEntity(BaseUntisEventEntity): 67 | """Event entity for lesson changes.""" 68 | 69 | def __init__(self, server) -> None: 70 | super().__init__( 71 | server=server, 72 | name=NAME_EVENT_LESSON_CHANGE, 73 | icon=ICON_EVENT_LESSNON_CHANGE, 74 | event_types=["lesson_change", "rooms", "teachers", "cancelled", "code"], 75 | ) 76 | 77 | 78 | class HomeworkEventEntity(BaseUntisEventEntity): 79 | """Event entity for homework changes.""" 80 | 81 | def __init__(self, server) -> None: 82 | super().__init__( 83 | server=server, 84 | name=NAME_EVENT_HOMEWORK, 85 | icon=ICON_EVENT_HOMEWORK, 86 | event_types=["homework"], 87 | ) 88 | -------------------------------------------------------------------------------- /docs/ENTITIES_AND_SERVICES.md: -------------------------------------------------------------------------------- 1 | # WebUntis Entities & Services 2 | 3 | This page lists all available entities and services for the WebUntis Home Assistant integration. 4 | Replace `` with your actual WebUntis integration device name. 5 | 6 | --- 7 | 8 | ## Entities 9 | 10 | | Entity ID (English) | Entity ID (German) | 11 | | -------------------------------------- | -------------------------------------------- | 12 | | `sensor._next_lesson` | `sensor._nachste_stunde` | 13 | | `sensor._next_lesson_to_wake_up` | `sensor._nachste_stunde_zum_aufstehen` | 14 | | `sensor._today_school_start` | `sensor._heutiger_schulbeginn` | 15 | | `sensor._today_school_end` | `sensor._heutiges_schulende` | 16 | | `calendar.` | `calendar.` | 17 | | `calendar._exams` | `calendar._prufungen` | 18 | | `calendar._homework` | `calendar._hausaufgaben` | 19 | | `event._lesson_change` | `event._stundenanderung` | 20 | | `event._new_homework` | `event._neue_hausaufgabe` | 21 | 22 | > ⚠️ **Important:** 23 | > The **Exam Calendar** and **Homework Calendar** are **not available when using a parent account**. 24 | > Please use a **student account** to access exams and homework. 25 | 26 | --- 27 | 28 | ## Services 29 | 30 | The integration provides several services to directly fetch data from WebUntis. 31 | 32 | --- 33 | 34 | ### 🔹 `webuntis.get_timetable` 35 | 36 | Fetches the timetable for a given date range. 37 | The result includes all lessons within the range, depending on your filter settings. 38 | 39 | **Fields:** 40 | 41 | - `device_id` (**required**) – The device/instance of the WebUntis integration. 42 | - `start` (**required**) – Start date (`YYYY-MM-DD`). 43 | - `end` (**required**) – End date (`YYYY-MM-DD`). 44 | - `apply_filter` (default: `true`) – Apply filters defined in the integration (e.g., subject or teacher filters). 45 | - `show_cancelled` (default: `true`) – Include cancelled lessons. 46 | - `compact_result` (default: `true`) – Return a compact result format. 47 | 48 | --- 49 | 50 | ### 🔹 `webuntis.count_lessons` 51 | 52 | Counts the number of lessons in a given date range. 53 | 54 | **Fields:** 55 | 56 | - `device_id` (**required**) – The device/instance of the WebUntis integration. 57 | - `start` (**required**) – Start date (`YYYY-MM-DD`). 58 | - `end` (**required**) – End date (`YYYY-MM-DD`). 59 | - `apply_filter` (default: `true`) – Apply filters defined in the integration. 60 | - `count_cancelled` (default: `false`) – Count cancelled lessons as well. 61 | 62 | --- 63 | 64 | ### 🔹 `webuntis.get_schoolyears` 65 | 66 | Fetches all available school years from WebUntis. 67 | 68 | **Fields:** 69 | 70 | - `device_id` (**required**) – The device/instance of the WebUntis integration. 71 | -------------------------------------------------------------------------------- /docs/SETUP.md: -------------------------------------------------------------------------------- 1 | # WebUntis Setup & Installation 2 | 3 | This page provides instructions to install and configure the WebUntis integration for Home Assistant. 4 | 5 | --- 6 | 7 | ## Installation 8 | 9 | ### HACS Installation 10 | 11 | 1. Install [HACS](https://github.com/custom-components/hacs) if you haven't already. 12 | 2. Open HACS and install the **WebUntis Integration**. 13 | 3. Restart Home Assistant. 14 | 4. Add the integration via the [Home Assistant UI](https://my.home-assistant.io/redirect/integrations/) or click [here](https://my.home-assistant.io/redirect/config_flow_start/?domain=webuntis). 15 | 16 | ### Manual Installation 17 | 18 | 1. Copy all files from `custom_components/webuntis/` to your Home Assistant config directory at `custom_components/webuntis/`. 19 | 2. Restart Home Assistant. 20 | 3. Add the integration via the [Home Assistant UI](https://my.home-assistant.io/redirect/integrations/) or click [here](https://my.home-assistant.io/redirect/config_flow_start/?domain=webuntis). 21 | 22 | ### Docker Users 23 | 24 | If Home Assistant is running in Docker, make sure to set your local timezone. 25 | 26 | **Option 1: Mount `/etc/localtime`** 27 | 28 | ```yaml 29 | volumes: 30 | - /etc/localtime:/etc/localtime:ro 31 | ``` 32 | 33 | **Option 2: Environment variable** 34 | TZ=Europe/Berlin 35 | 36 | ## Configuration via UI 37 | 38 | ### Server & School 39 | 40 | Visit https://webuntis.com and click on your school. 41 | 42 | In the URL you should find the information you need: 43 | 44 | ``` 45 | https://demo.webuntis.com/WebUntis/?school=Demo-School#/basic/login 46 | ^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^ 47 | Server School 48 | ``` 49 | 50 | If your school has the new address pattern, similar to this: 51 | 52 | ``` 53 | https://demo-school.webuntis.com/today 54 | ``` 55 | then the server is `https://demo-school.webuntis.com` and the school is `demo-school`. 56 | 57 | ### Username and Password 58 | 59 | Unfortunately, it is not possible to use the Untis API with an anonymous user. 60 | 61 | You can ask the school administration to give you access, otherwise it won't work. 62 | 63 | 64 | --- 65 | 66 | ### iServ or Office365 Login 67 | 68 | Currently, the only possible way to integrate login via iServ or Office365 would be through the **QR-Code login** ([#111](https://github.com/JonasJoKuJonas/homeassistant-WebUntis/issues/111)). 69 | However, this feature is not natively supported in the `python-webuntis` library. 70 | 71 | I have tested different approaches to make QR-Code login work, but to support it, the entire authentication process would need to be **reworked**. If anyone has experience with this and would like to collaborate, feel free to send me a DM on Discord so we can work on it together. 72 | 73 | Until then, **login via iServ or Office365 will unfortunately not be possible.** 74 | 75 | --- 76 | 77 | 78 | ### Timetable Source 79 | 80 | Select from witch source the intigration should pull the data. 81 | 82 | If the student or teacher is not found try 83 | 84 | first name: `first name` `middle name`
85 | last name: `last name` 86 | 87 | (This could vary from school to school) 88 | -------------------------------------------------------------------------------- /custom_components/webuntis/services.py: -------------------------------------------------------------------------------- 1 | """Services for WebUntis integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from datetime import datetime 7 | 8 | from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse 9 | from homeassistant.exceptions import HomeAssistantError 10 | from homeassistant.helpers.service import async_extract_config_entry_ids 11 | 12 | from .const import DOMAIN 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | async def async_setup_services(hass: HomeAssistant) -> None: 18 | """Set up services for WebUntis integration.""" 19 | 20 | if hass.services.has_service(DOMAIN, "get_timetable"): 21 | return 22 | 23 | async def async_call_webuntis_service(service_call: ServiceCall) -> None: 24 | """Call correct WebUntis service.""" 25 | 26 | entry_id = await async_extract_config_entry_ids(hass, service_call) 27 | config_entry = hass.config_entries.async_get_entry(list(entry_id)[0]) 28 | webuntis_object = hass.data[DOMAIN][config_entry.unique_id] 29 | 30 | data = service_call.data 31 | 32 | if "start" in data and "end" in data: 33 | start_date = datetime.strptime(data["start"], "%Y-%m-%d") 34 | end_date = datetime.strptime(data["end"], "%Y-%m-%d") 35 | 36 | if end_date < start_date: 37 | raise HomeAssistantError(f"Start date has to be bevor end date") 38 | 39 | await hass.async_add_executor_job(webuntis_object.webuntis_login) 40 | 41 | result = None 42 | 43 | if service_call.service == "get_timetable": 44 | lesson_list = await hass.async_add_executor_job( 45 | webuntis_object._get_events_in_timerange, 46 | start_date, 47 | end_date, 48 | data["apply_filter"], 49 | data["show_cancelled"], 50 | data["compact_result"], 51 | ) 52 | result = {"lessons": lesson_list} 53 | 54 | elif service_call.service == "count_lessons": 55 | result = await hass.async_add_executor_job( 56 | webuntis_object._count_lessons, 57 | start_date, 58 | end_date, 59 | data["apply_filter"], 60 | data["count_cancelled"], 61 | ) 62 | 63 | elif service_call.service == "get_schoolyears": 64 | result = await hass.async_add_executor_job(webuntis_object._get_schoolyears) 65 | 66 | await hass.async_add_executor_job(webuntis_object.webuntis_logout) 67 | 68 | return result 69 | 70 | hass.services.async_register( 71 | DOMAIN, 72 | "get_timetable", 73 | async_call_webuntis_service, 74 | supports_response=SupportsResponse.ONLY, 75 | ) 76 | hass.services.async_register( 77 | DOMAIN, 78 | "count_lessons", 79 | async_call_webuntis_service, 80 | supports_response=SupportsResponse.ONLY, 81 | ) 82 | hass.services.async_register( 83 | DOMAIN, 84 | "get_schoolyears", 85 | async_call_webuntis_service, 86 | supports_response=SupportsResponse.ONLY, 87 | ) 88 | -------------------------------------------------------------------------------- /docs/OPTIONAL_CONFIGURATIONS.md: -------------------------------------------------------------------------------- 1 | ## Optional Configurations 2 | 3 | ### Filter Options 4 | 5 | Control which lessons or subjects are included or excluded. 6 | 7 | | Option | Description | Example/Default | 8 | | :----------------- | :------------------------------------------------------- | :--------------- | 9 | | filter_mode | Filter mode, e.g., `Blacklist` or `Whitelist`. | `Blacklist` | 10 | | filter_subjects | Subjects to exclude from any data. | `["Math", "PE"]` | 11 | | filter_description | Exclude lessons containing specific text in description. | `"Online"` | 12 | | invalid_subjects | Allow lessons without subjects. | `False` | 13 | 14 | ### Calendar Options 15 | 16 | Customize what is shown in calendar entities. 17 | 18 | | Option | Description | Default | 19 | | :------------------------------ | :------------------------------------ | :--------------- | 20 | | calendar_show_cancelled_lessons | Show cancelled lessons in calendar. | `False` | 21 | | calendar_description | Display format for event description. | `JSON` | 22 | | calendar_room | Specify location display. | `Room long name` | 23 | | calendar_show_room_change | Show room changes in calendar. | `False` | 24 | | calendar_replace_name | Replace words in event name. | `None` | 25 | 26 | ### Lesson Options 27 | 28 | Control how the lesson name is displayed. 29 | 30 | | Option | Description | Default | 31 | | :------------------ | :--------------------------------------------- | :------ | 32 | | lesson_long_name | Show the full lesson name. | `True` | 33 | | lesson_replace_name | Replace lesson names based on mapping. | `None` | 34 | | lesson_add_teacher | Show the teacher's name for selected subjects. | `None` | 35 | 36 | ### Notification Options 37 | 38 | Configure how lesson change notifications are sent. 39 | 40 | | Option | Description | Default | 41 | | :--------------- | :------------------------------------------------------------ | :-------------- | 42 | | name | Name of the Notify device. | `entity_id` | 43 | | notify_entity_id | Home Assistant notification service, e.g., `notify.telegram`. | `None` | 44 | | target | Additional targets for the notification. | `None` | 45 | | data | Additional data for the notification service. | `None` | 46 | | template | Notification template to use. | `message_title` | 47 | | options | Options that trigger the notification. | `None` | 48 | 49 | ### Backend Options 50 | 51 | Control backend behavior and data generation. 52 | 53 | | Option | Description | Default | 54 | | :------------- | :---------------------------------------------------------- | :------ | 55 | | keep_logged_in | Keep the client logged in (Beta). | `False` | 56 | | generate_json | Generate JSON in sensor attributes for templates. | `False` | 57 | | exclude_data | Automatically exclude data if the user lacks access rights. | `None` | 58 | -------------------------------------------------------------------------------- /custom_components/webuntis/utils/utils.py: -------------------------------------------------------------------------------- 1 | """Miscellaneous support functions for webuntis""" 2 | 3 | import logging 4 | 5 | _LOGGER = logging.getLogger(__name__) 6 | 7 | 8 | def is_service(hass, entry): 9 | """check whether config entry is a service""" 10 | domain, service = entry.split(".")[0], ".".join(entry.split(".")[1:]) 11 | return hass.services.has_service(domain, service) 12 | 13 | 14 | def is_different(arr1, arr2): 15 | """ 16 | Compares two lists of dictionaries and returns True if they are different, False otherwise. 17 | 18 | Args: 19 | - arr1 (list): A list of dictionaries. 20 | - arr2 (list): A list of dictionaries. 21 | 22 | Returns: 23 | - bool: True if the two lists are different, False otherwise. 24 | """ 25 | if len(arr1) != len(arr2): 26 | return True 27 | for el1 in arr1: 28 | found = False 29 | for el2 in arr2: 30 | if el1 == el2: 31 | found = True 32 | break 33 | if not found: 34 | return True 35 | return False 36 | 37 | 38 | def compact_list(list, type=None): 39 | if type == "notify": 40 | compacted_list = [] 41 | i = 0 42 | while i < len(list): 43 | item = list[i] 44 | if compacted_list: 45 | last_item = compacted_list[-1] 46 | if ( 47 | last_item[2]["end"] == item[2]["start"] 48 | and last_item[2]["code"] == item[2]["code"] 49 | ): 50 | last_item[1]["end"] = item[1]["end"] 51 | last_item[2]["end"] = item[2]["end"] 52 | i += 1 53 | continue 54 | compacted_list.append(item) 55 | i += 1 56 | 57 | elif type == "dict": 58 | compacted_list = [] 59 | i = 0 60 | while i < len(list): 61 | item = list[i] 62 | if compacted_list: 63 | last_item = compacted_list[-1] 64 | if ( 65 | last_item["end"] == item["start"] 66 | and last_item["lsnumber"] == item["lsnumber"] 67 | and last_item["code"] == item["code"] 68 | ): 69 | last_item["end"] = item["end"] 70 | 71 | i += 1 72 | continue 73 | compacted_list.append(item) 74 | i += 1 75 | 76 | else: # calendar 77 | compacted_list = [] 78 | i = 0 79 | while i < len(list): 80 | item = list[i] 81 | if compacted_list: 82 | last_item = compacted_list[-1] 83 | if last_item.end == item.start and last_item.summary == item.summary: 84 | last_item.end = item.end 85 | 86 | i += 1 87 | continue 88 | compacted_list.append(item) 89 | i += 1 90 | 91 | return compacted_list 92 | 93 | 94 | async def async_notify(hass, service_id, data): 95 | """Show a notification""" 96 | 97 | if "target" in data and not data["target"]: 98 | del data["target"] 99 | 100 | _LOGGER.debug("Send notification(%s): %s", service_id, data) 101 | 102 | domain = service_id.split(".")[0] 103 | service = service_id.split(".")[1] 104 | 105 | try: 106 | await hass.services.async_call(domain, service, data, blocking=True) 107 | except Exception as error: 108 | _LOGGER.warning( 109 | "Sending notification to %s failed - %s", 110 | service_id, 111 | error, 112 | ) 113 | return False 114 | 115 | return True 116 | -------------------------------------------------------------------------------- /custom_components/webuntis/utils/web_untis_extended.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | from webuntis import errors 4 | from webuntis.utils import log # pylint: disable=no-name-in-module 5 | from webuntis.session import Session as WebUntisSession 6 | 7 | import logging 8 | 9 | # logging.basicConfig(level=logging.DEBUG) 10 | 11 | 12 | class ExtendedSession(WebUntisSession): 13 | """ 14 | This class extends the original Session to include new functionality for 15 | fetching homeworks from the WebUntis API using a different endpoint. 16 | """ 17 | 18 | def _send_custom_request(self, endpoint, params): 19 | """ 20 | A custom method for sending a request to a specific endpoint, different from the JSON-RPC method. 21 | 22 | :param endpoint: The API endpoint for the custom request (e.g., '/api/homeworks/lessons') 23 | :param params: The query parameters for the request 24 | :return: JSON response from the API 25 | """ 26 | 27 | base_url = self.config["server"].replace("/WebUntis/jsonrpc.do", "") 28 | 29 | # Construct the URL 30 | url = f"{base_url}{endpoint}" 31 | 32 | # Prepare headers 33 | headers = { 34 | "User-Agent": self.config["useragent"], 35 | "Content-Type": "application/json", 36 | } 37 | 38 | # Ensure session is logged in 39 | if "jsessionid" in self.config: 40 | headers["Cookie"] = f'JSESSIONID={self.config["jsessionid"]}' 41 | else: 42 | raise errors.NotLoggedInError("No JSESSIONID found. Please log in first.") 43 | 44 | # Log the request details 45 | log("debug", f"Making custom request to {url} with params: {params}") 46 | 47 | # Send the request using requests library 48 | response = requests.get(url, params=params, headers=headers) 49 | 50 | # Check if the response is valid JSON 51 | try: 52 | response_data = response.json() 53 | log("debug", f"Received valid JSON response: {str(response_data)[:100]}") 54 | except json.JSONDecodeError: 55 | raise errors.RemoteError("Invalid JSON response", response.text) 56 | 57 | return response_data 58 | 59 | def get_homeworks(self, start, end): 60 | """ 61 | Fetch homeworks for lessons within a specific date range using the 62 | '/api/homeworks/lessons' endpoint. 63 | 64 | :param start_date: Start date in the format YYYYMMDD (e.g., 20240901) 65 | :param end_date: End date in the format YYYYMMDD (e.g., 20240930) 66 | :return: JSON response containing homework data 67 | """ 68 | # Define the custom endpoint 69 | endpoint = "/WebUntis/api/homeworks/lessons" 70 | 71 | # Set query parameters 72 | params = { 73 | "startDate": start.strftime("%Y%m%d"), 74 | "endDate": end.strftime("%Y%m%d"), 75 | } 76 | 77 | # Send the request and return the response 78 | return self._send_custom_request(endpoint, params) 79 | 80 | def get_exams(self, start, end): 81 | """ 82 | Fetch exams within a specific date range using the 83 | '/api/homeworks/exams' endpoint. 84 | 85 | :param start_date: Start date in the format YYYYMMDD (e.g., 20240901) 86 | :param end_date: End date in the format YYYYMMDD (e.g., 20240930) 87 | :return: JSON response containing exams data 88 | """ 89 | # Define the custom endpoint 90 | endpoint = "/WebUntis/api/exams" 91 | 92 | # Set query parameters 93 | params = { 94 | "startDate": start.strftime("%Y%m%d"), 95 | "endDate": end.strftime("%Y%m%d"), 96 | } 97 | 98 | # Send the request and return the response 99 | return self._send_custom_request(endpoint, params) 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebUntis 2 | 3 | ### Custom component to access Web Untis data in Home Assistant 4 | 5 | [![HACS Badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs) 6 | ![Version](https://img.shields.io/github/v/release/JonasJoKuJonas/homeassistant-WebUntis) 7 | [![Downloads](https://img.shields.io/github/downloads/JonasJoKuJonas/homeassistant-WebUntis/total)](https://tooomm.github.io/github-release-stats/?username=JonasJoKuJonas&repository=HomeAssistant-WebUntis) 8 | [![Latest Release](https://img.shields.io/github/release-date/JonasJoKuJonas/homeassistant-WebUntis?style=flat&label=Latest%20Release)](https://github.com/JonasJoKuJonas/homeassistant-WebUntis/releases) 9 | [![Open Issues](https://img.shields.io/github/issues/JonasJoKuJonas/homeassistant-WebUntis?style=flat&label=Open%20Issues)](https://github.com/JonasJoKuJonas/homeassistant-WebUntis/issues) 10 | [![Discord](https://img.shields.io/discord/1090218586565509170?style=flat&logo=discord&logoColor=white&label=Discord&color=7289da)](https://discord.gg/34EHnHQaPm) 11 | 12 | --- 13 | 14 | ## 🌟 Features 15 | 16 | | Feature | Description | Link | 17 | |---------|-------------|------| 18 | | 📅 **30-Day Calendar** | Displays all lessons in the calendar or calendar-card for the upcoming month. | [Entities & Services](docs/ENTITIES_AND_SERVICES.md) | 19 | | ⏰ **Lesson Sensors** | Includes school start/end times and next lesson, useful for wake-up automations. | [Examples & Automations](docs/EXAMPLES_AND_AUTOMATIONS.md) | 20 | | 🔔 **Lesson Change Notifications** | Get notified for cancellations, room changes, teacher changes, and lesson swaps. | [Notification Options](docs/OPTIONAL_CONFIGURATIONS.md#notification-options) | 21 | | 📝 **Fetch Lessons Service** | Request lessons for a specific date range. | [`webuntis.get_timetable`](docs/ENTITIES_AND_SERVICES.md#-webuntisget_timetable) | 22 | | 📊 **Count Lessons Service** | Count lessons by subject within a given date range. | [`webuntis.count_lessons`](docs/ENTITIES_AND_SERVICES.md#-webuntiscount_lessons) | 23 | 24 | 25 | --- 26 | 27 | ## 🚀 Setup 28 | [![Open in HACS](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=JonasJoKuJonas&repository=Homeassistant-WebUntis) 29 | 30 | You can install WebUntis via HACS or manually. For detailed instructions, see the dedicated setup guide: 31 | 32 | [**Setup & Installation Guide**](docs/SETUP.md) 33 | 34 | 35 | --- 36 | 37 | ## 📖 Documentation 38 | 39 | - **Entities & Services** – Full list of entities, their German/English names, and available services: 40 | [Entities & Services](docs/ENTITIES_AND_SERVICES.md) 41 | - **Optional Configurations** – All configuration options for filters, calendars, lessons, notifications, and backend: 42 | [Optional Configurations](docs/OPTIONAL_CONFIGURATIONS.md) 43 | - **Examples & Automations** – Ready-to-use automations and template snippets for common use cases: 44 | [Examples & Automations](docs/EXAMPLES_AND_AUTOMATIONS.md) 45 | 46 | --- 47 | 48 | ## 🌐 Help Translate 49 | 50 | We use Crowdin to simplify translations. If you’re fluent in another language and want to contribute, you can help translate the project. Contributions in any language are welcome! 51 | 52 | [![Help Translate on Crowdin](https://badges.crowdin.net/badge/light/crowdin-on-dark.png)](https://crowdin.com/project/homeassistant-webuntis) 53 | 54 | --- 55 | 56 | ## [![Join our Discord](https://discordapp.com/api/guilds/1090218586565509170/widget.png?style=banner2)](https://discord.gg/34EHnHQaPm) 57 | 58 | 59 | ## 💖 Support Me 60 | 61 | I’m a 19-year-old software developer from Germany, creating projects like this in my free time. If you like my work, consider supporting me: 62 | 63 | Buy Me A Coffee 64 | 65 | 66 | Donate with PayPal 67 | 68 | -------------------------------------------------------------------------------- /custom_components/webuntis/utils/exams.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from webuntis import errors 3 | from homeassistant.components.calendar import CalendarEvent 4 | from webuntis.utils.datetime_utils import parse_datetime 5 | 6 | 7 | # pylint: disable=relative-beyond-top-level 8 | from ..utils.web_untis import get_lesson_name_str 9 | 10 | 11 | class ExamEventsFetcher: 12 | def __init__(self, server, timezone_str="UTC"): 13 | self.server = server 14 | self.session = server.session 15 | self.event_list = [] 16 | self.current_schoolyear = server.current_schoolyear 17 | 18 | def _get_exam_events(self): 19 | """ 20 | Fetch exam events from the WebUntis API using the session object and return them as a list of event dictionaries. 21 | """ 22 | 23 | # Fetch exam data using the session object 24 | try: 25 | exam_data = self.session.get_exams( 26 | start=self.current_schoolyear.start.date(), 27 | end=self.current_schoolyear.end.date(), 28 | ) 29 | except errors.NotLoggedInError: 30 | raise Exception("You are not logged in. Please log in and try again.") 31 | except errors.RemoteError as e: 32 | raise Exception(f"Error fetching exam data: {e}") 33 | 34 | # Process the exam data and extract exam events 35 | exam_events = self._process_exam_data(exam_data) 36 | return exam_events 37 | 38 | def _process_exam_data(self, response_data): 39 | """ 40 | Process the exam response data and return a list of event dictionaries. 41 | """ 42 | exams = response_data.get("data", {}).get("exams", []) 43 | 44 | event_list = [] 45 | 46 | # Process each exam entry and create a CalendarEvent object 47 | for exam in exams: 48 | exam_id = exam.get("id", None) 49 | exam_type = exam.get("examType", "Unknown Type") 50 | name = exam.get("name", "No Name") 51 | subject = exam.get("subject", "Unknown Subject") 52 | text = exam.get("text", "") 53 | grade = exam.get("grade", "") 54 | 55 | assigned_students = exam.get("assignedStudents", []) 56 | if assigned_students: # Checks if the list is not empty 57 | student_id = assigned_students[0].get("id", None) 58 | else: 59 | student_id = None 60 | 61 | # Parse dates and times for the exam 62 | exam_date = exam.get("examDate") 63 | start_time = exam.get("startTime", 0) 64 | end_time = exam.get("endTime", 0) 65 | 66 | # Combine date and time for start and end datetime objects, ensuring they are timezone-aware 67 | start_datetime = parse_datetime( 68 | date=exam_date, time=start_time 69 | ).astimezone() 70 | 71 | end_datetime = parse_datetime(date=exam_date, time=end_time).astimezone() 72 | 73 | # Get teacher and room details 74 | teachers = ", ".join(exam.get("teachers", [])) or "Unknown Teacher" 75 | rooms = ", ".join(exam.get("rooms", [])) or "Unknown Room" 76 | 77 | summary = get_lesson_name_str(self.server, subject, teachers) 78 | 79 | description = f"""{exam_type} Name: {name}""" 80 | 81 | if text: 82 | description += f" Text: \n{text}" 83 | 84 | # Create a structured CalendarEvent object with timezone-aware datetimes 85 | event = { 86 | "uid": str(uuid.uuid4()), 87 | "summary": summary, 88 | "start": start_datetime, 89 | "end": end_datetime, 90 | "description": description, 91 | "location": rooms, 92 | } 93 | 94 | if self.server.student_id is None or self.server.student_id == student_id: 95 | event_list.append(CalendarEvent(**event)) 96 | 97 | return event_list 98 | 99 | 100 | # Example usage: 101 | def return_exam_events(server, timezone_str="UTC"): 102 | """ 103 | Function to initialize the ExamEventsFetcher class and return the exam events. 104 | """ 105 | fetcher = ExamEventsFetcher(server, timezone_str=timezone_str) 106 | return fetcher._get_exam_events() 107 | -------------------------------------------------------------------------------- /custom_components/webuntis/utils/homework.py: -------------------------------------------------------------------------------- 1 | from datetime import date, timedelta, datetime 2 | from webuntis import errors 3 | import pytz # to handle timezone conversions 4 | 5 | from homeassistant.components.calendar import CalendarEvent 6 | 7 | from custom_components.webuntis.const import DAYS_TO_CHECK 8 | 9 | # pylint: disable=relative-beyond-top-level 10 | from ..utils.web_untis import get_lesson_name_str 11 | 12 | 13 | class HomeworkEventsFetcher: 14 | def __init__( 15 | self, 16 | server, 17 | calendar_description="Lesson Info", 18 | calendar_room="Room long name", 19 | timezone="Europe/Berlin", # Default timezone, can be changed if needed 20 | ): 21 | self.session = server.session 22 | self.server = server 23 | self.calendar_description = calendar_description 24 | self.calendar_room = calendar_room 25 | self.event_list = [] 26 | self.timezone = pytz.timezone(timezone) 27 | 28 | def _get_homework_events(self): 29 | """ 30 | Fetch homework events from the WebUntis API and return them as a list of calendar events. 31 | """ 32 | today = date.today() 33 | start = today - timedelta(days=DAYS_TO_CHECK) 34 | end = today + timedelta(days=DAYS_TO_CHECK) 35 | 36 | # Fetch homework data using the session object 37 | try: 38 | homework_data = self.session.get_homeworks( 39 | start=start, 40 | end=end, 41 | ) 42 | except errors.NotLoggedInError: 43 | raise Exception("You are not logged in. Please log in and try again.") 44 | 45 | # Process the homework data and extract the homework events 46 | homework_events = self._process_homework_data(homework_data) 47 | return homework_events 48 | 49 | def _process_homework_data(self, response_data): 50 | """ 51 | Process the homework response data and return a list of event dictionaries. 52 | """ 53 | homeworks = response_data.get("data", {}).get("homeworks", []) 54 | lessons = response_data.get("data", {}).get("lessons", []) 55 | records = response_data.get("data", {}).get("records", []) 56 | teachers = response_data.get("data", {}).get("teachers", []) 57 | 58 | # Create a mapping of teacher ID to teacher data 59 | teacher_map = {teacher["id"]: teacher for teacher in teachers} 60 | 61 | event_list = [] 62 | param_list = [] 63 | 64 | # Process each homework entry 65 | for homework in homeworks: 66 | hw_id = homework.get("id") 67 | lesson_id = homework.get("lessonId") 68 | date_assigned_int = homework.get("date") # Date in integer YYYYMMDD format 69 | due_date_int = homework.get( 70 | "dueDate" 71 | ) # Due date in integer YYYYMMDD format 72 | text = homework.get("text") 73 | completed = homework.get("completed", False) 74 | 75 | date_assigned = datetime.strptime(str(date_assigned_int), "%Y%m%d").date() 76 | due_date = datetime.strptime( 77 | str(due_date_int), "%Y%m%d" 78 | ).date() + timedelta(days=1) 79 | 80 | # Find the corresponding record to get the teacher ID 81 | record = next((rec for rec in records if rec["homeworkId"] == hw_id), None) 82 | 83 | # Fetch the teacher ID from the record 84 | teacher_id = record.get("teacherId") if record else None 85 | 86 | student_id = record.get("elementIds", [])[0] 87 | 88 | # Get the teacher's name using the teacher ID 89 | teacher = teacher_map.get(teacher_id, {}) 90 | teacher_name = teacher.get("name", "Unknown Teacher") 91 | 92 | # Find the corresponding lesson 93 | lesson = next((l for l in lessons if l["id"] == lesson_id), {}) 94 | subject = lesson.get("subject", "Unknown Subject") 95 | 96 | summary = get_lesson_name_str(self.server, subject, teachers[0]["name"]) 97 | 98 | # Create a calendar event for each homework entry 99 | event = { 100 | "uid": hw_id, 101 | "summary": summary, 102 | "start": date_assigned, 103 | "end": due_date, 104 | "description": text, 105 | } 106 | 107 | parameters = { 108 | "homework_id": hw_id, 109 | "subject": subject, 110 | "teacher": teacher_name, 111 | "student_id": student_id, 112 | "completed": completed, 113 | "date_assigned": date_assigned, 114 | "due_date": due_date, 115 | "text": text, 116 | } 117 | 118 | if self.server.student_id is None or self.server.student_id == student_id: 119 | if ( 120 | subject in self.server.filter_subjects 121 | and self.server.filter_mode == "Blacklist" 122 | ): 123 | continue 124 | # Add the homework event to the event list 125 | event_list.append(CalendarEvent(**event)) 126 | 127 | param_list.append(parameters) 128 | 129 | return event_list, param_list 130 | 131 | 132 | # Example usage: 133 | def return_homework_events(server): 134 | fetcher = HomeworkEventsFetcher(server) 135 | return fetcher._get_homework_events() 136 | -------------------------------------------------------------------------------- /custom_components/webuntis/sensor.py: -------------------------------------------------------------------------------- 1 | """The Web Untis sensor platform.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Optional 6 | 7 | from homeassistant.components.sensor import SensorEntity 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 11 | 12 | from . import WebUntis, WebUntisEntity 13 | from .const import ( 14 | DOMAIN, 15 | ICON_SENSOR_NEXT_CLASS, 16 | ICON_SENSOR_NEXT_LESSON_TO_WAKE_UP, 17 | ICON_SENSOR_TODAY_END, 18 | ICON_SENSOR_TODAY_START, 19 | NAME_SENSOR_NEXT_CLASS, 20 | NAME_SENSOR_NEXT_LESSON_TO_WAKE_UP, 21 | NAME_SENSOR_TODAY_END, 22 | NAME_SENSOR_TODAY_START, 23 | ) 24 | 25 | 26 | async def async_setup_entry( 27 | hass: HomeAssistant, 28 | config_entry: ConfigEntry, 29 | async_add_entities: AddEntitiesCallback, 30 | ) -> None: 31 | """Set up the Web Untis sensor platform.""" 32 | server = hass.data[DOMAIN][config_entry.unique_id] 33 | 34 | # Create entities list. 35 | entities = [ 36 | WebUntisNextClassSensor(server), 37 | WebUntisNextLessonToWakeUpSensor(server), 38 | WebUntisToayStart(server), 39 | WebUntisToayEnd(server), 40 | ] 41 | 42 | # Add sensor entities. 43 | async_add_entities(entities, True) 44 | 45 | 46 | class WebUntisSensorEntity(WebUntisEntity, SensorEntity): 47 | """Representation of a Web Untis sensor base entity.""" 48 | 49 | unit: Optional[str] = None 50 | device_class: Optional[str] = None 51 | 52 | def __init__( 53 | self, 54 | server: WebUntis, 55 | name: str, 56 | icon: str, 57 | device_class: Optional[str] = None, 58 | ) -> None: 59 | """Initialize sensor base entity.""" 60 | super().__init__(server, name, icon, device_class) 61 | self._attr_native_unit_of_measurement = self.unit 62 | 63 | @property 64 | def available(self) -> bool: 65 | """Return sensor availability.""" 66 | return True 67 | 68 | 69 | class WebUntisNextClassSensor(WebUntisSensorEntity): 70 | """Representation of a Web Untis next class sensor.""" 71 | 72 | unit: Optional[str] = None 73 | device_class: Optional[str] = "timestamp" 74 | 75 | def __init__(self, server: WebUntis) -> None: 76 | """Initialize next class sensor.""" 77 | super().__init__( 78 | server=server, 79 | name=NAME_SENSOR_NEXT_CLASS, 80 | icon=ICON_SENSOR_NEXT_CLASS, 81 | device_class=self.device_class, 82 | ) 83 | 84 | @property 85 | def available(self) -> bool: 86 | """Return sensor availability.""" 87 | return bool(self._server.next_class) 88 | 89 | async def async_update(self) -> None: 90 | """Update next class.""" 91 | self._attr_native_value = self._server.next_class 92 | self._attr_extra_state_attributes = {"lesson": self._server.next_class_json} 93 | 94 | 95 | class WebUntisNextLessonToWakeUpSensor(WebUntisSensorEntity): 96 | """Representation of a Web Untis next lesson to wake up sensor.""" 97 | 98 | unit: Optional[str] = None 99 | device_class: Optional[str] = "timestamp" 100 | 101 | def __init__(self, server: WebUntis) -> None: 102 | """Initialize next lesson to wake up sensor.""" 103 | super().__init__( 104 | server=server, 105 | name=NAME_SENSOR_NEXT_LESSON_TO_WAKE_UP, 106 | icon=ICON_SENSOR_NEXT_LESSON_TO_WAKE_UP, 107 | device_class=self.device_class, 108 | ) 109 | self._attr_extra_state_attributes = {} 110 | 111 | async def async_update(self) -> None: 112 | """Update next lesson to wake up.""" 113 | self._attr_native_value = self._server.next_lesson_to_wake_up 114 | self._attr_extra_state_attributes = {"day": self._server.next_day_json} 115 | 116 | 117 | class WebUntisToayStart(WebUntisSensorEntity): 118 | """Representation of a Web Untis Today start sensor.""" 119 | 120 | unit: Optional[str] = None 121 | device_class: Optional[str] = "timestamp" 122 | 123 | def __init__(self, server: WebUntis) -> None: 124 | """Initialize sensor.""" 125 | super().__init__( 126 | server=server, 127 | name=NAME_SENSOR_TODAY_START, 128 | icon=ICON_SENSOR_TODAY_START, 129 | device_class=self.device_class, 130 | ) 131 | self._attr_extra_state_attributes = {} 132 | 133 | async def async_update(self) -> None: 134 | """Update sensor data.""" 135 | self._attr_native_value = self._server.today[0] 136 | self._attr_extra_state_attributes = {"day": self._server.day_json} 137 | 138 | 139 | class WebUntisToayEnd(WebUntisSensorEntity): 140 | """Representation of a Web Untis Today end sensor.""" 141 | 142 | unit: Optional[str] = None 143 | device_class: Optional[str] = "timestamp" 144 | 145 | def __init__(self, server: WebUntis) -> None: 146 | """Initialize sensor.""" 147 | super().__init__( 148 | server=server, 149 | name=NAME_SENSOR_TODAY_END, 150 | icon=ICON_SENSOR_TODAY_END, 151 | device_class=self.device_class, 152 | ) 153 | self._attr_extra_state_attributes = {} 154 | 155 | async def async_update(self) -> None: 156 | """Update sensor data.""" 157 | self._attr_native_value = self._server.today[-1] 158 | -------------------------------------------------------------------------------- /custom_components/webuntis/calendar.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | 5 | from homeassistant.components.calendar import CalendarEntity, CalendarEvent 6 | from homeassistant.config_entries import ConfigEntry 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 9 | 10 | from . import WebUntis, WebUntisEntity # pylint: disable=no-name-in-module 11 | from .const import ( 12 | DOMAIN, 13 | ICON_CALENDAR, 14 | ICON_CALENDAR_HOMEWORK, 15 | NAME_CALENDAR, 16 | NAME_CALENDAR_HOMEWORK, 17 | ICON_CALENDAR_EXAM, 18 | NAME_CALENDAR_EXAM, 19 | ) 20 | 21 | 22 | async def async_setup_entry( 23 | hass: HomeAssistant, 24 | config_entry: ConfigEntry, 25 | async_add_entities: AddEntitiesCallback, 26 | ) -> None: 27 | """Set up the Web Untis calendar platform.""" 28 | server = hass.data[DOMAIN][config_entry.unique_id] 29 | 30 | entities = [UntisCalendar(server)] 31 | 32 | if server.timetable_source != "teacher": 33 | entities.append(HomeworkCalendar(server)) 34 | entities.append(ExamCalendar(server)) 35 | 36 | # Add calendar entities. 37 | async_add_entities(entities, True) 38 | 39 | 40 | class BaseUntisCalendar(WebUntisEntity, CalendarEntity): 41 | """Base class for WebUntis calendar entities.""" 42 | 43 | def __init__(self, server: WebUntis, name: str, icon: str) -> None: 44 | """Initialize base calendar entity.""" 45 | super().__init__( 46 | server=server, 47 | name=name, 48 | icon=icon, 49 | device_class=None, 50 | ) 51 | self.events = self._get_events 52 | self._event = None 53 | 54 | def _get_events(self): 55 | return [] 56 | 57 | @property 58 | def event(self) -> CalendarEvent: 59 | """Return the next upcoming event.""" 60 | return self._event 61 | 62 | async def async_get_events( 63 | self, 64 | hass: HomeAssistant, 65 | start_date: datetime.datetime, 66 | end_date: datetime.datetime, 67 | ) -> list[CalendarEvent]: 68 | """Return calendar events within a datetime range.""" 69 | events_in_range = [] 70 | # Use the timezone of the start_date (or Home Assistant timezone) 71 | timezone = start_date.tzinfo or datetime.timezone.utc 72 | 73 | for event in self.events: 74 | # Convert event.start and event.end to datetime if they are date objects 75 | if isinstance(event.start, datetime.date) and not isinstance( 76 | event.start, datetime.datetime 77 | ): 78 | event_start = datetime.datetime.combine( 79 | event.start, datetime.time.min 80 | ).replace(tzinfo=timezone) 81 | else: 82 | event_start = event.start 83 | 84 | if isinstance(event.end, datetime.date) and not isinstance( 85 | event.end, datetime.datetime 86 | ): 87 | event_end = datetime.datetime.combine( 88 | event.end, datetime.time.min 89 | ).replace(tzinfo=timezone) 90 | else: 91 | event_end = event.end 92 | 93 | # Ensure event_start and event_end are timezone-aware 94 | if event_start.tzinfo is None: 95 | event_start = event_start.replace(tzinfo=timezone) 96 | if event_end.tzinfo is None: 97 | event_end = event_end.replace(tzinfo=timezone) 98 | 99 | # Now compare the event start and end with the given range 100 | if event_start >= start_date and event_end <= end_date: 101 | events_in_range.append(event) 102 | 103 | return events_in_range 104 | 105 | async def async_update(self) -> None: 106 | """Update status.""" 107 | self.events = self._get_events() 108 | 109 | if self.events: 110 | self.events.sort(key=lambda e: (e.end)) 111 | now = datetime.datetime.now() 112 | 113 | for event in self.events: 114 | if event.end_datetime_local.astimezone() > now.astimezone(): 115 | self._event = event 116 | break 117 | else: 118 | self._event = None 119 | 120 | 121 | class UntisCalendar(BaseUntisCalendar): 122 | """Representation of a Web Untis Calendar sensor.""" 123 | 124 | _attr_name = None 125 | 126 | def __init__(self, server: WebUntis) -> None: 127 | """Initialize the Untis Calendar.""" 128 | super().__init__(server=server, name=NAME_CALENDAR, icon=ICON_CALENDAR) 129 | 130 | def _get_events(self): 131 | return self._server.calendar_events 132 | 133 | 134 | class HomeworkCalendar(BaseUntisCalendar): 135 | """Representation of a Web Untis Homework Calendar sensor.""" 136 | 137 | def __init__(self, server: WebUntis) -> None: 138 | """Initialize the Homework Calendar.""" 139 | super().__init__( 140 | server=server, name=NAME_CALENDAR_HOMEWORK, icon=ICON_CALENDAR_HOMEWORK 141 | ) 142 | 143 | def _get_events(self): 144 | 145 | return self._server.calendar_homework 146 | 147 | 148 | class ExamCalendar(BaseUntisCalendar): 149 | """Representation of a Web Untis Exams Calendar sensor.""" 150 | 151 | def __init__(self, server: WebUntis) -> None: 152 | """Initialize the Exams Calendar.""" 153 | super().__init__( 154 | server=server, name=NAME_CALENDAR_EXAM, icon=ICON_CALENDAR_EXAM 155 | ) 156 | 157 | def _get_events(self): 158 | 159 | return self._server.calendar_exams 160 | -------------------------------------------------------------------------------- /docs/EXAMPLES_AND_AUTOMATIONS.md: -------------------------------------------------------------------------------- 1 | # WebUntis Examples & Automations 2 | 3 | This page contains **ready-to-use automation examples** and template snippets for the WebUntis Home Assistant integration. 4 | Replace `` with your actual WebUntis integration device name. 5 | 6 | --- 7 | 8 | ## Table of Contents 9 | 10 | 1. [Wake-Up Alarm (Dynamic Time)](#wake-up-alarm-dynamic-time) 11 | 2. [Wake-Up Alarm (Fixed Time)](#wake-up-alarm-fixed-time) 12 | 3. [List Lessons from Next Day](#list-lessons-from-next-day) 13 | 4. [Lesson Change Event Trigger](#lesson-change-event-trigger) 14 | 5. [New Homework Event Trigger](#new-homework-event-trigger) 15 | 6. [Add Homework to ToDo](#add-homework-to-todo) 16 | 17 | --- 18 | 19 | ## Wake-Up Alarm (Dynamic Time) 20 | 21 | ```yaml 22 | sensor: 23 | - platform: template 24 | sensors: 25 | webuntis_wake_time: 26 | friendly_name: "WebUntis Wake-Up Time" 27 | value_template: > 28 | {% set datetime = states('sensor._next_lesson_to_wake_up') %} 29 | {% if datetime not in ["unknown", "unavailable", None] %} 30 | {{ as_datetime(datetime) - timedelta(hours=1, minutes=10) }} 31 | {% else %} 32 | {{ None }} 33 | {% endif %} 34 | ``` 35 | 36 | Trigger automation at wake-up time: 37 | 38 | ```yaml 39 | trigger: 40 | platform: time 41 | entity_id: sensor.webuntis_wake_time 42 | action: 43 | # Add your wake-up actions here, e.g., switch on lights, play alarm 44 | ``` 45 | 46 | --- 47 | 48 | ## Wake-Up Alarm (Fixed Time) 49 | 50 | ```yaml 51 | {% set datetime = states('sensor._next_lesson_to_wake_up') %} 52 | {% if datetime not in ["unknown", "unavailable", None] %} 53 | {% set datetime = datetime | as_datetime | as_local %} 54 | {% set time = datetime.hour|string + ":" + datetime.minute|string %} 55 | {% if time == "8:0" %} 56 | {% set wake_up_time = "6:25" %} 57 | {% elif time == "9:14" %} 58 | {% set wake_up_time = "7:30" %} 59 | {% elif time == "10:45" %} 60 | {% set wake_up_time = "8:45" %} 61 | {% endif %} 62 | {{ datetime.replace(hour=wake_up_time.split(":")[0]|int, minute=wake_up_time.split(":")[1]|int) }} 63 | {% else %} 64 | {{ None }} 65 | {% endif %} 66 | ``` 67 | 68 | --- 69 | 70 | ## List Lessons from Next Day 71 | 72 | **Simple list:** 73 | 74 | ```yaml 75 | {% set json = state_attr("sensor._next_lesson_to_wake_up", "day") | from_json %} 76 | {% for lesson in json -%} 77 | {{ lesson.subjects.0.long_name + "\n" }} 78 | {%- endfor %} 79 | ``` 80 | 81 | **Unique subjects list:** 82 | 83 | ```yaml 84 | {% set lessonList = namespace(lesson=[]) %} 85 | {% set lessons = state_attr("sensor._next_lesson_to_wake_up", "day") | from_json %} 86 | {% for lesson in lessons -%} 87 | {% set lessonList.lesson = lessonList.lesson + [lesson.subjects.0.long_name] %} 88 | {%- endfor %} 89 | {{ lessonList.lesson | unique | join(', ') }} 90 | ``` 91 | 92 | --- 93 | 94 | ## Lesson Change Event Trigger 95 | 96 | ```yaml 97 | trigger: 98 | platform: state 99 | entity_id: event._lesson_change 100 | ``` 101 | 102 | Access event type: 103 | 104 | ```yaml 105 | to_state: 106 | entity_id: event._lesson_change 107 | attributes: 108 | event_type: code 109 | ``` 110 | 111 | There can be different event_type's 112 | 113 | - code 114 | - rooms 115 | - teachers 116 | - cancelled 117 | - lesson_change 118 | 119 | Available lesson chage Attributes 120 | 121 | ```yaml 122 | to_state: 123 | entity_id: event.NAME_webuntis_lesson_change 124 | attributes: 125 | event_type: code 126 | old_lesson: 127 | start: '2025-02-12T07:45:00+01:00' 128 | end: '2025-02-12T09:15:00+01:00' 129 | subject_id: xxx 130 | id: xxx 131 | lsnumber: xxx 132 | code: irregular 133 | type: ls 134 | subjects: 135 | - name: M 136 | long_name: Mathe 137 | rooms: 138 | - name: R1 139 | long_name: Raum 1 140 | original_rooms: [] 141 | teachers: 142 | - name: x 143 | long_name: xxx 144 | new_lesson: 145 | start: '2025-02-12T07:45:00+01:00' 146 | end: '2025-02-12T09:15:00+01:00' 147 | subject_id: None 148 | id: xxx 149 | lsnumber: xxx 150 | code: cancelled 151 | type: ls 152 | subjects: [] 153 | rooms: 154 | - name: R1 155 | long_name: Room 1 156 | original_rooms: [] 157 | teachers: 158 | - name: x 159 | long_name: xxx 160 | ``` 161 | 162 | --- 163 | 164 | ## New Homework Event Trigger 165 | 166 | ```yaml 167 | trigger: 168 | platform: state 169 | entity_id: event._new_homework 170 | ``` 171 | 172 | Available homework attributes: 173 | 174 | ```yaml 175 | to_state: 176 | entity_id: event._new_homework 177 | attributes: 178 | event_type: homework 179 | homework_data: 180 | homework_id: ??? # Replace with homework ID 181 | subject: IT 182 | teacher: Jonas 183 | student_id: 42 184 | completed: false 185 | date_assigned: "2025-02-18" 186 | due_date: "2025-02-25" 187 | text: Fix all bugs in the WebUntis integration! 188 | ``` 189 | 190 | --- 191 | 192 | ## Add Homework to ToDo 193 | 194 | Automatically add new homework to a **ToDo integration**: 195 | 196 | ```yaml 197 | alias: Add Homework to ToDo 198 | description: "" 199 | trigger: 200 | - platform: state 201 | entity_id: 202 | - event._webuntis_homework 203 | condition: [] 204 | action: 205 | - service: todo.add_item 206 | target: 207 | entity_id: todo.hausaufgaben 208 | data: 209 | item: "{{ trigger.to_state.attributes.homework_data.subject }}" 210 | due_date: "{{ trigger.to_state.attributes.homework_data.due_date }}" 211 | description: "{{ trigger.to_state.attributes.homework_data.text }}" 212 | mode: single 213 | ``` 214 | 215 | --- 216 | 217 | ### Notes 218 | 219 | - Replace all `` placeholders with your actual WebUntis integration device name. 220 | - Enable **Backend → generate JSON** in options to access attributes in templates. 221 | - Combine these triggers with notifications, smart home actions, or scripts as needed. 222 | -------------------------------------------------------------------------------- /custom_components/webuntis/notify.py: -------------------------------------------------------------------------------- 1 | from .const import TEMPLATE_OPTIONS 2 | 3 | from .utils.web_untis import get_lesson_name_str, get_lesson_name 4 | 5 | 6 | def compare_list(old_list, new_list, blacklist=[]): 7 | updated_items = [] 8 | 9 | for new_item in new_list: 10 | for old_item in old_list: 11 | if ( 12 | new_item["subject_id"] == old_item["subject_id"] 13 | and new_item["start"] == old_item["start"] 14 | and not ( 15 | new_item["code"] == "irregular" and old_item["code"] == "cancelled" 16 | ) 17 | ): 18 | # if lesson is on blacklist to prevent spaming notifications 19 | if any( 20 | item["subject_id"] == new_item["subject_id"] 21 | and item["start"] == new_item["start"] 22 | for item in blacklist 23 | ): 24 | break 25 | 26 | if new_item["code"] != old_item["code"]: 27 | if old_item["code"] == "None" and new_item["code"] == "cancelled": 28 | matching_item = next( 29 | ( 30 | item 31 | for item in new_list 32 | if item["start"] == new_item["start"] 33 | and item["subject_id"] != new_item["subject_id"] 34 | and item["code"] == "irregular" 35 | ), 36 | None, 37 | ) 38 | 39 | if matching_item is not None: 40 | updated_items.append( 41 | ["lesson_change", matching_item, old_item] 42 | ) 43 | else: 44 | updated_items.append(["cancelled", new_item, old_item]) 45 | else: 46 | updated_items.append(["code", new_item, old_item]) 47 | 48 | if ( 49 | "rooms" in new_item 50 | and "rooms" in old_item 51 | and new_item["rooms"] 52 | and old_item["rooms"] 53 | and new_item["rooms"] != old_item["rooms"] 54 | and new_item["code"] != "cancelled" 55 | ): 56 | updated_items.append(["rooms", new_item, old_item]) 57 | 58 | if ( 59 | "teachers" in new_item 60 | and "teachers" in old_item 61 | and new_item["teachers"] 62 | and old_item["teachers"] 63 | and new_item["teachers"] != old_item["teachers"] 64 | and new_item["code"] != "cancelled" 65 | ): 66 | updated_items.append(["teachers", new_item, old_item]) 67 | 68 | break 69 | 70 | return updated_items 71 | 72 | 73 | def get_notify_blacklist(current_list): 74 | blacklist = [] 75 | 76 | for item in compare_list(current_list, current_list): 77 | blacklist.append( 78 | {"subject_id": item[1]["subject_id"], "start": item[1]["start"]} 79 | ) 80 | 81 | return blacklist 82 | 83 | 84 | def get_notification_data(changes, service, entry_title): 85 | 86 | message = "" 87 | title = "" 88 | data = {} 89 | result = {} 90 | 91 | template = service.get("template", TEMPLATE_OPTIONS[0]) 92 | 93 | if template == "message_title" or template == "message": 94 | title = f"WebUntis ({entry_title}) - {changes['title']}" 95 | result["title"] = title 96 | message = f"""Subject: {changes["subject"]} 97 | Date: {changes["date"]} 98 | Time: {changes["time_start"]} - {changes["time_end"]}""" 99 | 100 | if changes["change"] not in ["cancelled", "test"]: 101 | message += f""" 102 | Change ({changes["change"]}): 103 | Old: {changes["old"]} 104 | New: {changes["new"]}""" 105 | 106 | if template == "message": 107 | message = f"{title}\n{message}" 108 | result.pop("title") 109 | 110 | if template == "telegram": 111 | message = f""" 112 | WebUntis ({entry_title}) - {changes['title']} 113 | Subject: {changes["subject"]} 114 | Date: {changes["date"]} 115 | Time: {changes["time_start"]} - {changes["time_end"]}""" 116 | 117 | if changes["change"] not in ["cancelled", "test"]: 118 | message += f""" 119 | {changes["change"]} 120 | Old: {changes["old"]} 121 | New: {changes["new"]}""" 122 | data = {"parse_mode": "html"} 123 | 124 | if template == "discord": 125 | data = { 126 | "embed": { 127 | "title": changes["title"], 128 | "description": entry_title, 129 | "color": 16750848, 130 | "author": { 131 | "name": "WebUntis", 132 | "url": "https://www.home-assistant.io", 133 | "icon_url": "https://brands.home-assistant.io/webuntis/icon.png", 134 | }, 135 | "fields": [ 136 | {"name": "Subject", "value": changes["subject"], "inline": False}, 137 | {"name": "Date", "value": changes["date"], "inline": False}, 138 | {"name": "Time", "value": "", "inline": False}, 139 | {"name": "Start", "value": changes["time_start"]}, 140 | {"name": "End", "value": changes["time_end"]}, 141 | ], 142 | } 143 | } 144 | 145 | if changes["change"] not in ["cancelled", "test"]: 146 | data["embed"]["fields"].extend( 147 | [ 148 | { 149 | "name": changes["title"].replace(" changed", ""), 150 | "value": "", 151 | "inline": False, 152 | }, 153 | {"name": "Old", "value": changes["old"]}, 154 | {"name": "New", "value": changes["new"]}, 155 | ] 156 | ) 157 | 158 | result["message"] = message 159 | 160 | return result, data 161 | 162 | 163 | """ 164 | parameters = { 165 | "homework_id": hw_id, 166 | "subject": subject, 167 | "teacher": teacher_name, 168 | "completed": completed, 169 | "date_assigned": date_assigned, 170 | "due_date": due_date, 171 | "text": text, 172 | } 173 | """ 174 | 175 | 176 | def get_notification_data_homework(parameters, service, entry_title, server): 177 | message = "" 178 | title = "" 179 | data = {} 180 | 181 | subject = get_lesson_name_str(server, parameters["subject"], parameters["teacher"]) 182 | 183 | template = service.get("template", TEMPLATE_OPTIONS[0]) 184 | 185 | if template == "message_title": 186 | title = f"WebUntis ({entry_title}) - Homework: {subject}" 187 | message = f"""{parameters["text"]} 188 | Subject: {parameters["subject"]} 189 | Teacher: {parameters["teacher"]} 190 | Assigned Date: {parameters["date_assigned"]} 191 | Due Date: {parameters["due_date"]}""" 192 | 193 | elif template == "message": 194 | message = f"{title}\n{message}" 195 | 196 | elif template == "discord": 197 | data = { 198 | "embed": { 199 | "title": "Homework: " + subject, 200 | "description": entry_title, 201 | "color": 16750848, 202 | "author": { 203 | "name": "WebUntis", 204 | "url": "https://www.home-assistant.io", 205 | "icon_url": "https://brands.home-assistant.io/webuntis/icon.png", 206 | }, 207 | "fields": [ 208 | { 209 | "name": "Subject", 210 | "value": subject, 211 | "inline": False, 212 | }, 213 | { 214 | "name": "Text", 215 | "value": parameters["text"], 216 | "inline": False, 217 | }, 218 | {"name": "Date", "value": "", "inline": False}, 219 | {"name": "Assigned", "value": parameters["date_assigned"]}, 220 | {"name": "Due", "value": parameters["due_date"]}, 221 | ], 222 | } 223 | } 224 | 225 | return { 226 | "message": message, 227 | "title": title, 228 | }, data 229 | 230 | 231 | def get_changes(change, lesson, lesson_old, server): 232 | 233 | changes = {"change": change} 234 | 235 | changes["title"] = { 236 | "code": "Status changed", 237 | "rooms": "Room changed", 238 | "cancelled": "Lesson cancelled", 239 | "lesson_change": "Lesson changed", 240 | "teachers": "Teacher changed", 241 | }[change] 242 | 243 | changes["subject"] = get_lesson_name(server, lesson) 244 | 245 | changes["date"] = lesson["start"].strftime("%d.%m.%Y") 246 | changes["time_start"] = lesson["start"].strftime("%H:%M") 247 | changes["time_end"] = lesson["end"].strftime("%H:%M") 248 | 249 | changes["old"] = None 250 | changes["new"] = None 251 | 252 | if change == "cancelled": 253 | pass 254 | elif change == "lesson_change": 255 | changes.update( 256 | { 257 | "old": lesson_old.get("subjects", [{}])[0].get("long_name", ""), 258 | "new": lesson.get("subjects", [{}])[0].get("long_name", ""), 259 | } 260 | ) 261 | elif change == "rooms": 262 | changes.update( 263 | { 264 | "old": lesson_old.get("rooms", [{}])[0].get("name", ""), 265 | "new": lesson.get("rooms", [{}])[0].get("name", ""), 266 | } 267 | ) 268 | elif change == "teachers": 269 | changes.update( 270 | { 271 | "old": lesson_old.get("teachers", [{}])[0].get("name", ""), 272 | "new": lesson.get("teachers", [{}])[0].get("name", ""), 273 | } 274 | ) 275 | 276 | return changes 277 | -------------------------------------------------------------------------------- /custom_components/webuntis/translations/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Het apparaat is al geconfigureerd" 5 | }, 6 | "error": { 7 | "cannot_connect": "Kan geen verbinding maken. Controleer de host.", 8 | "invalid_auth": "Ongeldige verificatie", 9 | "bad_credentials": "Ongeldige gebruikersnaam en/of wachtwoord", 10 | "school_not_found": "Invalid school name/id", 11 | "name_split_error": "Ongeldige naam. Probeer \"voornaam, achternaam\".", 12 | "student_not_found": "Student niet gevonden. Controleer de naam of kies een andere bron.", 13 | "teacher_not_found": "Leraar niet gevonden. Controleer de naam of kies een andere bron.", 14 | "class_not_found": "Klasse niet gevonden. Controleer de klasse of kies een andere bron.", 15 | "no_rights_for_timetable": "No rights for timetable. Choose another name or source.", 16 | "no_school_year": "You can only configurate via Class if there is a active schoolyear.", 17 | "unknown": "Unexpected error. View Logs for more infos." 18 | }, 19 | "step": { 20 | "user": { 21 | "data": { 22 | "server": "Server", 23 | "school": "School", 24 | "username": "Gebruikersnaam", 25 | "password": "Wachtwoord", 26 | "timetable_source": "Tijdschema bron", 27 | "timetable_source_id": "Full name/Class" 28 | }, 29 | "data_description": { 30 | "timetable_source_id": "Enter the full name of the student/teacher or the class name" 31 | } 32 | }, 33 | "timetable_source": { 34 | "description": "Select the data source" 35 | }, 36 | "pick_student": { 37 | "description": "Enter a Student Name", 38 | "data": { 39 | "surname": "Surname", 40 | "fore_name": "Fore Name" 41 | } 42 | }, 43 | "pick_teacher": { 44 | "description": "Enter a Teacher Name", 45 | "data": { 46 | "surname": "Surname", 47 | "fore_name": "Fore Name" 48 | } 49 | }, 50 | "pick_klasse": { 51 | "description": "Enter class Name", 52 | "data": { 53 | "klasse": "Class" 54 | } 55 | } 56 | } 57 | }, 58 | "options": { 59 | "error": { 60 | "unknown_service": "Service ID not found.", 61 | "not_a_dict": "Has to be a dictionary", 62 | "notification_invalid": "Notification could not be sent. See logs for more information.", 63 | "unknown": "Onverwachte fout" 64 | }, 65 | "step": { 66 | "init": { 67 | "menu_options": { 68 | "filter": "Filter", 69 | "calendar": "Calendar", 70 | "lesson": "Lesson Name", 71 | "backend": "Backend", 72 | "notify_menu": "Notify Menu" 73 | } 74 | }, 75 | "filter": { 76 | "title": "Filterinstellingen", 77 | "description": "Instellingen om ongewenste onderwerpen uit te filteren.", 78 | "data": { 79 | "filter_mode": "Filter modus", 80 | "filter_subjects": "Filtermodus - onderwerpen", 81 | "filter_description": "Filtermodus - Beschrijving", 82 | "invalid_subjects": "No Subjects" 83 | }, 84 | "data_description": { 85 | "filter_description": "Verberg lessen die de volgende zinnen in de beschrijving bevatten. (Door komma's gescheiden lijst) \nWordt niet beïnvloed door zwarte lijst/witte lijst.", 86 | "invalid_subjects": "Allow lessons with no subjects" 87 | } 88 | }, 89 | "lesson": { 90 | "title": "Lesson Name Settings", 91 | "description": "Settings to customize how the lesson is displayed. \nThis will be applied to the calendar and notifications.", 92 | "data": { 93 | "lesson_long_name": "Long name", 94 | "lesson_replace_name": "Replace Lesson name", 95 | "lesson_add_teacher": "Add Teacher to subject" 96 | }, 97 | "data_description": { 98 | "lesson_replace_name": "e.g.,\nM: Math", 99 | "lesson_add_teacher": "Select which subjects should be displayed with the teacher's name." 100 | } 101 | }, 102 | "calendar": { 103 | "title": "Kalenderinstellingen", 104 | "description": "Instellingen om de kalender aan te passen.", 105 | "data": { 106 | "calendar_show_cancelled_lessons": "Kalender - geannuleerde lessen", 107 | "calendar_description": "Calendar - description", 108 | "calendar_room": "Calendar - Location", 109 | "calendar_show_room_change": "Calendar - Room change", 110 | "calendar_replace_name": "Replace event name" 111 | }, 112 | "data_description": { 113 | "calendar_show_cancelled_lessons": "Toon geannuleerde lessen in de kalender.", 114 | "calendar_description": "Determine what should be in the description of a calendar event.", 115 | "calendar_room": "Specify what to display for location.", 116 | "calendar_show_room_change": "Show room changes in the calendar.", 117 | "calendar_replace_name": "e.g.,\nCancelled: ❌" 118 | } 119 | }, 120 | "backend": { 121 | "title": "Instellingen achterzijde", 122 | "description": "Instellingen voor verbinding en gegevensverwerking.", 123 | "data": { 124 | "keep_loged_in": "Ingelogd blijven", 125 | "generate_json": "JSON genereren", 126 | "exclude_data": "Gegevens uitsluiten van verzoek" 127 | }, 128 | "data_description": { 129 | "keep_loged_in": "Probeer de sessiegegevens op te slaan. (BETA)", 130 | "generate_json": "Genereer JSON in Sensorattributen - alleen inschakelen indien nodig.", 131 | "exclude_data": "Deze optie wordt automatisch ingesteld als de gebruiker geen rechten heeft, om het spammen van foutmeldingen te voorkomen." 132 | } 133 | }, 134 | "edit_notify_service": { 135 | "title": "Notify Menu", 136 | "description": "add or edit a notify service", 137 | "data": { 138 | "name": "Name", 139 | "entity_id": "Notify Service ID", 140 | "target": "Platform-Specific Target Data", 141 | "data": "Platform-Specific Notification Data", 142 | "template": "Notify Template", 143 | "options": "Notify on" 144 | }, 145 | "data_description": { 146 | "data": "e.g.,\nnotification_icon: mdi:school-outline", 147 | "entity_id": "e.g., notify.discord", 148 | "template": "may overwrite data keys" 149 | } 150 | }, 151 | "notify_menu": { 152 | "title": "Notify Service", 153 | "menu_options": { 154 | "edit_notify_service": "add notify service", 155 | "edit_notify_service_select": "edit notify service", 156 | "remove_notify_service": "remove notify service", 157 | "test_notify_service": "test notify service" 158 | } 159 | } 160 | } 161 | }, 162 | "selector": { 163 | "notify_options": { 164 | "options": { 165 | "homework": "Homework added", 166 | "code": "Lesson status changed", 167 | "rooms": "Room changed", 168 | "teachers": "Teacher changed", 169 | "cancelled": "Lesson is cancelled", 170 | "lesson_change": "Lesson was swapped" 171 | } 172 | }, 173 | "timetable_source": { 174 | "options": { 175 | "personal": "Personal Timetable", 176 | "student": "Student", 177 | "klasse": "Klasse", 178 | "teacher": "Teacher", 179 | "subject": "Subject", 180 | "room": "Raum" 181 | } 182 | }, 183 | "notify_template": { 184 | "options": { 185 | "message_title": "Message & Title", 186 | "message": "Message", 187 | "discord": "Discord" 188 | } 189 | } 190 | }, 191 | "issues": { 192 | "bad_credentials": { 193 | "fix_flow": { 194 | "error": { 195 | "cannot_connect": "Failed to connect. Please check the host.", 196 | "invalid_auth": "Invalid authentication", 197 | "bad_credentials": "Invalid password", 198 | "unknown": "Unexpected error" 199 | }, 200 | "step": { 201 | "confirm": { 202 | "description": "Please enter the new password!", 203 | "title": "Password has been changed", 204 | "data": { 205 | "password": "Password" 206 | } 207 | } 208 | } 209 | }, 210 | "title": "Password has been changed" 211 | } 212 | }, 213 | "services": { 214 | "get_timetable": { 215 | "description": "Get the timetable in a given time range.", 216 | "fields": { 217 | "device_id": { 218 | "name": "Device", 219 | "description": "Select an instance" 220 | }, 221 | "start": { 222 | "name": "Start", 223 | "description": "Start of time range" 224 | }, 225 | "end": { 226 | "name": "End", 227 | "description": "End of time range" 228 | }, 229 | "apply_filter": { 230 | "name": "Activate Filter", 231 | "description": "Applies the filter from the settings to the results" 232 | }, 233 | "show_cancelled": { 234 | "name": "Show Cancelled lessons", 235 | "description": "Display cancelled lessons in the result" 236 | }, 237 | "compact_result": { 238 | "name": "Simplify Results", 239 | "description": "Combine double lessons in the result" 240 | } 241 | }, 242 | "name": "Get Timetable" 243 | }, 244 | "count_lessons": { 245 | "description": "Count lessons within a time range.", 246 | "fields": { 247 | "device_id": { 248 | "name": "Device", 249 | "description": "Select an instance" 250 | }, 251 | "start": { 252 | "name": "Start", 253 | "description": "Start of time range" 254 | }, 255 | "end": { 256 | "name": "End", 257 | "description": "End of time range" 258 | }, 259 | "apply_filter": { 260 | "name": "Activate Filter", 261 | "description": "Apply the filter from the settings to the results" 262 | }, 263 | "count_cancelled": { 264 | "name": "Count Cancelled lessons", 265 | "description": "Count cancelled lessons" 266 | } 267 | }, 268 | "name": "Count Hours" 269 | } 270 | } 271 | } -------------------------------------------------------------------------------- /custom_components/webuntis/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device is already configured" 5 | }, 6 | "error": { 7 | "cannot_connect": "Failed to connect. Please check the host.", 8 | "invalid_auth": "Invalid authentication", 9 | "bad_credentials": "Invalid username and/or password", 10 | "school_not_found": "Invalid school name/id", 11 | "name_split_error": "Invalid name. Try \"first_name, last_name\".", 12 | "student_not_found": "Student not found. Check the name or choose another source.", 13 | "teacher_not_found": "Teacher not found. Check the name or choose another source.", 14 | "class_not_found": "Class not found. Check the class or choose another source.", 15 | "no_rights_for_timetable": "No rights for timetable. Choose another name or source.", 16 | "no_school_year": "Configuration via class only possible during an active school year.", 17 | "no_personal_timetable": "No personal timetable is available for this user. Please choose another source.", 18 | "unknown": "Unexpected error. View logs for more information." 19 | }, 20 | "step": { 21 | "user": { 22 | "data": { 23 | "server": "Server", 24 | "school": "School", 25 | "username": "Username", 26 | "password": "Password", 27 | "timetable_source": "Timetable source", 28 | "timetable_source_id": "Full name/Class" 29 | }, 30 | "data_description": { 31 | "timetable_source_id": "Enter the full name of the student/teacher or the class name" 32 | } 33 | }, 34 | "timetable_source": { 35 | "description": "Select the data source" 36 | }, 37 | "pick_student": { 38 | "description": "Enter a Student Name", 39 | "data": { 40 | "surname": "Surname", 41 | "fore_name": "Fore Name", 42 | "back": "Back" 43 | } 44 | }, 45 | "pick_teacher": { 46 | "description": "Enter a Teacher Name", 47 | "data": { 48 | "surname": "Surname", 49 | "fore_name": "Fore Name", 50 | "back": "Back" 51 | } 52 | }, 53 | "pick_klasse": { 54 | "description": "Enter a class name", 55 | "data": { 56 | "klasse": "Class", 57 | "back": "Back" 58 | } 59 | } 60 | } 61 | }, 62 | "options": { 63 | "error": { 64 | "unknown_service": "Service ID not found.", 65 | "not_a_dict": "Has to be a dictionary", 66 | "notification_invalid": "Notification could not be sent. See logs for more information.", 67 | "unknown": "Unexpected error" 68 | }, 69 | "step": { 70 | "init": { 71 | "menu_options": { 72 | "filter": "Filter", 73 | "calendar": "Calendar", 74 | "lesson": "Lesson Name", 75 | "backend": "Backend", 76 | "notify_menu": "Notify Menu" 77 | } 78 | }, 79 | "filter": { 80 | "title": "Filter Settings", 81 | "description": "Settings to filter out unwanted subjects.", 82 | "data": { 83 | "filter_mode": "Filter mode", 84 | "filter_subjects": "Filter mode - subjects", 85 | "filter_description": "Filter mode - Description", 86 | "invalid_subjects": "No Subjects" 87 | }, 88 | "data_description": { 89 | "filter_description": "Hide lessons that contain the following phrases in the description. (Comma-separated list) \nNot affected by blacklist/whitelist.", 90 | "invalid_subjects": "Allow lessons with no subjects" 91 | } 92 | }, 93 | "lesson": { 94 | "title": "Lesson Name Settings", 95 | "description": "Settings to customize how the lesson is displayed. \nThis will be applied to the calendar and notifications.", 96 | "data": { 97 | "lesson_long_name": "Long name", 98 | "lesson_replace_name": "Replace Lesson name", 99 | "lesson_add_teacher": "Add Teacher to subject" 100 | }, 101 | "data_description": { 102 | "lesson_replace_name": "e.g.,\nM: Math", 103 | "lesson_add_teacher": "Select which subjects should be displayed with the teacher's name." 104 | } 105 | }, 106 | "calendar": { 107 | "title": "Calendar Settings", 108 | "description": "Settings to customize the calendar.", 109 | "data": { 110 | "calendar_show_cancelled_lessons": "Calendar - cancelled lessons", 111 | "calendar_description": "Calendar - description", 112 | "calendar_room": "Calendar - Location", 113 | "calendar_show_room_change": "Calendar - Room change", 114 | "calendar_replace_name": "Replace event name" 115 | }, 116 | "data_description": { 117 | "calendar_show_cancelled_lessons": "Show cancelled lessons in the calendar.", 118 | "calendar_description": "Determine what should be in the description of a calendar event.", 119 | "calendar_room": "Specify what to display for location.", 120 | "calendar_show_room_change": "Show room changes in the calendar.", 121 | "calendar_replace_name": "e.g.,\nCancelled: ❌" 122 | } 123 | }, 124 | "backend": { 125 | "title": "Backend Settings", 126 | "description": "Connection and data processing settings.", 127 | "data": { 128 | "keep_loged_in": "Keep logged in", 129 | "generate_json": "Generate JSON", 130 | "exclude_data": "Exclude data from request" 131 | }, 132 | "data_description": { 133 | "keep_loged_in": "Try to save the session data. (BETA)", 134 | "generate_json": "Generate JSON in Sensor Attributes - enable only if needed.", 135 | "exclude_data": "This option is set automatically if the user has no rights, to prevent error spamming." 136 | } 137 | }, 138 | "edit_notify_service": { 139 | "title": "Notify Menu", 140 | "description": "add or edit a notify service", 141 | "data": { 142 | "name": "Name", 143 | "entity_id": "Notify Service ID", 144 | "target": "Platform-Specific Target Data", 145 | "data": "Platform-Specific Notification Data", 146 | "template": "Notify Template", 147 | "options": "Notify on" 148 | }, 149 | "data_description": { 150 | "data": "e.g.,\nnotification_icon: mdi:school-outline", 151 | "entity_id": "e.g., notify.discord", 152 | "template": "may overwrite data keys" 153 | } 154 | }, 155 | "notify_menu": { 156 | "title": "Notify Service", 157 | "menu_options": { 158 | "edit_notify_service": "add notify service", 159 | "edit_notify_service_select": "edit notify service", 160 | "remove_notify_service": "remove notify service", 161 | "test_notify_service": "test notify service" 162 | } 163 | } 164 | } 165 | }, 166 | "selector": { 167 | "notify_options": { 168 | "options": { 169 | "homework": "Homework added", 170 | "code": "Lesson status changed", 171 | "rooms": "Room changed", 172 | "teachers": "Teacher changed", 173 | "cancelled": "Lesson is cancelled", 174 | "lesson_change": "Lesson was swapped" 175 | } 176 | }, 177 | "timetable_source": { 178 | "options": { 179 | "personal": "Personal timetable (recommended)", 180 | "student": "Student", 181 | "klasse": "Class", 182 | "teacher": "Teacher", 183 | "subject": "Subject", 184 | "room": "Raum" 185 | } 186 | }, 187 | "notify_template": { 188 | "options": { 189 | "message_title": "Message & Title", 190 | "message": "Message", 191 | "discord": "Discord", 192 | "telegram": "Telegram" 193 | } 194 | }, 195 | "calendar_description": { 196 | "options": { 197 | "none": "None", 198 | "json": "JSON", 199 | "lesson_info": "Lesson Info", 200 | "class_name_short": "Class Name (short)", 201 | "class_name_long": "Class Name (long)" 202 | } 203 | } 204 | }, 205 | "issues": { 206 | "bad_credentials": { 207 | "fix_flow": { 208 | "error": { 209 | "cannot_connect": "Failed to connect. Please check the host.", 210 | "invalid_auth": "Invalid authentication", 211 | "bad_credentials": "Invalid password", 212 | "unknown": "Unexpected error" 213 | }, 214 | "step": { 215 | "confirm": { 216 | "description": "Please enter the new password!", 217 | "title": "Password has been changed", 218 | "data": { 219 | "password": "Password" 220 | } 221 | } 222 | } 223 | }, 224 | "title": "Password has been changed" 225 | } 226 | }, 227 | "services": { 228 | "get_timetable": { 229 | "description": "Get the timetable in a given time range.", 230 | "fields": { 231 | "device_id": { 232 | "name": "Device", 233 | "description": "Select an instance" 234 | }, 235 | "start": { 236 | "name": "Start", 237 | "description": "Start of time range" 238 | }, 239 | "end": { 240 | "name": "End", 241 | "description": "End of time range" 242 | }, 243 | "apply_filter": { 244 | "name": "Activate Filter", 245 | "description": "Applies the filter from the settings to the results" 246 | }, 247 | "show_cancelled": { 248 | "name": "Show Cancelled lessons", 249 | "description": "Display cancelled lessons in the result" 250 | }, 251 | "compact_result": { 252 | "name": "Simplify Results", 253 | "description": "Combine double lessons in the result" 254 | } 255 | }, 256 | "name": "Get Timetable" 257 | }, 258 | "count_lessons": { 259 | "description": "Count lessons within a time range.", 260 | "fields": { 261 | "device_id": { 262 | "name": "Device", 263 | "description": "Select an instance" 264 | }, 265 | "start": { 266 | "name": "Start", 267 | "description": "Start of time range" 268 | }, 269 | "end": { 270 | "name": "End", 271 | "description": "End of time range" 272 | }, 273 | "apply_filter": { 274 | "name": "Activate Filter", 275 | "description": "Apply the filter from the settings to the results" 276 | }, 277 | "count_cancelled": { 278 | "name": "Count Cancelled lessons", 279 | "description": "Count cancelled lessons" 280 | } 281 | }, 282 | "name": "Count Hours" 283 | }, 284 | "get_schoolyears": { 285 | "description": "Get WebUntis schoolyears", 286 | "fields": { 287 | "device_id": { 288 | "name": "Device", 289 | "description": "Select an instance" 290 | } 291 | }, 292 | "name": "Get Schoolyears" 293 | } 294 | }, 295 | "entity": { 296 | "sensor": { 297 | "next_class": { 298 | "name": "Next Lesson" 299 | }, 300 | "next_lesson_to_wake_up": { 301 | "name": "Next Lesson to Wake Up" 302 | }, 303 | "today_school_start": { 304 | "name": "Today's School Start" 305 | }, 306 | "today_school_end": { 307 | "name": "Today's School End" 308 | } 309 | }, 310 | "calendar": { 311 | "homework": { 312 | "name": "Homework" 313 | }, 314 | "exam": { 315 | "name": "Exams" 316 | } 317 | }, 318 | "event": { 319 | "lesson_change": { 320 | "name": "Lesson Change", 321 | "state_attributes": { 322 | "event_type": { 323 | "state": { 324 | "code": "Lesson status changed", 325 | "rooms": "Room changed", 326 | "teachers": "Teacher changed", 327 | "cancelled": "Lesson cancelled", 328 | "lesson_change": "Lesson swapped" 329 | } 330 | } 331 | } 332 | }, 333 | "new_homework": { 334 | "name": "New Homework", 335 | "state_attributes": { 336 | "event_type": { 337 | "state": { 338 | "homework": "Homework added" 339 | } 340 | } 341 | } 342 | } 343 | } 344 | } 345 | } -------------------------------------------------------------------------------- /custom_components/webuntis/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Der Sensor ist bereits konfiguriert." 5 | }, 6 | "error": { 7 | "cannot_connect": "Verbindung fehlgeschlagen. Überprüfe die Server-Domain.", 8 | "invalid_auth": "Ungültige Authentifizierung", 9 | "bad_credentials": "Ungültiger Benutzername und/oder Passwort", 10 | "school_not_found": "Ungültiger Schulname/-ID", 11 | "name_split_error": "Ungültiger Name. Versuche \"Vorname, Nachname\".", 12 | "student_not_found": "Schüler nicht gefunden. Überprüfen Sie den Namen oder wählen Sie eine andere Quelle.", 13 | "teacher_not_found": "Lehrer nicht gefunden. Überprüfen Sie den Namen oder wählen Sie eine andere Quelle.", 14 | "class_not_found": "Klasse nicht gefunden. Überprüfen Sie die Klasse oder wählen Sie eine andere Quelle.", 15 | "no_rights_for_timetable": "Keine Rechte für den Stundenplan. Wählen Sie einen anderen Namen oder eine andere Quelle.", 16 | "no_school_year": "Konfiguration über die Klasse nur während eines aktiven Schuljahres möglich.", 17 | "no_personal_timetable": "Für diesen Benutzer ist kein persönlicher Stundenplan verfügbar. Bitte wähle eine andere Quelle.", 18 | "unknown": "Unerwarteter Fehler. Logs für weitere Informationen ansehen." 19 | }, 20 | "step": { 21 | "user": { 22 | "data": { 23 | "server": "Server", 24 | "school": "Schule", 25 | "username": "Benutzername", 26 | "password": "Passwort", 27 | "timetable_source": "Stundenplan-Quelle", 28 | "timetable_source_id": "Voller Name/Klasse" 29 | }, 30 | "data_description": { 31 | "timetable_source_id": "Geben Sie den vollständigen Namen des Schülers/Lehrers oder den Namen der Klasse ein" 32 | } 33 | }, 34 | "timetable_source": { 35 | "description": "Wähle die Quelle" 36 | }, 37 | "pick_student": { 38 | "description": "Name eines Schülers eingeben", 39 | "data": { 40 | "surname": "Nachname", 41 | "fore_name": "Vorname", 42 | "back": "Zurück" 43 | } 44 | }, 45 | "pick_teacher": { 46 | "description": "Name eines Lehrers eingeben", 47 | "data": { 48 | "surname": "Nachname", 49 | "fore_name": "Vorname", 50 | "back": "Zurück" 51 | } 52 | }, 53 | "pick_klasse": { 54 | "description": "Klasse eingeben", 55 | "data": { 56 | "klasse": "Klasse", 57 | "back": "Zurück" 58 | } 59 | } 60 | } 61 | }, 62 | "options": { 63 | "error": { 64 | "unknown_service": "Service-ID nicht gefunden.", 65 | "not_a_dict": "Muss ein Dictionary sein", 66 | "notification_invalid": "Benachrichtigung konnte nicht gesendet werden. Siehe Logs für mehr Infos.", 67 | "unknown": "Unerwarteter Fehler" 68 | }, 69 | "step": { 70 | "init": { 71 | "menu_options": { 72 | "filter": "Filter", 73 | "calendar": "Kalender", 74 | "lesson": "Stunden Name", 75 | "backend": "Backend", 76 | "notify_menu": "Notify Menü" 77 | } 78 | }, 79 | "filter": { 80 | "title": "Filter-Einstellungen", 81 | "description": "Einstellungen zum Herausfiltern unerwünschter Fächer.", 82 | "data": { 83 | "filter_mode": "Fächer Filter", 84 | "filter_subjects": "Fächer Filter - Fächer", 85 | "filter_description": "Fächer Filter - Beschreibung", 86 | "invalid_subjects": "Kein Fach" 87 | }, 88 | "data_description": { 89 | "filter_description": "Verstecke Stunden, die die folgenden Ausdrücke in der Beschreibung enthalten. (Kommagetrennte Liste) \nWird nicht von Black/Whitelist beeinflusst.", 90 | "invalid_subjects": "Erlaube Stunden ohne Fach" 91 | } 92 | }, 93 | "lesson": { 94 | "title": "Stunden Name Einstellungen", 95 | "description": "Einstellungen, wie der Stunden-Name angezeigt wird.\n\\nDies wird auf den Kalender und die Benachrichtigungen angewendet.", 96 | "data": { 97 | "lesson_long_name": "Langer Name", 98 | "lesson_replace_name": "Stunden-Name ersetzen", 99 | "lesson_add_teacher": "Füge Lehrer-Kürzel zu Stunde hinzu" 100 | }, 101 | "data_description": { 102 | "lesson_replace_name": "Bsp:\nM: Mathe", 103 | "lesson_add_teacher": "Wähle Stunden aus, die mit Lehrer-Kürze angezeigt werden sollen." 104 | } 105 | }, 106 | "calendar": { 107 | "title": "Kalender-Einstellungen", 108 | "description": "Einstellungen zur Kalender Darstellung.", 109 | "data": { 110 | "calendar_show_cancelled_lessons": "Kalender - Ausfallende Stunden", 111 | "calendar_description": "Kalender - Beschreibung", 112 | "calendar_room": "Kalender - Ort", 113 | "calendar_show_room_change": "Kalender - Zimmerwechsel", 114 | "calendar_replace_name": "Ereignisnamen ersetzen" 115 | }, 116 | "data_description": { 117 | "calendar_show_cancelled_lessons": "Zeige ausfallende Stunden im Kalender an.", 118 | "calendar_description": "Bestimme, was in der Beschreibung eines Kalender-Events stehen soll.", 119 | "calendar_room": "Bestimme, was im Kalender als Ort angezeigt wird.", 120 | "calendar_show_room_change": "Zeige Zimmerwechsel im Kalender an.", 121 | "calendar_replace_name": "Bsp:\nCancelled: ❌" 122 | } 123 | }, 124 | "backend": { 125 | "title": "Server-Einstellungen", 126 | "description": "Einstellungen zur Verbindung und Datenverarbeitung.", 127 | "data": { 128 | "keep_loged_in": "Eingeloggt bleiben", 129 | "generate_json": "JSON generieren", 130 | "exclude_data": "Daten von request ausschließen" 131 | }, 132 | "data_description": { 133 | "keep_loged_in": "Versucht die Session zu speichern. (BETA)", 134 | "generate_json": "Generiere JSON in Sensor-Attribute - nur aktivieren, wenn es benötigt wird.", 135 | "exclude_data": "Diese Option wird automatisch gesetzt, wenn der Benutzer keine Rechte hat, um das spamen von Fehlermeldungen zu vermeiden." 136 | } 137 | }, 138 | "edit_notify_service": { 139 | "title": "Notify Menü", 140 | "description": "füge oder bearbeite einen Benachrichtigungs-Dienst", 141 | "data": { 142 | "name": "Name", 143 | "entity_id": "Notify Service ID", 144 | "target": "Plattformspezifische Targetdaten", 145 | "data": "Plattformspezifische Benachrichtigungs-Daten", 146 | "template": "Benachrichtigungs-Template", 147 | "options": "Benachrichtigen, wenn" 148 | }, 149 | "data_description": { 150 | "data": "Bsp:\nnotification_icon: mdi:school-outline", 151 | "entity_id": "Bsp: notify.discord", 152 | "template": "Kann Benachrichtigungs-Daten überschreiben" 153 | } 154 | }, 155 | "notify_menu": { 156 | "title": "Notify Dienst", 157 | "menu_options": { 158 | "edit_notify_service": "Notify Dienst hinzufügen", 159 | "edit_notify_service_select": "Notify Dienst bearbeiten", 160 | "remove_notify_service": "Notify Dienst löschen", 161 | "test_notify_service": "Notify Dienst testen" 162 | } 163 | } 164 | } 165 | }, 166 | "selector": { 167 | "notify_options": { 168 | "options": { 169 | "homework": "Hausaufgaben hinzugefügt", 170 | "code": "Stunden-Status ändert sich", 171 | "rooms": "Raum Änderung", 172 | "teachers": "Lehrer Änderung", 173 | "cancelled": "Stunde ausfällt", 174 | "lesson_change": "Stunde ausgetauscht" 175 | } 176 | }, 177 | "timetable_source": { 178 | "options": { 179 | "personal": "Persönlicher Stundenplan (empfohlen)", 180 | "student": "Schüler", 181 | "klasse": "Klasse", 182 | "teacher": "Lehrer", 183 | "subject": "Fach", 184 | "room": "Raum" 185 | } 186 | }, 187 | "notify_template": { 188 | "options": { 189 | "message_title": "Nachricht & Titel", 190 | "message": "Nachricht", 191 | "discord": "Discord" 192 | } 193 | }, 194 | "calendar_description": { 195 | "options": { 196 | "none": "Keine", 197 | "json": "JSON", 198 | "lesson_info": "Stunden Info", 199 | "class_name_short": "Klassen Name (kurz)", 200 | "class_name_long": "Klassen Name (lang)" 201 | } 202 | } 203 | }, 204 | "issues": { 205 | "bad_credentials": { 206 | "fix_flow": { 207 | "error": { 208 | "cannot_connect": "Verbindung fehlgeschlagen. Überprüfe die Server-Domain.", 209 | "invalid_auth": "Ungültige Authentifizierung", 210 | "bad_credentials": "Ungültiges Passwort", 211 | "unknown": "Unerwarteter Fehler" 212 | }, 213 | "step": { 214 | "confirm": { 215 | "description": "Bitte gib das neue Passwort ein!", 216 | "title": "Das Passwort wurde geändert", 217 | "data": { 218 | "password": "Passwort" 219 | } 220 | } 221 | } 222 | }, 223 | "title": "Das Passwort wurde geändert" 224 | } 225 | }, 226 | "services": { 227 | "get_timetable": { 228 | "description": "Stundenplan in einem bestimmten Zeitraum abrufen.", 229 | "fields": { 230 | "device_id": { 231 | "name": "Gerät", 232 | "description": "Eine Instanz auswählen" 233 | }, 234 | "start": { 235 | "name": "Start", 236 | "description": "Start des Zeitraums" 237 | }, 238 | "end": { 239 | "name": "Ende", 240 | "description": "Ende des Zeitraums" 241 | }, 242 | "apply_filter": { 243 | "name": "Filter aktivieren", 244 | "description": "Wendet den Filter aus den Einstellungen auf die Ergebnisse an" 245 | }, 246 | "show_cancelled": { 247 | "name": "Zeige ausgefallene Stunden", 248 | "description": "Zeige ausgefallene Stunden im Ergebnis" 249 | }, 250 | "compact_result": { 251 | "name": "Vereinfachte Ergebnisse", 252 | "description": "Fasse doppel Stunden im Ergebnis zusammen" 253 | } 254 | }, 255 | "name": "Stundenplan abrufen" 256 | }, 257 | "count_lessons": { 258 | "description": "Stunden in einem Zeitraum zählen.", 259 | "fields": { 260 | "device_id": { 261 | "name": "Gerät", 262 | "description": "Eine Instanz auswählen" 263 | }, 264 | "start": { 265 | "name": "Start", 266 | "description": "Start des Zeitraums" 267 | }, 268 | "end": { 269 | "name": "Ende", 270 | "description": "Ende des Zeitraums" 271 | }, 272 | "apply_filter": { 273 | "name": "Filter aktivieren", 274 | "description": "Wendet den Filter aus den Einstellungen auf die Ergebnisse an" 275 | }, 276 | "count_cancelled": { 277 | "name": "Zähle ausgefallene Stunden", 278 | "description": "Zähle ausgefallene Stunden" 279 | } 280 | }, 281 | "name": "Stunden zählen" 282 | }, 283 | "get_schoolyears": { 284 | "description": "WebUntis Schuljahre abrufen.", 285 | "fields": { 286 | "device_id": { 287 | "name": "Gerät", 288 | "description": "Eine Instanz auswählen" 289 | } 290 | }, 291 | "name": "Schuljahre abrufen" 292 | } 293 | }, 294 | "entity": { 295 | "sensor": { 296 | "next_class": { 297 | "name": "Nächste Stunde" 298 | }, 299 | "next_lesson_to_wake_up": { 300 | "name": "Nächste Stunde zum Aufstehen" 301 | }, 302 | "today_school_start": { 303 | "name": "Heutiger Schulbeginn" 304 | }, 305 | "today_school_end": { 306 | "name": "Heutiges Schulende" 307 | } 308 | }, 309 | "calendar": { 310 | "homework": { 311 | "name": "Hausaufgaben" 312 | }, 313 | "exam": { 314 | "name": "Prüfungen" 315 | } 316 | }, 317 | "event": { 318 | "lesson_change": { 319 | "name": "Stundenänderung", 320 | "state_attributes": { 321 | "event_type": { 322 | "state": { 323 | "code": "Stunden-Status geändert", 324 | "rooms": "Raum Änderung", 325 | "teachers": "Lehrer Änderung", 326 | "cancelled": "Stunde fällt aus", 327 | "lesson_change": "Stunde ausgetauscht" 328 | } 329 | } 330 | } 331 | }, 332 | "new_homework": { 333 | "name": "Neue Hausaufgabe", 334 | "state_attributes": { 335 | "event_type": { 336 | "state": { 337 | "homework": "Hausaufgabe hinzugefügt" 338 | } 339 | } 340 | } 341 | } 342 | } 343 | } 344 | } -------------------------------------------------------------------------------- /custom_components/webuntis/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for webuntisnew integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import datetime 6 | import logging 7 | import socket 8 | from typing import Any 9 | 10 | import requests 11 | import voluptuous as vol 12 | 13 | # pylint: disable=maybe-no-member 14 | import webuntis 15 | 16 | from urllib.parse import urlparse 17 | 18 | from homeassistant import config_entries 19 | from homeassistant.core import HomeAssistant, callback 20 | from homeassistant.data_entry_flow import FlowResult 21 | from homeassistant.helpers import config_validation as cv 22 | from homeassistant.helpers import selector 23 | 24 | from .const import ( 25 | CONFIG_ENTRY_VERSION, 26 | DEFAULT_OPTIONS, 27 | DOMAIN, 28 | NOTIFY_OPTIONS, 29 | TEMPLATE_OPTIONS, 30 | ) 31 | from .notify import get_notification_data 32 | from .utils.errors import * 33 | from .utils.utils import async_notify, is_service 34 | from .utils.web_untis import get_timetable_object 35 | 36 | # import webuntis.session 37 | 38 | 39 | _LOGGER = logging.getLogger(__name__) 40 | 41 | 42 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 43 | """Handle a config flow for webuntisnew.""" 44 | 45 | VERSION = CONFIG_ENTRY_VERSION 46 | 47 | _session_temp = None 48 | _user_input_temp = {} 49 | _source_id = None 50 | 51 | @staticmethod 52 | @callback 53 | def async_get_options_flow( 54 | config_entry: config_entries.ConfigEntry, 55 | ) -> config_entries.OptionsFlow: 56 | """Create the options flow.""" 57 | return OptionsFlowHandler(config_entry) 58 | 59 | async def async_step_user( 60 | self, 61 | user_input: dict[str, Any] | None = None, 62 | errors: dict[str, Any] | None = None, 63 | ) -> FlowResult: 64 | if user_input is not None: 65 | errors, self._session_temp = await self.validate_login(user_input) 66 | 67 | if not errors: 68 | self._user_input_temp = user_input 69 | return await self.async_step_timetable_source() 70 | 71 | user_input = user_input or {} 72 | 73 | return self.async_show_form( 74 | step_id="user", 75 | data_schema=vol.Schema( 76 | { 77 | vol.Required("server", default=user_input.get("server", "")): str, 78 | vol.Required("school", default=user_input.get("school", "")): str, 79 | vol.Required( 80 | "username", default=user_input.get("username", "") 81 | ): str, 82 | vol.Required( 83 | "password", default=user_input.get("password", "") 84 | ): str, 85 | } 86 | ), 87 | errors=errors, 88 | ) 89 | 90 | async def async_step_timetable_source( 91 | self, user_input: dict[str, Any] | None = None 92 | ) -> FlowResult: 93 | """Handle the initial step.""" 94 | errors = {} 95 | if user_input is not None: 96 | self._user_input_temp.update( 97 | {"timetable_source": user_input["timetable_source"]} 98 | ) 99 | if user_input["timetable_source"] == "personal": 100 | self._user_input_temp.update(user_input) 101 | self._user_input_temp.update({"timetable_source_id": "personal"}) 102 | errors = await self.hass.async_add_executor_job(self.test_timetable) 103 | if not errors: 104 | return await self.create_entry() 105 | elif user_input["timetable_source"] == "student": 106 | return await self.async_step_pick_student() 107 | 108 | elif user_input["timetable_source"] == "teacher": 109 | return await self.async_step_pick_teacher() 110 | 111 | elif user_input["timetable_source"] == "klasse": 112 | schoolyears = await self.hass.async_add_executor_job( 113 | self._session_temp.schoolyears 114 | ) 115 | 116 | current_schoolyear = await self.hass.async_add_executor_job( 117 | lambda: schoolyears.current 118 | ) 119 | if current_schoolyear: 120 | return await self.async_step_pick_klasse() 121 | else: 122 | errors = {"base": "no_school_year"} 123 | 124 | return self.async_show_form( 125 | errors=errors, 126 | step_id="timetable_source", 127 | data_schema=vol.Schema( 128 | { 129 | vol.Required( 130 | "timetable_source", 131 | ): selector.SelectSelector( 132 | selector.SelectSelectorConfig( 133 | options=[ 134 | "personal", 135 | "student", 136 | "klasse", 137 | "teacher", 138 | ], # "subject", "room" 139 | translation_key="timetable_source", 140 | ) 141 | ) 142 | } 143 | ), 144 | ) 145 | 146 | async def async_step_pick_student( 147 | self, user_input: dict[str, Any] | None = None 148 | ) -> FlowResult: 149 | """Handle the initial step.""" 150 | errors = {} 151 | if user_input is not None: 152 | if user_input.get("back"): 153 | return await self.async_step_timetable_source() 154 | self._user_input_temp.update( 155 | { 156 | "timetable_source": "student", 157 | "timetable_source_id": [ 158 | user_input["fore_name"], 159 | user_input["surname"], 160 | ], 161 | } 162 | ) 163 | 164 | errors = await self.hass.async_add_executor_job(self.test_timetable) 165 | if not errors: 166 | return await self.create_entry() 167 | else: 168 | user_input = {} 169 | 170 | return self.async_show_form( 171 | errors=errors, 172 | step_id="pick_student", 173 | data_schema=vol.Schema( 174 | { 175 | vol.Optional( 176 | "fore_name", default=user_input.get("fore_name", "") 177 | ): str, 178 | vol.Optional("surname", default=user_input.get("surname", "")): str, 179 | vol.Optional("back", default=False): bool, 180 | } 181 | ), 182 | ) 183 | 184 | async def async_step_pick_teacher( 185 | self, user_input: dict[str, Any] | None = None 186 | ) -> FlowResult: 187 | """Handle the initial step.""" 188 | errors = {} 189 | if user_input is not None: 190 | if user_input.get("back"): 191 | return await self.async_step_timetable_source() 192 | self._user_input_temp.update( 193 | { 194 | "timetable_source": "teacher", 195 | "timetable_source_id": [ 196 | user_input.get("fore_name"), 197 | user_input.get("surname"), 198 | ], 199 | } 200 | ) 201 | errors = await self.hass.async_add_executor_job(self.test_timetable) 202 | if not errors: 203 | return await self.create_entry() 204 | else: 205 | user_input = {} 206 | 207 | return self.async_show_form( 208 | errors=errors, 209 | step_id="pick_teacher", 210 | data_schema=vol.Schema( 211 | { 212 | vol.Optional( 213 | "fore_name", default=user_input.get("fore_name", "") 214 | ): str, 215 | vol.Optional("surname", default=user_input.get("surname", "")): str, 216 | vol.Optional("back", default=False): bool, 217 | } 218 | ), 219 | ) 220 | 221 | async def async_step_pick_klasse( 222 | self, user_input: dict[str, Any] | None = None 223 | ) -> FlowResult: 224 | """Handle the initial step.""" 225 | errors = {} 226 | if user_input is not None: 227 | if user_input.get("back"): 228 | return await self.async_step_timetable_source() 229 | if not user_input.get("klasse"): 230 | errors = {"base": "class_not_found"} 231 | else: 232 | klassen = await self.hass.async_add_executor_job( 233 | self._session_temp.klassen 234 | ) 235 | try: 236 | source = klassen.filter(name=user_input["klasse"])[0] 237 | except Exception as exc: 238 | errors = {"base": "class_not_found"} 239 | 240 | self._user_input_temp.update( 241 | { 242 | "timetable_source": "klasse", 243 | "timetable_source_id": user_input["klasse"], 244 | } 245 | ) 246 | errors = await self.hass.async_add_executor_job(self.test_timetable) 247 | if not errors: 248 | return await self.create_entry() 249 | else: 250 | user_input = {} 251 | 252 | return self.async_show_form( 253 | errors=errors, 254 | step_id="pick_klasse", 255 | data_schema=vol.Schema( 256 | { 257 | vol.Optional("klasse", default=user_input.get("klasse", "")): str, 258 | vol.Optional("back", default=False): bool, 259 | } 260 | ), 261 | ) 262 | 263 | async def create_entry(self): 264 | user_input = self._user_input_temp 265 | await self.async_set_unique_id( 266 | f"{self._source_id}@{user_input['school']}".lower().replace(" ", "-") 267 | ) 268 | self._abort_if_unique_id_configured() 269 | return self.async_create_entry( 270 | title=user_input["username"], 271 | data=user_input, 272 | options=DEFAULT_OPTIONS, 273 | ) 274 | 275 | def _show_form_user( 276 | self, 277 | user_input: dict[str, Any] | None = None, 278 | errors: dict[str, Any] | None = None, 279 | ) -> FlowResult: 280 | if user_input is None: 281 | user_input = {} 282 | 283 | return self.async_show_form( 284 | step_id="user", 285 | data_schema=vol.Schema( 286 | { 287 | vol.Required("server", default=user_input.get("server", "")): str, 288 | vol.Required("school", default=user_input.get("school", "")): str, 289 | vol.Required( 290 | "username", default=user_input.get("username", "") 291 | ): str, 292 | vol.Required( 293 | "password", default=user_input.get("password", "") 294 | ): str, 295 | vol.Required( 296 | "timetable_source", default=user_input.get("timetable_source") 297 | ): selector.SelectSelector( 298 | selector.SelectSelectorConfig( 299 | options=[ 300 | "student", 301 | "klasse", 302 | "teacher", 303 | ], # "subject", "room" 304 | translation_key="timetable_source", 305 | mode="dropdown", 306 | ) 307 | ), 308 | vol.Required( 309 | "timetable_source_id", 310 | default=user_input.get("timetable_source_id", ""), 311 | ): str, 312 | } 313 | ), 314 | errors=errors, 315 | ) 316 | 317 | async def validate_login(self, credentials: dict[str, Any]) -> dict[str, Any]: 318 | hass: HomeAssistant = self.hass 319 | 320 | errors = {} 321 | 322 | server = credentials["server"].strip() 323 | 324 | if not server.lower().startswith(("http://", "https://")): 325 | server = "https://" + server 326 | 327 | parsed = urlparse(server) 328 | hostname = parsed.hostname 329 | credentials["server"] = f"{parsed.scheme}://{parsed.netloc}" 330 | 331 | try: 332 | socket.gethostbyname(hostname) 333 | except Exception as exc: 334 | _LOGGER.error("Cannot resolve hostname(%s): %s", credentials["server"], exc) 335 | errors["server"] = "cannot_connect" 336 | return errors, None 337 | 338 | try: 339 | session = webuntis.Session( 340 | server=credentials["server"], 341 | school=credentials["school"], 342 | username=credentials["username"], 343 | password=credentials["password"], 344 | useragent="home-assistant", 345 | ) 346 | await hass.async_add_executor_job(session.login) 347 | except webuntis.errors.BadCredentialsError: 348 | errors["username"] = "bad_credentials" 349 | except requests.exceptions.ConnectionError as exc: 350 | _LOGGER.error("webuntis.Session connection error: %s", exc) 351 | errors["server"] = "cannot_connect" 352 | except webuntis.errors.RemoteError as exc: # pylint: disable=no-member 353 | errors["school"] = "school_not_found" 354 | raise (exc) 355 | except Exception as exc: 356 | _LOGGER.error("webuntis.Session unknown error: %s", exc) 357 | errors["base"] = "unknown" 358 | 359 | return errors, session 360 | 361 | def test_timetable(self): 362 | """test if timetable is allowed to be fetched""" 363 | 364 | session: webuntis.Session = self._session_temp 365 | user_input = self._user_input_temp 366 | 367 | day = datetime.date.today() 368 | schoolyears = session.schoolyears() 369 | current_schoolyear = schoolyears.current 370 | if not current_schoolyear: 371 | day = schoolyears[-1].start.date() 372 | 373 | try: 374 | if user_input["timetable_source"] == "personal": 375 | session.my_timetable(start=day, end=day) 376 | self._source_id = session.login_result["personId"] 377 | else: 378 | timetable_object = get_timetable_object( 379 | user_input["timetable_source_id"], 380 | user_input["timetable_source"], 381 | session, 382 | ) 383 | session.timetable( 384 | start=day, 385 | end=day, 386 | **timetable_object, 387 | ) 388 | self._source_id = timetable_object[user_input["timetable_source"]].id 389 | 390 | except Exception as exc: 391 | if str(exc) == "'Student not found'": 392 | return {"base": "student_not_found"} 393 | elif str(exc) == "no right for timetable": 394 | return {"base": "no_rights_for_timetable"} 395 | elif str(exc) == "'Teacher not found'": 396 | return {"base": "teacher_not_found"} 397 | elif str(exc) == "list index out of range": 398 | return {"base": "class_not_found"} 399 | elif str(exc) == "invalid elementType: 12": 400 | return {"base": "no_personal_timetable"} 401 | 402 | _LOGGER.error("Error testing timetable: %s", exc) 403 | return {"base": "unknown"} 404 | 405 | 406 | OPTIONS_MENU = [ 407 | "filter", 408 | "calendar", 409 | "lesson", 410 | "notify_menu", 411 | "backend", 412 | ] 413 | 414 | 415 | class OptionsFlowHandler(config_entries.OptionsFlow): 416 | """Handle the option flow for WebUntis.""" 417 | 418 | def __init__(self, config_entry: config_entries.ConfigEntry) -> None: 419 | """Initialize options flow.""" 420 | self._config_entry = config_entry 421 | 422 | async def async_step_init( 423 | self, 424 | user_input: dict[str, Any] | None = None, # pylint: disable=unused-argument 425 | ) -> FlowResult: 426 | """Manage the options.""" 427 | return self.async_show_menu(step_id="init", menu_options=OPTIONS_MENU) 428 | 429 | async def save(self, user_input): 430 | """Save the options""" 431 | _LOGGER.debug("Saving options: %s", user_input) 432 | options = dict(self._config_entry.options) # old options 433 | options.update(user_input) # update old options with new options 434 | _LOGGER.debug("New options: %s", options) 435 | return self.async_create_entry(title="", data=options) 436 | 437 | async def async_step_filter(self, user_input: dict[str, str] = None) -> FlowResult: 438 | """Manage the filter options.""" 439 | if user_input is not None: 440 | if not "filter_description" in user_input: 441 | user_input["filter_description"] = [] 442 | 443 | if user_input["filter_mode"] and not user_input["filter_subjects"]: 444 | user_input["filter_mode"] = "None" 445 | 446 | if user_input["filter_description"]: 447 | user_input["filter_description"] = user_input[ 448 | "filter_description" 449 | ].split(",") 450 | user_input["filter_description"] = [ 451 | s.strip() for s in user_input["filter_description"] if s != "" 452 | ] 453 | 454 | return await self.save(user_input) 455 | 456 | server = self.hass.data[DOMAIN][self._config_entry.unique_id] 457 | 458 | return self.async_show_form( 459 | step_id="filter", 460 | data_schema=vol.Schema( 461 | { 462 | vol.Required( 463 | "filter_mode", 464 | default=str(self._config_entry.options.get("filter_mode")), 465 | ): selector.SelectSelector( 466 | selector.SelectSelectorConfig( 467 | options=[ 468 | "None", 469 | "Blacklist", 470 | "Whitelist", 471 | ], 472 | mode="dropdown", 473 | ) 474 | ), 475 | vol.Required( 476 | "filter_subjects", 477 | default=self._config_entry.options.get("filter_subjects"), 478 | ): selector.SelectSelector( 479 | selector.SelectSelectorConfig( 480 | options=_create_subject_list(server), 481 | multiple=True, 482 | mode=selector.SelectSelectorMode.DROPDOWN, 483 | ), 484 | ), 485 | vol.Optional( 486 | "filter_description", 487 | description={ 488 | "suggested_value": ", ".join( 489 | self._config_entry.options.get("filter_description") 490 | ) 491 | }, 492 | ): selector.TextSelector( 493 | selector.TextSelectorConfig(multiline=True) 494 | ), 495 | vol.Required( 496 | "invalid_subjects", 497 | default=self._config_entry.options.get("invalid_subjects"), 498 | ): selector.BooleanSelector(), 499 | } 500 | ), 501 | ) 502 | 503 | async def async_step_calendar( 504 | self, user_input: dict[str, str] = None 505 | ) -> FlowResult: 506 | """Manage the calendar options.""" 507 | errors = {} 508 | if user_input is not None: 509 | if user_input.get("calendar_replace_name") is None: 510 | user_input["calendar_replace_name"] = {} 511 | if not ( 512 | isinstance(user_input.get("calendar_replace_name"), dict) 513 | and all( 514 | isinstance(k, str) and isinstance(v, str) 515 | for k, v in user_input["calendar_replace_name"].items() 516 | ) 517 | ): 518 | errors = {"calendar_replace_name": "not_a_dict"} 519 | else: 520 | return await self.save(user_input) 521 | 522 | return self.async_show_form( 523 | step_id="calendar", 524 | errors=errors, 525 | data_schema=vol.Schema( 526 | { 527 | vol.Required( 528 | "calendar_show_cancelled_lessons", 529 | default=self._config_entry.options.get( 530 | "calendar_show_cancelled_lessons" 531 | ), 532 | ): selector.BooleanSelector(), 533 | vol.Required( 534 | "calendar_show_room_change", 535 | default=self._config_entry.options.get( 536 | "calendar_show_room_change" 537 | ), 538 | ): selector.BooleanSelector(), 539 | vol.Required( 540 | "calendar_description", 541 | default=str( 542 | self._config_entry.options.get("calendar_description") 543 | ), 544 | ): selector.SelectSelector( 545 | selector.SelectSelectorConfig( 546 | options=[ 547 | "none", 548 | "json", 549 | "lesson_info", 550 | "class_name_short", 551 | "class_name_long", 552 | ], 553 | translation_key="calendar_description", 554 | mode="dropdown", 555 | ) 556 | ), 557 | vol.Required( 558 | "calendar_room", 559 | default=str(self._config_entry.options.get("calendar_room")), 560 | ): selector.SelectSelector( 561 | selector.SelectSelectorConfig( 562 | options=[ 563 | "Room long name", 564 | "Room short name", 565 | "Room short-long name", 566 | "None", 567 | ], 568 | mode="dropdown", 569 | ) 570 | ), 571 | vol.Optional( 572 | "calendar_replace_name", 573 | description={ 574 | "suggested_value": self._config_entry.options.get( 575 | "calendar_replace_name" 576 | ) 577 | }, 578 | ): selector.ObjectSelector(), 579 | } 580 | ), 581 | ) 582 | 583 | async def async_step_lesson(self, user_input: dict[str, str] = None) -> FlowResult: 584 | """Manage the lesson options.""" 585 | errors = {} 586 | if user_input is not None: 587 | if user_input.get("lesson_replace_name") is None: 588 | user_input["lesson_replace_name"] = {} 589 | 590 | if not ( 591 | isinstance(user_input.get("lesson_replace_name"), dict) 592 | and all( 593 | isinstance(k, str) and isinstance(v, str) 594 | for k, v in user_input["lesson_replace_name"].items() 595 | ) 596 | ): 597 | errors = {"lesson_replace_name": "not_a_dict"} 598 | else: 599 | return await self.save(user_input) 600 | 601 | server = self.hass.data[DOMAIN][self._config_entry.unique_id] 602 | 603 | return self.async_show_form( 604 | step_id="lesson", 605 | errors=errors, 606 | data_schema=vol.Schema( 607 | { 608 | vol.Required( 609 | "lesson_long_name", 610 | default=self._config_entry.options.get("lesson_long_name"), 611 | ): selector.BooleanSelector(), 612 | vol.Optional( 613 | "lesson_replace_name", 614 | description={ 615 | "suggested_value": self._config_entry.options.get( 616 | "lesson_replace_name" 617 | ) 618 | }, 619 | ): selector.ObjectSelector(), 620 | vol.Optional( 621 | "lesson_add_teacher", 622 | default=self._config_entry.options.get( 623 | "lesson_add_teacher", [] 624 | ), 625 | ): selector.SelectSelector( 626 | selector.SelectSelectorConfig( 627 | options=_create_subject_list(server), 628 | multiple=True, 629 | mode=selector.SelectSelectorMode.DROPDOWN, 630 | ), 631 | ), 632 | } 633 | ), 634 | ) 635 | 636 | async def async_step_backend( 637 | self, 638 | user_input: dict[str, str] = None, 639 | errors: dict[str, Any] | None = None, 640 | ) -> FlowResult: 641 | """Manage the backend options.""" 642 | if user_input is not None: 643 | return await self.save(user_input) 644 | return self.async_show_form( 645 | step_id="backend", 646 | data_schema=vol.Schema( 647 | { 648 | vol.Required( 649 | "keep_loged_in", 650 | default=self._config_entry.options.get("keep_loged_in"), 651 | ): selector.BooleanSelector(), 652 | vol.Required( 653 | "generate_json", 654 | default=self._config_entry.options.get("generate_json"), 655 | ): selector.BooleanSelector(), 656 | vol.Required( 657 | "exclude_data", 658 | default=self._config_entry.options.get("exclude_data"), 659 | ): selector.SelectSelector( 660 | selector.SelectSelectorConfig( 661 | options=["teachers"], 662 | multiple=True, 663 | mode=selector.SelectSelectorMode.DROPDOWN, 664 | ), 665 | ), 666 | } 667 | ), 668 | errors=errors, 669 | ) 670 | 671 | async def list_notify_services( 672 | self, step_id, multible=False, required=True, errors={} 673 | ): 674 | services = { 675 | id: service["name"] 676 | for id, service in self._config_entry.options["notify_config"].items() 677 | } 678 | 679 | select = cv.multi_select if multible else vol.In 680 | required = vol.Required if required else vol.Optional 681 | 682 | return self.async_show_form( 683 | step_id=step_id, 684 | errors=errors, 685 | data_schema=vol.Schema( 686 | { 687 | required("services"): select(services), 688 | } 689 | ), 690 | ) 691 | 692 | async def async_step_notify_menu( 693 | self, 694 | user_input: dict[str, str] = None, 695 | errors: dict[str, Any] | None = None, 696 | ) -> FlowResult: 697 | """Manage the notify_menu options.""" 698 | 699 | if not self._config_entry.options["notify_config"]: 700 | options = [ 701 | "edit_notify_service", 702 | ] 703 | else: 704 | options = [ 705 | "edit_notify_service", 706 | "edit_notify_service_select", 707 | "remove_notify_service", 708 | "test_notify_service", 709 | ] 710 | return self.async_show_menu(step_id="notify_menu", menu_options=options) 711 | 712 | async def async_step_edit_notify_service_select( 713 | self, 714 | user_input: dict[str, str] = None, 715 | errors: dict[str, Any] | None = None, 716 | ) -> FlowResult: 717 | """Manage the test options.""" 718 | if user_input is None: 719 | return await self.list_notify_services("edit_notify_service_select") 720 | else: 721 | return await self.async_step_edit_notify_service( 722 | edit=user_input["services"] 723 | ) 724 | 725 | async def async_step_remove_notify_service( 726 | self, 727 | user_input: dict[str, str] = None, 728 | errors: dict[str, Any] | None = None, 729 | ) -> FlowResult: 730 | """Manage the test options.""" 731 | if user_input is None: 732 | return await self.list_notify_services( 733 | "remove_notify_service", multible=True 734 | ) 735 | else: 736 | notify_config = self._config_entry.options["notify_config"] 737 | for key in user_input["services"]: 738 | notify_config.pop(key, None) 739 | return await self.save( 740 | { 741 | "notify_config": notify_config, 742 | "toggle": not self._config_entry.options.get("toggle"), 743 | } 744 | ) 745 | 746 | async def async_step_test_notify_service( 747 | self, 748 | user_input: dict[str, str] = None, 749 | errors: dict[str, Any] | None = None, 750 | ) -> FlowResult: 751 | """Manage the test options.""" 752 | if user_input is None: 753 | return await self.list_notify_services( 754 | "test_notify_service", multible=True, required=False, errors=errors 755 | ) 756 | else: 757 | for service in user_input.get("services", {}): 758 | config = self._config_entry.options["notify_config"][service] 759 | 760 | data = { 761 | "data": config.get("data", {}), 762 | "target": config.get("target", {}), 763 | } 764 | 765 | changes = { 766 | "change": "test", 767 | "title": "Test Notification", 768 | "subject": "Math", 769 | "date": datetime.datetime.now().strftime("%d.%m.%Y"), 770 | "time_start": datetime.datetime.now().strftime("%H:%M:%S"), 771 | "time_end": datetime.datetime.now().strftime("%H:%M:%S"), 772 | } 773 | 774 | dic, notify_data = get_notification_data( 775 | changes, config, self._config_entry.title 776 | ) 777 | 778 | for key, value in notify_data.items(): 779 | data["data"][key] = value 780 | 781 | data.update(dic) 782 | 783 | success = await async_notify( 784 | self.hass, 785 | service_id=config["entity_id"], 786 | data=data, 787 | ) 788 | if not success: 789 | return await self.async_step_test_notify_service( 790 | None, errors={"base": "notification_invalid"} 791 | ) 792 | 793 | return await self.save({}) 794 | 795 | async def async_step_edit_notify_service( 796 | self, 797 | user_input: dict[str, str] = None, 798 | errors: dict[str, Any] | None = None, 799 | edit=None, 800 | ) -> FlowResult: 801 | options = {} 802 | if edit: 803 | options = self._config_entry.options["notify_config"].get(edit) 804 | 805 | if user_input is not None: 806 | errors = {} 807 | 808 | if "entity_id" in user_input and not is_service( 809 | self.hass, user_input["entity_id"] 810 | ): 811 | errors["entity_id"] = "unknown_service" 812 | 813 | if "notify_target" in user_input and not isinstance( 814 | user_input["target"], dict 815 | ): 816 | errors["notify_target"] = "not_a_dict" 817 | 818 | if "notify_data" in user_input and not isinstance(user_input["data"], dict): 819 | errors["notify_data"] = "not_a_dict" 820 | 821 | if "name" not in user_input: 822 | user_input["name"] = user_input["entity_id"] 823 | 824 | if not errors: 825 | notify_config = self._config_entry.options["notify_config"] 826 | notify_config[user_input["entity_id"]] = user_input 827 | 828 | return await self.save( 829 | { 830 | "notify_config": notify_config, 831 | "toggle": not self._config_entry.options.get("toggle"), 832 | } 833 | ) 834 | 835 | options = self._config_entry.options["notify_config"].get( 836 | user_input["entity_id"], {} 837 | ) 838 | 839 | schema_options = { 840 | vol.Optional( 841 | "name", 842 | description={"suggested_value": options.get("name")}, 843 | ): selector.TextSelector(), 844 | vol.Required( 845 | "entity_id", 846 | description={"suggested_value": options.get("entity_id")}, 847 | ): selector.TextSelector(), 848 | vol.Optional( 849 | "target", 850 | description={"suggested_value": options.get("target")}, 851 | ): selector.ObjectSelector(selector.ObjectSelectorConfig()), 852 | vol.Optional( 853 | "data", 854 | description={"suggested_value": options.get("data")}, 855 | ): selector.ObjectSelector(), 856 | vol.Optional( 857 | "template", 858 | description={ 859 | "suggested_value": options.get("template", TEMPLATE_OPTIONS[0]) 860 | }, 861 | ): selector.SelectSelector( 862 | selector.SelectSelectorConfig( 863 | options=TEMPLATE_OPTIONS, 864 | mode=selector.SelectSelectorMode.DROPDOWN, 865 | translation_key="notify_template", 866 | ) 867 | ), 868 | vol.Optional( 869 | "options", 870 | description={"suggested_value": options.get("options")}, 871 | ): selector.SelectSelector( 872 | selector.SelectSelectorConfig( 873 | options=NOTIFY_OPTIONS, 874 | multiple=True, 875 | mode=selector.SelectSelectorMode.DROPDOWN, 876 | translation_key="notify_options", 877 | ) 878 | ), 879 | } 880 | 881 | return self.async_show_form( 882 | step_id="edit_notify_service", 883 | data_schema=vol.Schema(schema_options), 884 | errors=errors, 885 | ) 886 | 887 | 888 | def _create_subject_list(server): 889 | """Create a list of subjects.""" 890 | 891 | subjects = server.subjects 892 | 893 | return [subject.name for subject in subjects] 894 | -------------------------------------------------------------------------------- /custom_components/webuntis/__init__.py: -------------------------------------------------------------------------------- 1 | """The Web Untis integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | import logging 7 | from collections.abc import Mapping 8 | from datetime import date, datetime, timedelta 9 | from typing import Any 10 | import uuid 11 | 12 | from homeassistant.components.calendar import CalendarEvent 13 | from homeassistant.config_entries import ConfigEntry 14 | from homeassistant.const import Platform 15 | from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback 16 | from homeassistant.helpers import issue_registry as ir 17 | from homeassistant.helpers.dispatcher import ( 18 | async_dispatcher_connect, 19 | async_dispatcher_send, 20 | ) 21 | from homeassistant.helpers.entity import DeviceInfo, Entity 22 | from homeassistant.helpers.event import async_track_time_interval 23 | 24 | # pylint: disable=maybe-no-member 25 | from webuntis import errors 26 | from .utils.web_untis_extended import ExtendedSession 27 | from .utils.homework import return_homework_events 28 | from .utils.exams import return_exam_events 29 | from .utils.web_untis import get_lesson_name 30 | 31 | 32 | from .const import ( 33 | CONFIG_ENTRY_VERSION, 34 | DAYS_TO_FUTURE, 35 | DEFAULT_OPTIONS, 36 | DOMAIN, 37 | SCAN_INTERVAL, 38 | SIGNAL_NAME_PREFIX, 39 | NAME_EVENT_LESSON_CHANGE, 40 | NAME_EVENT_HOMEWORK 41 | ) 42 | from .notify import * 43 | from .services import async_setup_services 44 | from .utils.utils import compact_list, async_notify 45 | 46 | from .utils.web_untis import get_timetable_object 47 | 48 | PLATFORMS = [Platform.SENSOR, Platform.CALENDAR, Platform.EVENT] 49 | 50 | _LOGGER = logging.getLogger(__name__) 51 | 52 | 53 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 54 | """Set up WebUntis from a config entry.""" 55 | domain_data = hass.data.setdefault(DOMAIN, {}) 56 | 57 | # Create and store server instance. 58 | assert entry.unique_id 59 | unique_id = entry.unique_id 60 | _LOGGER.debug( 61 | "Creating server instance for '%s' (%s)", 62 | entry.data["username"], 63 | entry.data["school"], 64 | ) 65 | 66 | server = WebUntis(hass, unique_id, entry) 67 | domain_data[unique_id] = server 68 | 69 | # Set up platforms. 70 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 71 | 72 | await server.async_update() 73 | server.start_periodic_update() 74 | 75 | # Register update listener. 76 | entry.async_on_unload(entry.add_update_listener(async_update_entry)) 77 | 78 | await async_setup_services(hass) 79 | 80 | return True 81 | 82 | 83 | async def async_update_entry(hass, entry): 84 | """Handle options update.""" 85 | await hass.config_entries.async_reload(entry.entry_id) 86 | 87 | 88 | async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): 89 | """Migrate old entry.""" 90 | _LOGGER.debug("Migrating from version %s", config_entry.version) 91 | 92 | options = {**config_entry.options} 93 | 94 | for option, default in DEFAULT_OPTIONS.items(): 95 | if option not in options: 96 | options[option] = default 97 | 98 | if config_entry.version == 14: 99 | if "notify_entity_id" in options: 100 | options["notify_config"][options["notify_entity_id"]] = { 101 | "name": options["notify_entity_id"], 102 | "entity_id": options["notify_entity_id"], 103 | "target": options.get("notify_target", {}), 104 | "data": options.get("notify_data", {}), 105 | "options": options.get("notify_options", {}), 106 | } 107 | 108 | if config_entry.version == 16: 109 | for notify_key, notify_value in options["notify_config"].items(): 110 | if "lesson change" in options["notify_config"][notify_key]["options"]: 111 | options["notify_config"][notify_key]["options"][ 112 | options["notify_config"][notify_key]["options"].index( 113 | "lesson change" 114 | ) 115 | ] = "lesson_change" 116 | 117 | for key in [ 118 | "notify_entity_id", 119 | "notify_target", 120 | "notify_data", 121 | "notify_options", 122 | ]: 123 | options.pop(key, None) 124 | 125 | if config_entry.version < 18: 126 | options["lesson_replace_name"] = options.get("calendar_replace_name", {}) 127 | options["calendar_replace_name"] = {} 128 | options["lesson_long_name"] = options["calendar_long_name"] 129 | options.pop("calendar_long_name") 130 | 131 | hass.config_entries.async_update_entry( 132 | entry=config_entry, options=options, version=CONFIG_ENTRY_VERSION 133 | ) 134 | 135 | _LOGGER.info("Migration to version %s successful", config_entry.version) 136 | 137 | return True 138 | 139 | 140 | async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 141 | """Unload a config entry.""" 142 | unique_id = config_entry.unique_id 143 | server = hass.data[DOMAIN][unique_id] 144 | 145 | # Unload platforms. 146 | unload_ok = await hass.config_entries.async_unload_platforms( 147 | config_entry, PLATFORMS 148 | ) 149 | 150 | # Clean up. 151 | server.stop_periodic_update() 152 | hass.data[DOMAIN].pop(unique_id) 153 | 154 | return unload_ok 155 | 156 | 157 | class WebUntis: 158 | """Representation of a WebUntis client.""" 159 | 160 | def __init__( 161 | self, 162 | hass: HomeAssistant, 163 | unique_id: str, 164 | config: Mapping[str, Any], 165 | ) -> None: 166 | """Initialize client instance.""" 167 | self._hass = hass 168 | self._config = config 169 | 170 | # Server data 171 | self.unique_id = unique_id 172 | self.server = config.data["server"] 173 | self.school = config.data["school"] 174 | self.username = config.data["username"] 175 | self.password = config.data["password"] 176 | self.timetable_source = config.data["timetable_source"] 177 | self.timetable_source_id = config.data["timetable_source_id"] 178 | self.title = config.title 179 | 180 | self.calendar_show_cancelled_lessons = config.options[ 181 | "calendar_show_cancelled_lessons" 182 | ] 183 | self.calendar_show_room_change = config.options["calendar_show_room_change"] 184 | self.calendar_description = config.options["calendar_description"] 185 | self.calendar_room = config.options["calendar_room"] 186 | self.calendar_replace_name = config.options.get("calendar_replace_name", {}) 187 | 188 | self.lesson_long_name = config.options["lesson_long_name"] 189 | self.lesson_replace_name = config.options.get("lesson_replace_name", {}) 190 | self.lesson_add_teacher = config.options.get("lesson_add_teacher", []) 191 | 192 | self.keep_logged_in = config.options["keep_loged_in"] 193 | 194 | self.filter_mode = config.options["filter_mode"] # Blacklist, Whitelist, None 195 | self.filter_subjects = config.options["filter_subjects"] 196 | 197 | self.exclude_data = config.options["exclude_data"] 198 | self.exclude_data_run = [] 199 | 200 | self.filter_description = config.options["filter_description"] 201 | self.generate_json = config.options["generate_json"] 202 | 203 | self.invalid_subjects = config.options["invalid_subjects"] 204 | 205 | self.notify_config = {} 206 | 207 | self.notify_config = config.options.get("notify_config") 208 | self.notify = any( 209 | config.get("options") for config in self.notify_config.values() 210 | ) 211 | 212 | self.session = ExtendedSession( 213 | username=self.username, 214 | password=self.password, 215 | server=self.server, 216 | useragent="foo", 217 | school=self.school, 218 | ) 219 | self._loged_in = False 220 | self._last_status_request_failed = False 221 | self._no_lessons = False 222 | self.updating = 0 223 | self.issue = False 224 | 225 | # Data provided by 3rd party library 226 | self.schoolyears = None 227 | self.current_schoolyear = None 228 | self.student_id = None 229 | 230 | # sensor data 231 | self.next_class = None 232 | self.next_class_json = None 233 | self.next_lesson_to_wake_up = None 234 | self.calendar_events = [] 235 | self.calendar_exams = [] 236 | self.calendar_homework = [] 237 | self.calendar_homework_ids = [] 238 | self.calendar_homework_ids_setup = False 239 | self.next_day_json = None 240 | self.day_json = None 241 | self.today = [None, None] 242 | 243 | self.subjects = [] 244 | 245 | self.event_list = [] 246 | self.event_list_old = [] 247 | 248 | # Dispatcher signal name 249 | self.signal_name = f"{SIGNAL_NAME_PREFIX}_{self.unique_id}" 250 | 251 | # Callback for stopping periodic update. 252 | self._stop_periodic_update: CALLBACK_TYPE | None = None 253 | 254 | self.lesson_change_callback = None 255 | self.homework_change_callback = None 256 | 257 | def event_entity_listen(self, callback, id) -> None: 258 | """Listen for lesson change events.""" 259 | if id == NAME_EVENT_LESSON_CHANGE: 260 | self.lesson_change_callback = callback 261 | elif id == NAME_EVENT_HOMEWORK: 262 | self.homework_change_callback = callback 263 | 264 | def start_periodic_update(self) -> None: 265 | """Start periodic execution of update method.""" 266 | self._stop_periodic_update = async_track_time_interval( 267 | self._hass, self.async_update, timedelta(seconds=SCAN_INTERVAL) 268 | ) 269 | 270 | def stop_periodic_update(self) -> None: 271 | """Stop periodic execution of update method.""" 272 | if self._stop_periodic_update: 273 | self._stop_periodic_update() 274 | 275 | # pylint: disable=unused-argument 276 | async def async_update(self, now: datetime | None = None) -> None: 277 | """Get server data from 3rd party library and update properties.""" 278 | 279 | await self._async_status_request() 280 | 281 | # Notify sensors about new data. 282 | async_dispatcher_send(self._hass, self.signal_name) 283 | 284 | async def _async_status_request(self) -> None: 285 | """Request status and update properties.""" 286 | 287 | if self.exclude_data_run: 288 | for i in self.exclude_data_run: 289 | self.exclude_data_(i) 290 | 291 | login_error = await self._hass.async_add_executor_job(self.webuntis_login) 292 | 293 | if login_error: 294 | if str(login_error) == "bad credentials": 295 | self.issue = True 296 | ir.async_create_issue( 297 | self._hass, 298 | DOMAIN, 299 | "bad_credentials", 300 | is_fixable=True, 301 | severity=ir.IssueSeverity.ERROR, 302 | translation_key="bad_credentials", 303 | data={ 304 | "unique_id": self.unique_id, 305 | "config_data": dict(self._config.data), 306 | "entry_id": self._config.entry_id, 307 | }, 308 | ) 309 | return 310 | elif self.issue: 311 | _LOGGER.info("delete issue bad_credentials") 312 | ir.async_delete_issue(self._hass, DOMAIN, "bad_credentials") 313 | self.issue = False 314 | 315 | # _LOGGER.debug("updating data") 316 | 317 | try: 318 | self.schoolyears = await self._hass.async_add_executor_job( 319 | self.session.schoolyears 320 | ) 321 | self.current_schoolyear = await self._hass.async_add_executor_job( 322 | lambda: self.schoolyears.current 323 | ) 324 | 325 | if not self.current_schoolyear: 326 | # Login error, set all properties to unknown. 327 | self.next_class = None 328 | self.next_class_json = None 329 | self.next_lesson_to_wake_up = None 330 | self.calendar_events = [] 331 | self.calendar_homework = [] 332 | self.next_day_json = None 333 | self.day_json = None 334 | self.today = [None, None] 335 | 336 | # Inform user once about failed update if necessary. 337 | if not self._last_status_request_failed: 338 | _LOGGER.info( 339 | "No active schoolyear '%s@%s'", 340 | self.school, 341 | self.username, 342 | ) 343 | _LOGGER.info( 344 | "Found schoolyears for '%s@%s': %s (%s)", 345 | self.school, 346 | self.username, 347 | self.schoolyears, 348 | self.current_schoolyear, 349 | ) 350 | self._last_status_request_failed = True 351 | await self._hass.async_add_executor_job(self.webuntis_logout) 352 | return 353 | 354 | except OSError as error: 355 | _LOGGER.warning( 356 | "Request for schoolyears of '%s@%s' failed - OSError: %s", 357 | self.school, 358 | self.username, 359 | error, 360 | ) 361 | 362 | try: 363 | self.subjects = await self._hass.async_add_executor_job( 364 | self.session.subjects 365 | ) 366 | except OSError as error: 367 | self.subjects = [] 368 | 369 | _LOGGER.warning( 370 | "Updating the subjects of '%s@%s' failed - OSError: %s", 371 | self.school, 372 | self.username, 373 | error, 374 | ) 375 | 376 | try: 377 | self.student_id = await self._hass.async_add_executor_job( 378 | self.get_student_id 379 | ) 380 | except OSError as error: 381 | self.subjects = [] 382 | 383 | _LOGGER.warning( 384 | "Updating the student_id of '%s@%s' failed - OSError: %s", 385 | self.school, 386 | self.username, 387 | error, 388 | ) 389 | 390 | try: 391 | self.next_class = await self._hass.async_add_executor_job(self._next_class) 392 | except OSError as error: 393 | self.next_class = None 394 | 395 | _LOGGER.warning( 396 | "Updating the property next_class of '%s@%s' failed - OSError: %s", 397 | self.school, 398 | self.username, 399 | error, 400 | ) 401 | 402 | try: 403 | self.next_lesson_to_wake_up = await self._hass.async_add_executor_job( 404 | self._next_lesson_to_wake_up 405 | ) 406 | except OSError as error: 407 | self.next_lesson_to_wake_up = None 408 | 409 | _LOGGER.warning( 410 | "Updating the property next_lesson_to_wake_up of '%s@%s' failed - OSError: %s", 411 | self.school, 412 | self.username, 413 | error, 414 | ) 415 | 416 | try: 417 | self.next_day_json = await self._hass.async_add_executor_job( 418 | self._next_day_json 419 | ) 420 | except OSError as error: 421 | self.next_day_json = None 422 | 423 | _LOGGER.warning( 424 | "Updating the property next_day_json of '%s@%s' failed - OSError: %s", 425 | self.school, 426 | self.username, 427 | error, 428 | ) 429 | 430 | try: 431 | self.day_json = await self._hass.async_add_executor_job(self._day_json) 432 | except OSError as error: 433 | self.day_json = None 434 | 435 | _LOGGER.warning( 436 | "Updating the property day_json of '%s@%s' failed - OSError: %s", 437 | self.school, 438 | self.username, 439 | error, 440 | ) 441 | 442 | try: 443 | self.calendar_events = await self._hass.async_add_executor_job( 444 | self._get_events 445 | ) 446 | self.calendar_events = compact_list(self.calendar_events, "calendar") 447 | except OSError as error: 448 | self.calendar_events = [] 449 | 450 | _LOGGER.warning( 451 | "Updating the property calendar_events of '%s@%s' failed - OSError: %s", 452 | self.school, 453 | self.username, 454 | error, 455 | ) 456 | 457 | if self.timetable_source != "teacher": 458 | 459 | try: 460 | self.calendar_exams = await self._hass.async_add_executor_job( 461 | return_exam_events, self 462 | ) 463 | except OSError as error: 464 | self.calendar_exams = [] 465 | 466 | _LOGGER.warning( 467 | "Updating the property calendar_exams of '%s@%s' failed - OSError: %s", 468 | self.school, 469 | self.username, 470 | error, 471 | ) 472 | 473 | try: 474 | self.calendar_homework, param_list = ( 475 | await self._hass.async_add_executor_job( 476 | return_homework_events, self 477 | ) 478 | ) 479 | except OSError as error: 480 | self.calendar_homework = [] 481 | 482 | _LOGGER.warning( 483 | "Updating the property calendar_homework of '%s@%s' failed - OSError: %s", 484 | self.school, 485 | self.username, 486 | error, 487 | ) 488 | 489 | if self.calendar_homework_ids_setup: 490 | for event in param_list: 491 | if event["homework_id"] not in self.calendar_homework_ids: 492 | self.calendar_homework_ids.append(event["homework_id"]) 493 | 494 | self.homework_change_callback( 495 | "homework", {"homework_data": event} 496 | ) 497 | 498 | for service in self.notify_config.values(): 499 | if "homework" in service.get("options", []): 500 | 501 | data = { 502 | "data": service.get("data", {}), 503 | "target": service.get("target", {}), 504 | } 505 | 506 | dic, notify_data = get_notification_data_homework( 507 | event, service, self.title, self 508 | ) 509 | 510 | for key, value in notify_data.items(): 511 | data["data"][key] = value 512 | 513 | data.update(dic) 514 | 515 | await async_notify( 516 | self._hass, 517 | service_id=service["entity_id"], 518 | data=data, 519 | ) 520 | 521 | else: 522 | self.calendar_homework_ids_setup = True 523 | 524 | self.calendar_homework_ids = [] 525 | for event in param_list: 526 | self.calendar_homework_ids.append(event["homework_id"]) 527 | 528 | try: 529 | self.today = await self._hass.async_add_executor_job(self._today) 530 | except OSError as error: 531 | self.today = [None, None] 532 | 533 | _LOGGER.warning( 534 | "Updating the property today-sensor of '%s@%s' failed - OSError: %s", 535 | self.school, 536 | self.username, 537 | error, 538 | ) 539 | 540 | try: 541 | await self.update_notify() 542 | except OSError as error: 543 | _LOGGER.warning( 544 | "Updating lesson changes '%s@%s' failed - OSError: %s", 545 | self.school, 546 | self.username, 547 | error, 548 | ) 549 | 550 | await self._hass.async_add_executor_job(self.webuntis_logout) 551 | 552 | def webuntis_login(self): 553 | if self._loged_in: 554 | # Check if there is a session id. 555 | if "jsessionid" not in self.session.config: 556 | _LOGGER.debug("No session id found") 557 | self._loged_in = False 558 | else: 559 | # Check if session id is still valid. 560 | try: 561 | self.session.schoolyears() 562 | self.updating += 1 563 | return None 564 | except errors.NotLoggedInError: 565 | _LOGGER.debug("Session invalid") 566 | self._loged_in = False 567 | 568 | if not self._loged_in: 569 | # _LOGGER.debug("logging in") 570 | 571 | try: 572 | self.session.login() 573 | # _LOGGER.debug("Login successful") 574 | self._loged_in = True 575 | self.updating += 1 576 | 577 | return None 578 | except OSError as error: 579 | # Login error, set all properties to unknown. 580 | self.next_class = None 581 | self.next_class_json = None 582 | self.next_lesson_to_wake_up = None 583 | self.calendar_events = [] 584 | self.calendar_homework = [] 585 | self.calendar_exams = [] 586 | self.next_day_json = None 587 | self.day_json = None 588 | 589 | # Inform user once about failed update if necessary. 590 | if not self._last_status_request_failed: 591 | _LOGGER.warning( 592 | "Login to WebUntis '%s@%s' failed - OSError: %s", 593 | self.school, 594 | self.username, 595 | error, 596 | ) 597 | self._last_status_request_failed = True 598 | 599 | return error 600 | except Exception as error: 601 | _LOGGER.error( 602 | "Login to WebUntis '%s@%s' failed - ERROR: %s", 603 | self.school, 604 | self.username, 605 | error, 606 | ) 607 | self._last_status_request_failed = True 608 | return error 609 | 610 | def webuntis_logout(self): 611 | self.updating -= 1 612 | if not self.keep_logged_in and self.updating == 0: 613 | self.session.logout() 614 | # _LOGGER.debug("Logout successful") 615 | self._loged_in = False 616 | 617 | def get_student_id(self): 618 | if self.timetable_source == "student": 619 | student = self.session.get_student( 620 | self.timetable_source_id[1], self.timetable_source_id[0] 621 | ) 622 | return student.id 623 | 624 | def get_timetable(self, start, end: datetime, sort=False): 625 | """Get the timetable for the given time period""" 626 | timetable_object = None 627 | if self.timetable_source != "personal": 628 | timetable_object = get_timetable_object( 629 | self.timetable_source_id, self.timetable_source, self.session 630 | ) 631 | 632 | if not self.current_schoolyear: 633 | _LOGGER.warning( 634 | "No valid school year found for start date %s. Returning empty timetable.", 635 | start, 636 | ) 637 | return [] 638 | 639 | # Ensure start and end are within the school year boundaries 640 | if start < self.current_schoolyear.start.date(): 641 | start = self.current_schoolyear.start.date() 642 | if end > self.current_schoolyear.end.date(): 643 | end = self.current_schoolyear.end.date() 644 | 645 | result = [] 646 | if self.timetable_source == "personal": 647 | result = self.session.my_timetable(start=start, end=end) 648 | else: 649 | result = self.session.timetable_extended( 650 | start=start, end=end, **timetable_object 651 | ) 652 | 653 | if sort: 654 | result = sorted(result, key=lambda x: x.start) 655 | 656 | return result 657 | 658 | def _next_class(self): 659 | """returns time of next class.""" 660 | today = date.today() 661 | in_x_days = today + timedelta(days=DAYS_TO_FUTURE) 662 | 663 | table = self.get_timetable(start=today, end=in_x_days) 664 | 665 | now = datetime.now() 666 | 667 | lesson_list = [] 668 | for lesson in table: 669 | if lesson.start > now and self.check_lesson(lesson): 670 | lesson_list.append(lesson) 671 | 672 | lesson_list.sort(key=lambda e: (e.start)) 673 | 674 | try: 675 | lesson = lesson_list[0] 676 | except IndexError: 677 | if not self._no_lessons: 678 | _LOGGER.info( 679 | "Updating the property _next_class of '%s@%s' failed - No lesson in the next %s days", 680 | self.school, 681 | self.username, 682 | DAYS_TO_FUTURE, 683 | ) 684 | self._no_lessons = True 685 | return None 686 | self._no_lessons = False 687 | 688 | self.next_class_json = self.get_lesson_json(lesson) 689 | 690 | return lesson.start.astimezone() 691 | 692 | def _next_lesson_to_wake_up(self): 693 | """returns time of the next lesson to weak up.""" 694 | today = date.today() 695 | now = datetime.now() 696 | in_x_days = today + timedelta(days=DAYS_TO_FUTURE) 697 | 698 | table = self.get_timetable(start=today, end=in_x_days) 699 | 700 | time_list = [] 701 | for lesson in table: 702 | if self.check_lesson(lesson): 703 | time_list.append(lesson.start) 704 | 705 | day = now 706 | time_list_new = [] 707 | for time in sorted(time_list): 708 | if time < day: 709 | day = now.replace( 710 | hour=0, minute=0, second=0, microsecond=0 711 | ) + timedelta(days=1) 712 | continue 713 | else: 714 | time_list_new.append(time) 715 | 716 | try: 717 | return sorted(time_list_new)[0].astimezone() 718 | except IndexError: 719 | if not self._no_lessons: 720 | _LOGGER.info( 721 | "Updating the property _next_lesson_to_wake_up of '%s@%s' failed - No lesson in the next %s days", 722 | self.school, 723 | self.username, 724 | DAYS_TO_FUTURE, 725 | ) 726 | self._no_lessons = True 727 | return None 728 | self._no_lessons = False 729 | 730 | def _next_day_json(self): 731 | if self.next_lesson_to_wake_up is None: 732 | return None 733 | if not self.generate_json: 734 | return "JSON data is disabled - activate it in the options" 735 | day = self.next_lesson_to_wake_up.date() 736 | 737 | table = self.get_timetable(start=day, end=day, sort=True) 738 | 739 | lessons = [] 740 | for lesson in table: 741 | if self.check_lesson(lesson): 742 | lessons.append(str(self.get_lesson_json(lesson))) 743 | 744 | json_str = "[" + ", ".join(lessons) + "]" 745 | 746 | return json_str 747 | 748 | def _day_json(self): 749 | if self.next_lesson_to_wake_up is None: 750 | return None 751 | if not self.generate_json: 752 | return "JSON data is disabled - activate it in the options" 753 | day = date.today() 754 | 755 | table = self.get_timetable(start=day, end=day, sort=True) 756 | 757 | lessons = [] 758 | for lesson in table: 759 | if self.check_lesson(lesson): 760 | lessons.append(str(self.get_lesson_json(lesson))) 761 | 762 | json_str = "[" + ", ".join(lessons) + "]" 763 | 764 | return json_str 765 | 766 | def _get_events(self): 767 | today = date.today() 768 | in_x_days = today + timedelta(days=DAYS_TO_FUTURE) 769 | # Get the current school year for today 770 | if self.current_schoolyear: 771 | # Use the later of week_start and schoolyear.start.date() 772 | week_start = max( 773 | today - timedelta(days=today.weekday()), 774 | self.current_schoolyear.start.date(), 775 | ) 776 | else: 777 | week_start = today - timedelta(days=today.weekday()) 778 | table = self.get_timetable(start=week_start, end=in_x_days) 779 | 780 | event_list = [] 781 | self.event_list = [] 782 | 783 | for lesson in table: 784 | if self.check_lesson(lesson, ignor_cancelled=True): 785 | self.event_list.append(self.get_lesson_for_notify(lesson)) 786 | 787 | if self.check_lesson( 788 | lesson, ignor_cancelled=self.calendar_show_cancelled_lessons 789 | ): 790 | try: 791 | event = {"uid": uuid.uuid4()} 792 | 793 | prefix = "" 794 | if self.calendar_show_room_change and lesson.original_rooms: 795 | prefix = "Room change: " 796 | if lesson.code == "cancelled": 797 | prefix = "Cancelled: " 798 | if lesson.code == "irregular": 799 | prefix = "Irregular: " 800 | 801 | event["summary"] = prefix + get_lesson_name( 802 | server=self, lesson=lesson 803 | ) 804 | 805 | for key, value in self.calendar_replace_name.items(): 806 | event["summary"] = event["summary"].replace(key, value) 807 | 808 | event["start"] = lesson.start.astimezone() 809 | event["end"] = lesson.end.astimezone() 810 | if self.calendar_description == "json": 811 | event["description"] = self.get_lesson_json(lesson, True) 812 | elif self.calendar_description == "lesson_info": 813 | event["description"] = str(lesson.lstext or lesson.substText) 814 | elif self.calendar_description == "class_name_short": 815 | event["description"] = ", ".join(k.name for k in lesson.klassen) 816 | elif self.calendar_description == "class_ame_long": 817 | event["description"] = ", ".join( 818 | k.long_name for k in lesson.klassen 819 | ) 820 | 821 | # add Room as location 822 | try: 823 | if lesson.rooms and not self.calendar_room == "None": 824 | if self.calendar_room == "Room long name": 825 | event["location"] = lesson.rooms[0].long_name 826 | elif self.calendar_room == "Room short name": 827 | event["location"] = lesson.rooms[0].name 828 | elif self.calendar_room == "Room short-long name": 829 | event["location"] = ( 830 | f"{lesson.rooms[0].name} - {lesson.rooms[0].long_name}" 831 | ) 832 | except IndexError: 833 | # server does not return rooms 834 | pass 835 | 836 | event_list.append(CalendarEvent(**event)) 837 | except OSError as error: 838 | _LOGGER.warning( 839 | "Updating of a calendar_event of '%s@%s' failed - OSError: %s", 840 | self.school, 841 | self.username, 842 | error, 843 | ) 844 | 845 | return event_list 846 | 847 | def _get_events_in_timerange( 848 | self, start, end, filter_on, show_cancelled=True, compact_result=True 849 | ): 850 | table = self.get_timetable(start=start.date(), end=end.date()) 851 | 852 | events = [] 853 | 854 | for lesson in table: 855 | if (not filter_on or self.check_lesson(lesson, show_cancelled)) and ( 856 | show_cancelled or lesson.code != "cancelled" 857 | ): 858 | events.append( 859 | self.get_lesson_json(lesson, force=True, output_str=False) 860 | ) 861 | 862 | events = sorted(events, key=lambda x: x["start"]) 863 | 864 | if compact_result: 865 | events = compact_list(events, type="dict") 866 | 867 | return events 868 | 869 | def _count_lessons(self, start, end, filter_on, count_cancelled=False): 870 | table = self.get_timetable(start=start.date(), end=end.date()) 871 | 872 | result = {} 873 | 874 | for lesson in table: 875 | if ( 876 | lesson.subjects 877 | and (not filter_on or self.check_lesson(lesson, count_cancelled)) 878 | and (count_cancelled or lesson.code != "cancelled") 879 | ): 880 | if getattr(lesson, "subjects", None): 881 | name = lesson.subjects[0].long_name 882 | else: 883 | name = "None" 884 | 885 | if name in result: 886 | result[name] += 1 887 | else: 888 | result[name] = 1 889 | 890 | sorted_result = dict( 891 | sorted(result.items(), key=lambda item: item[1], reverse=True) 892 | ) 893 | 894 | return sorted_result 895 | 896 | def _get_schoolyears(self): 897 | """convert self.schoolyears to dict""" 898 | if not self.schoolyears: 899 | return None 900 | schoolyear_list = [] 901 | for schoolyear in self.schoolyears: 902 | schoolyear_list.append( 903 | { 904 | "id": schoolyear.id, 905 | "name": schoolyear.name, 906 | "start": schoolyear.start.date().isoformat(), 907 | "end": schoolyear.end.date().isoformat(), 908 | "current": schoolyear.is_current, 909 | } 910 | ) 911 | return {"schoolyears": schoolyear_list} 912 | 913 | def _today(self): 914 | today = date.today() 915 | 916 | table = self.get_timetable(start=today, end=today) 917 | 918 | time_list_start = [] 919 | for lesson in table: 920 | if self.check_lesson(lesson): 921 | time_list_start.append(lesson.start) 922 | 923 | time_list_end = [] 924 | for lesson in table: 925 | if self.check_lesson(lesson): 926 | time_list_end.append(lesson.end) 927 | 928 | try: 929 | return [ 930 | sorted(time_list_start)[0].astimezone(), 931 | sorted(time_list_end)[-1].astimezone(), 932 | ] 933 | except IndexError: 934 | return [None, None] 935 | 936 | def check_lesson(self, lesson, ignor_cancelled=False) -> bool: 937 | """Checks if a lesson is taking place""" 938 | if lesson.code == "cancelled" and not ignor_cancelled: 939 | return False 940 | 941 | if not self.invalid_subjects: 942 | try: 943 | if not lesson.subjects: 944 | return False 945 | except IndexError: 946 | return False 947 | 948 | for filter_description in self.filter_description: 949 | if ( 950 | filter_description in lesson.lstext # Vertretungstext 951 | or filter_description in lesson.substText # Informationen zur Stunde 952 | ): 953 | return False 954 | 955 | try: 956 | if self.filter_mode == "Blacklist": 957 | if any( 958 | subject.name in self.filter_subjects for subject in lesson.subjects 959 | ): 960 | return False 961 | if self.filter_mode == "Whitelist" and self.filter_subjects: 962 | if not any( 963 | subject.name in self.filter_subjects for subject in lesson.subjects 964 | ): 965 | return False 966 | except IndexError: 967 | pass 968 | 969 | return True 970 | 971 | # pylint: disable=bare-except 972 | def get_lesson_json(self, lesson, force=False, output_str=True) -> str: 973 | """returns info about lesson in json""" 974 | if (not self.generate_json) and (not force): 975 | return "JSON data is disabled - activate it in the options" 976 | dic = {} 977 | if output_str: 978 | dic["start"] = str(lesson.start.astimezone()) 979 | dic["end"] = str(lesson.end.astimezone()) 980 | else: 981 | dic["start"] = lesson.start.astimezone() 982 | dic["end"] = lesson.end.astimezone() 983 | try: 984 | dic["id"] = int(lesson.id) 985 | except: 986 | pass 987 | try: 988 | dic["code"] = str(lesson.code) 989 | except: 990 | pass 991 | try: 992 | dic["type"] = str(lesson.type) 993 | except: 994 | pass 995 | try: 996 | dic["subjects"] = [ 997 | { 998 | "name": str(subject.name), 999 | "long_name": str(subject.long_name), 1000 | "id": subject.id, 1001 | } 1002 | for subject in lesson.subjects 1003 | ] 1004 | except: 1005 | pass 1006 | 1007 | try: 1008 | dic["lstext"] = str(lesson.lstext) 1009 | except: 1010 | pass 1011 | try: 1012 | dic["substText"] = str(lesson.substText) 1013 | except: 1014 | pass 1015 | try: 1016 | dic["lsnumber"] = str(lesson.lsnumber) 1017 | except: 1018 | pass 1019 | 1020 | try: 1021 | dic["rooms"] = [ 1022 | {"name": str(room.name), "long_name": str(room.long_name)} 1023 | for room in lesson.rooms 1024 | ] 1025 | except: 1026 | pass 1027 | try: 1028 | dic["klassen"] = [ 1029 | {"name": str(klasse.name), "long_name": str(klasse.long_name)} 1030 | for klasse in lesson.klassen 1031 | ] 1032 | except: 1033 | pass 1034 | try: 1035 | dic["original_rooms"] = [ 1036 | {"name": str(room.name), "long_name": str(room.long_name)} 1037 | for room in lesson.original_rooms 1038 | ] 1039 | except: 1040 | pass 1041 | 1042 | if "teachers" not in self.exclude_data: 1043 | try: 1044 | dic["teachers"] = [ 1045 | {"name": str(teacher.name), "long_name": str(teacher.long_name)} 1046 | for teacher in lesson.teachers 1047 | ] 1048 | except (OSError, errors.RemoteError) as error: 1049 | if "no right for getTeachers()" in str(error): 1050 | self.exclude_data_run.append("teachers") 1051 | self.exclude_data.append("teachers") 1052 | 1053 | except: 1054 | pass 1055 | 1056 | try: 1057 | dic["original_teachers"] = [ 1058 | {"name": str(teacher.name), "long_name": str(teacher.long_name)} 1059 | for teacher in lesson.original_teachers 1060 | ] 1061 | except: 1062 | pass 1063 | 1064 | dic["name"] = get_lesson_name(self, lesson) 1065 | 1066 | if output_str: 1067 | return str(json.dumps(dic)) 1068 | return dic 1069 | 1070 | def get_lesson_for_notify(self, lesson) -> str: 1071 | """returns info about for notify test""" 1072 | dic = {} 1073 | 1074 | dic["start"] = lesson.start.astimezone() 1075 | dic["end"] = lesson.end.astimezone() 1076 | 1077 | dic["subject_id"] = "None" # Defaultwert setzen 1078 | try: 1079 | subjects = getattr(lesson, "subjects", []) 1080 | if subjects: # nur wenn nicht leer 1081 | dic["subject_id"] = subjects[0].id 1082 | except: 1083 | pass 1084 | 1085 | dic["id"] = int(lesson.id) 1086 | dic["lsnumber"] = int(lesson.lsnumber) 1087 | 1088 | try: 1089 | dic["code"] = str(lesson.code) 1090 | except: 1091 | pass 1092 | try: 1093 | dic["type"] = str(lesson.type) 1094 | except: 1095 | pass 1096 | try: 1097 | dic["subjects"] = [ 1098 | {"name": str(subject.name), "long_name": str(subject.long_name)} 1099 | for subject in lesson.subjects 1100 | ] 1101 | except: 1102 | pass 1103 | 1104 | try: 1105 | dic["rooms"] = [ 1106 | {"name": str(room.name), "long_name": str(room.long_name)} 1107 | for room in lesson.rooms 1108 | ] 1109 | except: 1110 | pass 1111 | 1112 | try: 1113 | dic["original_rooms"] = [ 1114 | {"name": str(room.name), "long_name": str(room.long_name)} 1115 | for room in lesson.original_rooms 1116 | ] 1117 | except: 1118 | pass 1119 | 1120 | if "teachers" not in self.exclude_data: 1121 | try: 1122 | dic["teachers"] = [ 1123 | {"name": str(teacher.name), "long_name": str(teacher.long_name)} 1124 | for teacher in lesson.teachers 1125 | ] 1126 | except (OSError, errors.RemoteError) as error: 1127 | if "no right for getTeachers()" in str(error): 1128 | self.exclude_data_run.append("teachers") 1129 | self.exclude_data.append("teachers") 1130 | 1131 | except: 1132 | pass 1133 | 1134 | return dic 1135 | 1136 | def exclude_data_(self, data): 1137 | """adds data to exclude_data list""" 1138 | 1139 | self.exclude_data_run.remove(data) 1140 | 1141 | new_options = {**self._config.options} 1142 | new_options["exclude_data"] = [*new_options["exclude_data"], data] 1143 | 1144 | self._hass.config_entries.async_update_entry(self._config, options=new_options) 1145 | self.exclude_data.append(data) 1146 | 1147 | _LOGGER.info( 1148 | "No rights for %s, is now on blacklist '%s@%s'", 1149 | data, 1150 | self.school, 1151 | self.username, 1152 | ) 1153 | 1154 | async def update_notify(self): 1155 | """Update data and notify""" 1156 | 1157 | updated_items = [] 1158 | 1159 | if not self.event_list_old: 1160 | self.event_list_old = self.event_list 1161 | return 1162 | 1163 | blacklist = get_notify_blacklist(self.event_list) 1164 | 1165 | updated_items = compare_list( 1166 | self.event_list_old, self.event_list, blacklist=blacklist 1167 | ) 1168 | 1169 | if updated_items: 1170 | _LOGGER.debug("Timetable has chaged!") 1171 | _LOGGER.debug(updated_items) 1172 | 1173 | for change, lesson, lesson_old in updated_items: 1174 | 1175 | lesson_old["name"] = get_lesson_name(self, lesson_old) 1176 | lesson["name"] = get_lesson_name(self, lesson) 1177 | 1178 | self.lesson_change_callback( 1179 | change, 1180 | {"old_lesson": lesson_old, "new_lesson": lesson}, 1181 | ) 1182 | 1183 | updated_items = compact_list(updated_items, "notify") 1184 | 1185 | for service in self.notify_config.values(): 1186 | 1187 | for change, lesson, lesson_old in updated_items: 1188 | if change in service.get("options", []): 1189 | 1190 | data = { 1191 | "data": service.get("data", {}), 1192 | "target": service.get("target", {}), 1193 | } 1194 | 1195 | changes = get_changes(change, lesson, lesson_old, server=self) 1196 | 1197 | dic, notify_data = get_notification_data( 1198 | changes, service, self.title 1199 | ) 1200 | 1201 | for key, value in notify_data.items(): 1202 | data["data"][key] = value 1203 | 1204 | data.update(dic) 1205 | 1206 | await async_notify( 1207 | self._hass, 1208 | service_id=service["entity_id"], 1209 | data=data, 1210 | ) 1211 | 1212 | _LOGGER.info(updated_items) 1213 | 1214 | self.event_list_old = self.event_list 1215 | 1216 | 1217 | class WebUntisEntity(Entity): 1218 | """Representation of a Web Untis base entity.""" 1219 | 1220 | _attr_has_entity_name = True 1221 | _attr_should_poll = False 1222 | 1223 | def __init__( 1224 | self, 1225 | server: WebUntis, 1226 | name: str, 1227 | icon: str, 1228 | device_class: str | None, 1229 | ) -> None: 1230 | """Initialize base entity.""" 1231 | self._server = server 1232 | self._attr_icon = icon 1233 | self._attr_translation_key = name 1234 | self._attr_unique_id = f"{self._server.unique_id}_{name}" 1235 | self._attr_device_info = DeviceInfo( 1236 | identifiers={(DOMAIN, self._server.unique_id)}, 1237 | manufacturer="Web Untis", 1238 | model=f"{self._server.school}@{self._server.username}", 1239 | name=self._server.username, 1240 | ) 1241 | self._attr_device_class = device_class 1242 | self._extra_state_attributes = None 1243 | self._disconnect_dispatcher: CALLBACK_TYPE | None = None 1244 | 1245 | async def async_added_to_hass(self) -> None: 1246 | """Connect dispatcher to signal from server.""" 1247 | self._disconnect_dispatcher = async_dispatcher_connect( 1248 | self.hass, self._server.signal_name, self._update_callback 1249 | ) 1250 | 1251 | async def async_will_remove_from_hass(self) -> None: 1252 | """Disconnect dispatcher before removal.""" 1253 | if self._disconnect_dispatcher: 1254 | self._disconnect_dispatcher() 1255 | 1256 | @callback 1257 | def _update_callback(self) -> None: 1258 | """Triggers update of properties after receiving signal from server.""" 1259 | self.async_schedule_update_ha_state(force_refresh=True) 1260 | --------------------------------------------------------------------------------