diff --git a/custom_components/homeassistantedupage/__init__.py b/custom_components/homeassistantedupage/__init__.py index 2d44bdf..7cd9b51 100644 --- a/custom_components/homeassistantedupage/__init__.py +++ b/custom_components/homeassistantedupage/__init__.py @@ -1,37 +1,43 @@ import logging import asyncio -from datetime import timedelta -import datetime +from datetime import datetime, timedelta from edupage_api.exceptions import BadCredentialsException, CaptchaException from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.const import Platform from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .homeassistant_edupage import Edupage +from edupage_api.classes import Class +from edupage_api.people import EduTeacher +from edupage_api.people import Gender +from edupage_api.classrooms import Classroom from .const import DOMAIN _LOGGER = logging.getLogger("custom_components.homeassistant_edupage") async def async_setup(hass: HomeAssistant, config: dict) -> bool: """only ConfigEntry supported, no configuration.yaml yet""" - _LOGGER.info("INIT called async_setup") + _LOGGER.debug("INIT called async_setup") return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """initializin EduPage-integration and validate API-login""" - _LOGGER.info("INIT called async_setup_entry") + _LOGGER.debug("INIT called async_setup_entry") + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} username = entry.data["username"] password = entry.data["password"] subdomain = entry.data["subdomain"] + student_id = entry.data["student_id"] edupage = Edupage(hass) - unique_id_sensorGrade = f"edupage_{username}_gradesensor" + coordinator = None try: login_success = await hass.async_add_executor_job( edupage.login, username, password, subdomain ) - _LOGGER.info("INIT login_success") + _LOGGER.debug("INIT login_success") except BadCredentialsException as e: _LOGGER.error("INIT login failed: bad credentials. %s", e) @@ -48,67 +54,89 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: fetch_lock = asyncio.Lock() async def fetch_data(): - """Function to fetch grade and timetable data.""" - _LOGGER.info("INIT called fetch_data") + """Function to fetch timetable data for the selected student.""" + _LOGGER.debug("INIT called fetch_data") + async with fetch_lock: try: - # request classes - classes_data = await edupage.get_classes() -# _LOGGER.info("INIT classes count: " + str(len(classes_data))) - - # request grades - grades_data = await edupage.get_grades() -# _LOGGER.info("INIT grade count: " + str(len(grades_data))) - - # request user_id - userid = await edupage.get_user_id() -# _LOGGER.info("INIT user_id: "+str(userid)) - - # request all possible subjects - subjects_data = await edupage.get_subjects() -# _LOGGER.info("INIT subject count: " + str(len(subjects_data))) - - # request all possible students - students_data = await edupage.get_students() -# _LOGGER.info("INIT students count: " + str(len(students_data))) - - return { - "grades": grades_data, -# "timetable": timetable_data, - "user_id": userid, - "subjects": subjects_data + await edupage.login(username, password, subdomain) + + students = await edupage.get_students() + student = next((s for s in students if s.person_id == student_id), None) + _LOGGER.debug("INIT Student: %s", student) + + if not student: + _LOGGER.error("INIT No matching student found with ID: %s", student_id) + return {"timetable": {}} + + _LOGGER.debug("INIT Found EduStudent: %s", vars(student)) + + grades = await edupage.get_grades() + subjects = await edupage.get_subjects() + + timetable_data = {} + today = datetime.now().date() + for offset in range(14): + current_date = today + timedelta(days=offset) + timetable = await edupage.get_timetable(student, current_date) + if timetable: + _LOGGER.debug(f"Timetable for {current_date}: {timetable}") + timetable_data[current_date] = timetable + else: + _LOGGER.warning(f"INIT No timetable found for {current_date}") + + return_data = { + "student": {"id": student.person_id, "name": student.name}, + "grades": grades, + "subjects": subjects, + "timetable": timetable_data, } + _LOGGER.debug(f"INIT Coordinator fetch_data returning: {return_data}") + return return_data except Exception as e: - _LOGGER.error("INIT error fetching data: %s", e) - return False - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name="EduPage Data", - update_method=fetch_data, - update_interval=timedelta(minutes=5), - ) - - # First data fetch + _LOGGER.error("INIT Failed to fetch timetable: %s", e) + return {"timetable": {}} + + try: + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="Edupage", + update_method=fetch_data, + update_interval=timedelta(minutes=30), + ) + hass.data[DOMAIN][entry.entry_id] = coordinator + + await coordinator.async_config_entry_first_refresh() + _LOGGER.debug("INIT Coordinator successfully initialized") + + except Exception as e: + _LOGGER.error(f"INIT Error during async_setup_entry: {e}") + + if entry.entry_id in hass.data[DOMAIN]: + del hass.data[DOMAIN][entry.entry_id] + + return False + await asyncio.sleep(1) await coordinator.async_config_entry_first_refresh() + _LOGGER.debug(f"INIT Coordinator first fetch!") - # Save coordinator hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinator - # Forward platforms - await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) + await hass.config_entries.async_forward_entry_setups(entry, ["calendar", "sensor"]) + _LOGGER.debug(f"INIT forwarded") + _LOGGER.debug(f"INIT Coordinator data: {coordinator.data}") return True - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload ConfigEntry.""" - _LOGGER.info("INIT called async_unload_entry") - unload_ok = await hass.config_entries.async_forward_entry_unload(entry, Platform.SENSOR) + _LOGGER.debug("INIT called async_unload_entry") + unload_ok = await hass.config_entries.async_forward_entry_unload(entry, ["calendar", "sensor"]) if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + diff --git a/custom_components/homeassistantedupage/calendar.py b/custom_components/homeassistantedupage/calendar.py index 3dd51b6..1699e8f 100644 --- a/custom_components/homeassistantedupage/calendar.py +++ b/custom_components/homeassistantedupage/calendar.py @@ -1,48 +1,130 @@ -import voluptuous as vol import logging -from datetime import datetime, timedelta -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from datetime import datetime, timedelta, timezone +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.core import HomeAssistant -from .homeassistant_edupage import Edupage +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import DOMAIN +from collections import defaultdict +from zoneinfo import ZoneInfo + +_LOGGER = logging.getLogger("custom_components.homeassistant_edupage") +_LOGGER.debug("CALENDAR Edupage calendar.py is being loaded") + +async def async_setup_entry(hass, entry, async_add_entities: AddEntitiesCallback) -> None: + """Set up Edupage calendar entities.""" + _LOGGER.debug("CALENDAR called async_setup_entry") + + coordinator = hass.data[DOMAIN][entry.entry_id] -from homeassistant.components.calendar import CalendarEntity + edupage_calendar = EdupageCalendar(coordinator, entry.data) -_LOGGER = logging.getLogger(__name__) + async_add_entities([edupage_calendar]) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback): + _LOGGER.debug("CALENDAR async_setup_entry finished.") - username = entry.data["username"] - password = entry.data["password"] - subdomain = entry.data["subdomain"] +async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + _LOGGER.debug("CALENDAR added to hass") - edupage = Edupage(hass) - unique_id = f"edupage_{username}_calendar" - await hass.async_add_executor_job(edupage.login, username, password, subdomain) + if self.coordinator: + self.async_on_remove( + self.coordinator.async_add_listener( + self._handle_coordinator_update, None + ) + ) - async def async_update_data(): +class EdupageCalendar(CoordinatorEntity, CalendarEntity): + """Representation of an Edupage calendar entity.""" - today = datetime.now().date() - try: - return await edupage.get_timetable(today) - except Exception as e: - _LOGGER.error(f"error updating data: {e}") - raise UpdateFailed(F"error updating data: {e}") + def __init__(self, coordinator, data): + super().__init__(coordinator) + self._data = data + self._events = [] + self._attr_name = "Edupage Calendar" + _LOGGER.debug(f"CALENDAR Initialized EdupageCalendar with data: {data}") - async_add_entities([TimetableCalendar(edupage, unique_id)], True) + @property + def unique_id(self): + """Return a unique ID for this calendar.""" + student_id = self.coordinator.data.get("student", {}).get("id", "unknown") + return f"edupage_calendar_{student_id}" -class TimetableCalendar(CalendarEntity): - def __init__(self, edupage, unique_id): - self.edupage = edupage - self._attr_unique_id = unique_id - @property def name(self): - """return name of calendar""" - return self._name - - async def async_get_events(self, today: datetime): - timetable = self.get_timetable(today) - # Konvertieren Sie 'timetable' in eine Liste von Ereignissen, die von dieser Methode zurückgegeben werden - return timetable #dict + """Return the name of the calendar.""" + student_name = self.coordinator.data.get("student", {}).get("name", "Unknown Student") + return f"Edupage - {student_name}" + + @property + def extra_state_attributes(self): + """Return the extra state attributes.""" + return { + "unique_id": self.unique_id, + "other_info": "debug info" + } + + @property + def available(self) -> bool: + """Return True if the calendar is available.""" + _LOGGER.debug("CALENDAR Checking availability of Edupage Calendar") + return True + + @property + def event(self): + """Return the next upcoming event or None if no event exists.""" + local_tz = ZoneInfo(self.hass.config.time_zone) + now = datetime.now(local_tz) + return CalendarEvent( + start=now + timedelta(hours=1), + end=now + timedelta(hours=2), + summary="Next Dummy Event", + description="A placeholder event for debugging." + ) + + async def async_get_events(self, hass, start_date: datetime, end_date: datetime): + """Return events in a specific date range.""" + local_tz = ZoneInfo(self.hass.config.time_zone) + events = [] + + _LOGGER.debug(f"CALENDAR Fetching events between {start_date} and {end_date}") + timetable = self.coordinator.data.get("timetable", {}) + _LOGGER.debug(f"CALENDAR Coordinator data: {self.coordinator.data}") + _LOGGER.debug(f"CALENDAR Fetched timetable data: {timetable}") + + if not timetable: + _LOGGER.warning("CALENDAR Timetable data is missing.") + return events + + current_date = start_date.date() + while current_date <= end_date.date(): + day_timetable = timetable.get(current_date) + if day_timetable: + for lesson in day_timetable: + + _LOGGER.debug(f"CALENDAR Lesson attributes: {vars(lesson)}") + + room = "Unknown" + if lesson.classes and lesson.classes[0].homeroom: + room = lesson.classes[0].homeroom.name + + teacher_names = [teacher.name for teacher in lesson.teachers] + teachers = ", ".join(teacher_names) if teacher_names else "Unknown Teacher" + + start_time = datetime.combine(current_date, lesson.start_time).astimezone(local_tz) + end_time = datetime.combine(current_date, lesson.end_time).astimezone(local_tz) + events.append( + CalendarEvent( + start=start_time, + end=end_time, + summary=lesson.subject.name if lesson.subject else "Unknown Subject", + description=f"Room: {room}\nTeacher(s): {teachers}" + ) + ) + current_date += timedelta(days=1) + + _LOGGER.debug(f"CALENDAR Fetched {len(events)} events from {start_date} to {end_date}") + return events diff --git a/custom_components/homeassistantedupage/config_flow.py b/custom_components/homeassistantedupage/config_flow.py index 787d3cc..37a4adc 100644 --- a/custom_components/homeassistantedupage/config_flow.py +++ b/custom_components/homeassistantedupage/config_flow.py @@ -2,16 +2,15 @@ import voluptuous as vol from edupage_api import Edupage from homeassistant import config_entries -from homeassistant.core import HomeAssistant +from homeassistant.core import callback from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -CONF_SUBDOMAIN = "subdomain" +CONF_SUBDOMAIN = "subdomain" # Lokal definiert -class EdupageConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for edupage_api.""" +class EdupageConfigFlow(config_entries.ConfigFlow, domain="homeassistantedupage"): + """Handle a config flow for Edupage.""" VERSION = 1 @@ -20,15 +19,75 @@ async def async_step_user(self, user_input=None): errors = {} if user_input is not None: + _LOGGER.info("User input received: %s", user_input) + api = Edupage() - return self.async_create_entry(title="Edupage", data=user_input) + try: + # Login ausführen + _LOGGER.debug("Starting login process") + await self.hass.async_add_executor_job( + api.login, + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + user_input[CONF_SUBDOMAIN] + ) + _LOGGER.debug("Login successful") + # Schülerliste abrufen + students = await self.hass.async_add_executor_job(api.get_students) + _LOGGER.debug("Students retrieved: %s", students) + + if not students: + errors["base"] = "no_students_found" + else: + # Speichere Benutzer-Eingaben + self.user_data = user_input + self.students = {student.person_id: student.name for student in students} + + # Weiter zur Schülerauswahl + return await self.async_step_select_student() + + except Exception as e: + _LOGGER.error("Exception during API call: %s", e) + errors["base"] = "cannot_connect" + + # Formular anzeigen data_schema = vol.Schema({ - vol.Required(CONF_USERNAME, default="username"): str, - vol.Required(CONF_PASSWORD, default="password"): str, - vol.Required(CONF_SUBDOMAIN, default="subdomain only"): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_SUBDOMAIN): str, }) return self.async_show_form( step_id="user", data_schema=data_schema, errors=errors ) + + + async def async_step_select_student(self, user_input=None): + """Handle the selection of a student.""" + errors = {} + + if user_input is not None: + student_id = user_input.get("student") + _LOGGER.info("Selected student ID: %s", student_id) + + # Erstelle den Config-Entry mit allen Daten + return self.async_create_entry( + title=f"Edupage ({self.students[student_id]})", + data={ + **self.user_data, # Login-Daten hinzufügen + "student_id": student_id, + "student_name": self.students[student_id], + }, + ) + + # Dropdown-Formular für Schülerauswahl + student_schema = vol.Schema({ + vol.Required("student"): vol.In(self.students), + }) + + return self.async_show_form( + step_id="select_student", + data_schema=student_schema, + errors=errors + ) diff --git a/custom_components/homeassistantedupage/homeassistant-edupage b/custom_components/homeassistantedupage/homeassistant-edupage deleted file mode 120000 index 187fd7c..0000000 --- a/custom_components/homeassistantedupage/homeassistant-edupage +++ /dev/null @@ -1 +0,0 @@ -./homeassistant-edupage \ No newline at end of file diff --git a/custom_components/homeassistantedupage/homeassistant_edupage.py b/custom_components/homeassistantedupage/homeassistant_edupage.py index 305dd1d..74057ce 100644 --- a/custom_components/homeassistantedupage/homeassistant_edupage.py +++ b/custom_components/homeassistantedupage/homeassistant_edupage.py @@ -1,7 +1,19 @@ import logging +import asyncio + from edupage_api import Edupage as APIEdupage +from edupage_api.classes import Class +from edupage_api.people import EduTeacher +from edupage_api.people import Gender +from edupage_api.classrooms import Classroom +from zoneinfo import ZoneInfo +from edupage_api.exceptions import BadCredentialsException, CaptchaException + + from datetime import datetime +from datetime import date from homeassistant.helpers.update_coordinator import UpdateFailed +from concurrent.futures import ThreadPoolExecutor _LOGGER = logging.getLogger(__name__) @@ -10,9 +22,23 @@ def __init__(self,hass): self.hass = hass self.api = APIEdupage() - def login(self, username, password, subdomain): + async def login(self, username: str, password: str, subdomain: str): + """Perform login asynchronously.""" + try: + result = await asyncio.to_thread(self.api.login, username, password, subdomain) + _LOGGER.debug(f"EDUPAGE Login successful, result: {result}") + return result + except BadCredentialsException as e: + _LOGGER.error("EDUPAGE login failed: bad credentials. %s", e) + return False + + except CaptchaException as e: + _LOGGER.error("EDUPAGE login failed: CAPTCHA needed. %s", e) + return False - return self.api.login(username, password, subdomain) + except Exception as e: + _LOGGER.error("EDUPAGE unexpected login error: %s", e) + return False async def get_classes(self): @@ -54,6 +80,34 @@ async def get_user_id(self): except Exception as e: raise UpdateFailed(F"EDUPAGE error updating get_user_id() data from API: {e}") + async def get_classrooms(self): + + try: + all_classrooms = await self.hass.async_add_executor_job(self.api.get_classrooms) + return all_classrooms + except Exception as e: + raise UpdateFailed(F"EDUPAGE error updating get_classrooms data from API: {e}") + + async def get_teachers(self): + + try: + all_teachers = await self.hass.async_add_executor_job(self.api.get_teachers) + return all_teachers + except Exception as e: + raise UpdateFailed(F"EDUPAGE error updating get_teachers data from API: {e}") + + async def get_timetable(self, EduStudent, date): + try: + timetable_data = await self.hass.async_add_executor_job(self.api.get_timetable, EduStudent, date) + if timetable_data is None: + _LOGGER.debug("EDUPAGE timetable is None") + else: + _LOGGER.debug(f"EDUPAGE timetable_data for {date}: {timetable_data}") + return timetable_data + except Exception as e: + _LOGGER.error(f"EDUPAGE error updating get_timetable() data for {date}: {e}") + raise UpdateFailed(f"EDUPAGE error updating get_timetable() data for {date}: {e}") + async def async_update(self): pass diff --git a/custom_components/homeassistantedupage/sensor.py b/custom_components/homeassistantedupage/sensor.py index 5cf3f19..47f7062 100644 --- a/custom_components/homeassistantedupage/sensor.py +++ b/custom_components/homeassistantedupage/sensor.py @@ -9,32 +9,31 @@ _LOGGER = logging.getLogger("custom_components.homeassistant_edupage") - def group_grades_by_subject(grades): - """Group grades based on subject_id.""" + """grouping grades based on subject_id.""" grouped = defaultdict(list) for grade in grades: grouped[grade.subject_id].append(grade) return grouped - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None: - """Set up EduPage sensors based on subjects and their grades.""" - _LOGGER.info("SENSOR called async_setup_entry") + """Set up EduPage sensors for each student and their grades.""" + _LOGGER.debug("SENSOR called async_setup_entry") coordinator = hass.data[DOMAIN][entry.entry_id] + student = coordinator.data.get("student", {}) subjects = coordinator.data.get("subjects", []) grades = coordinator.data.get("grades", []) - # Group grades based on subject_id grades_by_subject = group_grades_by_subject(grades) sensors = [] for subject in subjects: - # Get grades per subject based on subject_id subject_grades = grades_by_subject.get(subject.subject_id, []) sensor = EduPageSubjectSensor( coordinator, + student.get("id"), + student.get("name"), subject.name, subject_grades ) @@ -44,19 +43,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e class EduPageSubjectSensor(CoordinatorEntity, SensorEntity): - """Subject sensor entity.""" + """Subject sensor entity for a specific student.""" - def __init__(self, coordinator, subject_name, grades=None): + def __init__(self, coordinator, student_id, student_name, subject_name, grades=None): """Initialize the sensor.""" super().__init__(coordinator) + self._student_id = student_id + self._student_name = student_name self._subject_name = subject_name - self._grades = grades or [] - self._attr_name = f"EduPage subject - {subject_name}" - self._attr_unique_id = f"edupage_grades_{subject_name.lower().replace(' ', '_')}" + self._grades = grades or [] + self._attr_name = f"{student_name} - {subject_name}" + self._attr_unique_id = f"edupage_grades_{student_id}_{subject_name.lower().replace(' ', '_')}" @property def state(self): - """Return grade count.""" + """Return the grade count.""" return len(self._grades) @property @@ -65,17 +66,12 @@ def extra_state_attributes(self): if not self._grades: return {"info": "no grades yet"} - attributes = {} + attributes = {"student": self._student_name} for i, grade in enumerate(self._grades): attributes[f"grade_{i+1}_title"] = grade.title attributes[f"grade_{i+1}_grade_n"] = grade.grade_n attributes[f"grade_{i+1}_date"] = grade.date.strftime("%Y-%m-%d %H:%M:%S") - - # Check if teacher exists before accessing name - if grade.teacher: - attributes[f"grade_{i+1}_teacher"] = grade.teacher.name - else: - attributes[f"grade_{i+1}_teacher"] = None # Optional: Log warning - _LOGGER.warning(f"Teacher information missing for grade {i+1} in subject {self._subject_name}.") - + attributes[f"grade_{i+1}_teacher"] = grade.teacher.name return attributes + +