diff --git a/.env b/.env index 66106a0..a1b285e 100644 --- a/.env +++ b/.env @@ -7,3 +7,7 @@ APP_SECRET=akwhdaeuifzuieshuikjfjk ###> application ### REQUIRED_API_KEY=74857389798572903480209489024 ###< application ### + +HOME_ASSISTANT_URL=https://ha.strolap.com +HOME_ASSISTANT_TOKEN= +HOME_ASSISTANT_VERIFY_SSL=true diff --git a/config/secrets/dev/dev.HOME_ASSISTANT_TOKEN.ad10d3.php b/config/secrets/dev/dev.HOME_ASSISTANT_TOKEN.ad10d3.php new file mode 100644 index 0000000..af67e7a --- /dev/null +++ b/config/secrets/dev/dev.HOME_ASSISTANT_TOKEN.ad10d3.php @@ -0,0 +1,3 @@ + null, 'ABSENCE_API_KEY' => null, + 'HOME_ASSISTANT_TOKEN' => null, ]; diff --git a/src/Commands/ShowEntityStateHistoryCommand.php b/src/Commands/ShowEntityStateHistoryCommand.php new file mode 100644 index 0000000..402d3f7 --- /dev/null +++ b/src/Commands/ShowEntityStateHistoryCommand.php @@ -0,0 +1,57 @@ +addArgument('entity-id', InputArgument::REQUIRED, 'The entity ID to show history for'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $entityId = $input->getArgument('entity-id'); + + $history = $this->client->getEntityStateHistory($entityId, new DateTimeImmutable('-10 days'), new DateTimeImmutable('+10 days')); + + if (empty($history)) { + $io->error('No history found for entity ' . $entityId); + return Command::FAILURE; + } + + $io->title('State History for ' . $entityId); + + foreach ($history[0] as $state) { + $io->writeln(sprintf( + '%s: %s', + $state['last_changed'], + $state['state'] + )); + } + + return Command::SUCCESS; + } +} diff --git a/src/Core/Services/Home/HomeEntityInterface.php b/src/Core/Services/Home/HomeEntityInterface.php new file mode 100644 index 0000000..0f2873f --- /dev/null +++ b/src/Core/Services/Home/HomeEntityInterface.php @@ -0,0 +1,22 @@ +state, ['on', 'home', 'open', 'unlocked', 'active'], true); + } + + public function isOff(): bool + { + return in_array($this->state, ['off', 'away', 'closed', 'locked', 'inactive'], true); + } + + public function getDomain(): string + { + $parts = explode('.', $this->entityId, 2); + + return $parts[0]; + } + + public function getName(): string + { + return $this->attributes['friendly_name'] ?? $this->entityId; + } +} diff --git a/src/HomeAssistant/HomeAssistantClient.php b/src/HomeAssistant/HomeAssistantClient.php new file mode 100644 index 0000000..145f23d --- /dev/null +++ b/src/HomeAssistant/HomeAssistantClient.php @@ -0,0 +1,107 @@ +request('GET', '/api/states'); + } + + public function getServices(): array + { + return $this->request('GET', '/api/services'); + } + + public function getEntityState(string $entityId): array + { + return $this->request('GET', "/api/states/{$entityId}"); + } + + public function getEntityStateHistory(string $entityId, DateTimeInterface $startDate, DateTimeInterface $endDate): array + { + $queryParams = http_build_query([ + 'end_time' => $endDate->format('Y-m-d\TH:i:s\Z'), + 'filter_entity_id' => $entityId, + ]); + + return $this->request('GET', "/api/history/period/{$startDate->format('Y-m-d\TH:i:s\Z')}?{$queryParams}"); + } + + public function callService(string $domain, string $service, array $data = []): array + { + return $this->request('POST', "/api/services/{$domain}/{$service}", $data); + } + + public function turnOn(string $entityId): array + { + $domain = explode('.', $entityId)[0]; + + return $this->callService($domain, 'turn_on', ['entity_id' => $entityId]); + } + + public function turnOff(string $entityId): array + { + $domain = explode('.', $entityId)[0]; + + return $this->callService($domain, 'turn_off', ['entity_id' => $entityId]); + } + + private function request(string $method, string $endpoint, array $data = []): array + { + $options = [ + 'headers' => [ + 'Authorization' => "Bearer {$this->token}", + 'Content-Type' => 'application/json', + ], + 'verify_peer' => $this->verifySSL, + 'verify_host' => $this->verifySSL, + ]; + + if (!empty($data)) { + $options['json'] = $data; + } + + $response = $this->httpClient->request( + $method, + $this->baseUrl.$endpoint, + $options, + ); + + return $this->handleResponse($response); + } + + private function handleResponse(ResponseInterface $response): array + { + $statusCode = $response->getStatusCode(); + + if ($statusCode >= 200 && $statusCode < 300) { + return $response->toArray(); + } + + $content = $response->getContent(false); + + throw new HomeAssistantException($content, $statusCode); + } +} diff --git a/src/HomeAssistant/HomeAssistantEntity.php b/src/HomeAssistant/HomeAssistantEntity.php new file mode 100644 index 0000000..10858b2 --- /dev/null +++ b/src/HomeAssistant/HomeAssistantEntity.php @@ -0,0 +1,67 @@ +entityState->entityId; + } + + public function getState(): mixed + { + return $this->entityState->state; + } + + public function getName(): string + { + return $this->entityState->getName(); + } + + public function getType(): HomeEntityType + { + $domain = $this->entityState->getDomain(); + + return match ($domain) { + 'light' => HomeEntityType::LIGHT, + 'switch' => HomeEntityType::SWITCH, + 'sensor' => HomeEntityType::SENSOR, + 'binary_sensor' => HomeEntityType::BINARY_SENSOR, + 'climate' => HomeEntityType::CLIMATE, + 'media_player' => HomeEntityType::MEDIA_PLAYER, + 'scene' => HomeEntityType::SCENE, + 'script' => HomeEntityType::SCRIPT, + 'automation' => HomeEntityType::AUTOMATION, + 'camera' => HomeEntityType::CAMERA, + 'cover' => HomeEntityType::COVER, + 'fan' => HomeEntityType::FAN, + 'lock' => HomeEntityType::LOCK, + 'vacuum' => HomeEntityType::VACUUM, + 'weather' => HomeEntityType::WEATHER, + 'zone' => HomeEntityType::ZONE, + default => throw new HomeAssistantException("Unknown entity type: {$domain}") + }; + } + + public function getLastChanged(): DateTimeImmutable + { + return new DateTimeImmutable($this->entityState->lastChanged); + } + + public function getLastUpdated(): DateTimeImmutable + { + return new DateTimeImmutable($this->entityState->lastUpdated); + } +} diff --git a/src/HomeAssistant/HomeAssistantException.php b/src/HomeAssistant/HomeAssistantException.php new file mode 100644 index 0000000..48b8889 --- /dev/null +++ b/src/HomeAssistant/HomeAssistantException.php @@ -0,0 +1,11 @@ +getEntityState($entityId); + + return new HomeAssistantEntity($entityState); + } + + public function findAllEntities(): array + { + $states = $this->getAllEntityStates(); + + if (empty($states)) { + throw new HomeAssistantException('No entities found'); + } + + $entities = []; + foreach ($states as $state) { + $type = HomeEntityType::tryFrom($state->getDomain()); + + if ($type === null) { + continue; + } + + $entities[] = new HomeAssistantEntity($state); + } + + return array_filter($entities, fn (HomeEntityInterface $entity) => $entity->getType() === HomeEntityType::LIGHT); + } + + public function callService(string $service, array $data = []): array + { + // Extract domain and service name from the service string + if (str_contains($service, '.')) { + [$domain, $serviceName] = explode('.', $service, 2); + + return $this->client->callService($domain, $serviceName, $data); + } + + throw new HomeAssistantException("Invalid service format. Expected 'domain.service'"); + } + + /** + * @return EntityState[] + */ + public function getAllEntityStates(): array + { + $states = $this->client->getStates(); + + return array_map( + static fn (array $state): EntityState => EntityState::fromArray($state), + $states, + ); + } + + public function getEntityState(string $entityId): EntityState + { + $state = $this->client->getEntityState($entityId); + + return EntityState::fromArray($state); + } + + /** + * @return EntityState[] + */ + public function getEntitiesByDomain(string $domain): array + { + $allStates = $this->getAllEntityStates(); + + return array_filter( + $allStates, + static fn (EntityState $state): bool => $state->getDomain() === $domain, + ); + } + + public function turnOn(string $entityId): EntityState + { + $this->client->turnOn($entityId); + + return $this->getEntityState($entityId); + } + + public function turnOff(string $entityId): EntityState + { + $this->client->turnOff($entityId); + + return $this->getEntityState($entityId); + } + + /** + * @return string[] + */ + public function getAvailableDomains(): array + { + $services = $this->client->getServices(); + + return array_keys($services); + } +} diff --git a/src/Service/CalendarExportService.php b/src/Service/CalendarExportService.php index 96a1cea..a924ff8 100644 --- a/src/Service/CalendarExportService.php +++ b/src/Service/CalendarExportService.php @@ -2,17 +2,25 @@ namespace App\Service; -use App\Model\Absence; -use DateTime; +use App\HomeAssistant\HomeAssistantClient; +use DateTimeImmutable; use DateTimeInterface; use DateTimeZone; +use RuntimeException; final class CalendarExportService { - private const DUSSELDORF_ADDRESS = 'CHECK24 \nHeinricht-Heine-Allee 53\n40213 Düsseldorf\nDeutschland'; - private const KAMEN_ADDRESS = 'Privat \nHeidkamp 21\n59174 Kamen\nDeutschland'; - private const COMPANY_NAME = 'CHECK24'; - private const TIMEZONE = 'Europe/Berlin'; + private const string DUSSELDORF_ADDRESS = 'CHECK24 \nHeinricht-Heine-Allee 53\n40213 Düsseldorf\nDeutschland'; + private const string KAMEN_ADDRESS = 'Privat \nHeidkamp 21\n59174 Kamen\nDeutschland'; + private const string COMPANY_NAME = 'CHECK24'; + private const string TIMEZONE = 'Europe/Berlin'; + private const string PERSON_ENTITY = 'person.tim'; + private const string OFFICE_STATE = 'CHECK24'; + + public function __construct( + private readonly HomeAssistantClient $homeAssistantClient + ) { + } public function generateIcsContent(array $days, array $absences): string { @@ -30,11 +38,18 @@ final class CalendarExportService continue; } - $eventStart = clone $day['date']; - $eventStart->setTime(9, 0, 0); + $dayDate = clone $day['date']; + $startAndEndTimes = $this->getWorkTimesFromHomeAssistant($dayDate); - $eventEnd = clone $day['date']; - $eventEnd->setTime(17, 30, 0); + if ($startAndEndTimes === null) { + $eventStart = clone $day['date']; + $eventStart->setTime(9, 0, 0); + + $eventEnd = clone $day['date']; + $eventEnd->setTime(17, 30, 0); + } else { + [$eventStart, $eventEnd] = $startAndEndTimes; + } $isHomeOffice = $day['isFtk'] ?? false; $location = $isHomeOffice ? self::KAMEN_ADDRESS : self::DUSSELDORF_ADDRESS; @@ -46,7 +61,7 @@ final class CalendarExportService $icsContent[] = 'BEGIN:VEVENT'; $icsContent[] = 'UID:' . $this->generateUid($eventStart); - $icsContent[] = 'DTSTAMP:' . $this->formatDateTime(new DateTime('now', new DateTimeZone(self::TIMEZONE))); + $icsContent[] = 'DTSTAMP:' . $this->formatDateTime(new DateTimeImmutable('now', new DateTimeZone(self::TIMEZONE))); $icsContent[] = 'DTSTART;TZID=' . self::TIMEZONE . ':' . $this->formatDateTimeLocal($eventStart); $icsContent[] = 'DTEND;TZID=' . self::TIMEZONE . ':' . $this->formatDateTimeLocal($eventEnd); $icsContent[] = 'SUMMARY:' . $name; @@ -59,16 +74,67 @@ final class CalendarExportService return implode("\r\n", $icsContent); } + private function getWorkTimesFromHomeAssistant(DateTimeInterface $date): ?array + { + $startOfDay = DateTimeImmutable::createFromInterface($date)->setTime(0, 0, 0); + $typicalEndOfDay = DateTimeImmutable::createFromInterface($date)->setTime(17, 30, 0); + + try { + $stateHistory = $this->homeAssistantClient->getEntityStateHistory( + self::PERSON_ENTITY, + $startOfDay, + $typicalEndOfDay + ); + + if (empty($stateHistory) || empty($stateHistory[0])) { + return null; + } + + $startTime = null; + $endTime = null; + $states = $stateHistory[0]; + + foreach ($states as $i => $state) { + if ($state['state'] === self::OFFICE_STATE) { + $startTime = new DateTimeImmutable($state['last_changed']); + + for ($j = $i + 1; $j < count($states); $j++) { + if ($states[$j]['state'] !== self::OFFICE_STATE) { + $endTime = new DateTimeImmutable($states[$j]['last_changed']); + break; + } + } + + if ($startTime !== null && $endTime === null) { + $endTime = clone $startTime; + $endTime = $endTime->modify('+9 hours'); + } + + break; + } + } + + if ($startTime === null || $endTime === null) { + return null; + } + + $startTime = $startTime->setTimezone(new DateTimeZone('Europe/Berlin')); + $endTime = $endTime->setTimezone(new DateTimeZone('Europe/Berlin')); + + return [$startTime, $endTime]; + } catch (RuntimeException) { + return null; + } + } + private function isWeekend(array $day): bool { return $day['weekday'] >= 6; } - private function formatDateTime(DateTimeInterface $dateTime): string + private function formatDateTime(DateTimeImmutable $dateTime): string { - $utcDateTime = clone $dateTime; - $utcDateTime->setTimezone(new DateTimeZone('UTC')); - return $utcDateTime->format('Ymd\THis\Z'); + return $dateTime->setTimezone(new DateTimeZone('UTC'))->format('Ymd\THis\Z'); } private function formatDateTimeLocal(DateTimeInterface $dateTime): string @@ -104,4 +170,4 @@ final class CalendarExportService 'END:VTIMEZONE', ]); } -} \ No newline at end of file +} \ No newline at end of file