Added home assistant

This commit is contained in:
Tim Lappe 2025-03-15 09:54:07 +01:00
parent c60b5f653e
commit abc6fae033
13 changed files with 569 additions and 16 deletions

4
.env
View File

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

View File

@ -0,0 +1,3 @@
<?php // dev.HOME_ASSISTANT_TOKEN.ad10d3 on Sat, 15 Mar 2025 08:32:34 +0000
return "\x85\xCB\xCD4\xAC\x23\xEF\x87\x08\x7F\x14q\x93Kq\xB3Q\xDF\x27\xD3\xF1B5\x7C\x7CO\xADz\x86\x9EK\x1B\xE5b1\xEA\x26\xA9\x5Bk\xBC\xE0y\xE4V\xA1q\x8D\xC1\x1F\xA4\x98f\xBF\xCF\x29\x3C2\xEE.\xCAE\xFD1\xAD\xA3\xAE\xC5\x94\xD1\xA8\x9C\x0F\xFDy\xC5K\x9A\x8D\xAC\xA9\xF1_\xBA4\x87\x98\x8An\x84\x8A\x23\xCF\x89or\xBF\x09\xBC\x8En\x7F\x04\xEB\x25\x01l\x9Fc\x09\x03\xA25\x3E\x3AVVL2r\x89ifh\xE0N\x40\xCA\xFB\x20A\xCDtCVl\xDF\x01\x3E\x0FP\x5Er\xC3\x27\x82\xE6_\xBE\xE1bi\x20k\xDA\x09J\xF7\x91rKs\x98\xC2\x7B\x3Ac\xD9\xB6z\x2FHw\x84\xA1\x3B\x3FA\xDB\x83_cB\xFF\x94M\xC8\x86\xEE\x05\xA7U\xA1\x8A\x9A\x81\xB3\x85\xF6\xFBG\xC4\x0B\x8D\xA9\x98\x82\xC4\xB5\xA6\x95\xFE\x5D\xBE\x98EQ\xA9\xC3\xE8\xBB\xDD7\x133\x5C\x40\xD5\xC1\x5D\x3B";

View File

@ -3,4 +3,5 @@
return [
'ABSENCE_API_ID' => null,
'ABSENCE_API_KEY' => null,
'HOME_ASSISTANT_TOKEN' => null,
];

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Commands;
use App\HomeAssistant\HomeAssistantClient;
use DateTimeImmutable;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:show-entity-state-history',
description: 'Shows the state history for a Home Assistant entity',
)]
final class ShowEntityStateHistoryCommand extends Command
{
public function __construct(
private readonly HomeAssistantClient $client,
) {
parent::__construct();
}
protected function configure(): void
{
$this->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;
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Core\Services\Home;
use DateTimeImmutable;
interface HomeEntityInterface
{
public function getId(): string;
public function getState(): mixed;
public function getName(): string;
public function getType(): HomeEntityType;
public function getLastChanged(): DateTimeImmutable;
public function getLastUpdated(): DateTimeImmutable;
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Core\Services\Home;
enum HomeEntityType
{
case LIGHT;
case SWITCH;
case SENSOR;
case BINARY_SENSOR;
case CLIMATE;
case MEDIA_PLAYER;
case SCENE;
case SCRIPT;
case AUTOMATION;
case CAMERA;
case COVER;
case FAN;
case LOCK;
case VACUUM;
case WEATHER;
case ZONE;
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Core\Services\Home;
interface HomeServiceInterface
{
public function findEntity(string $entityId): HomeEntityInterface;
public function findAllEntities(): array;
public function callService(string $service, array $data = []): array;
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\HomeAssistant;
use function explode;
use function in_array;
final readonly class EntityState
{
public function __construct(
public string $entityId,
public string $state,
public array $attributes,
public string $lastChanged,
public string $lastUpdated,
public array|string|null $context = null,
) {
}
public static function fromArray(array $data): self
{
return new self(
$data['entity_id'],
$data['state'],
$data['attributes'] ?? [],
$data['last_changed'] ?? '',
$data['last_updated'] ?? '',
$data['context'] ?? null,
);
}
public function isOn(): bool
{
return in_array($this->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;
}
}

View File

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\HomeAssistant;
use DateTimeInterface;
use function explode;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
final readonly class HomeAssistantClient
{
public function __construct(
private HttpClientInterface $httpClient,
#[Autowire('%env(HOME_ASSISTANT_URL)%')]
private string $baseUrl,
#[Autowire('%env(HOME_ASSISTANT_TOKEN)%')]
private string $token,
#[Autowire('%env(HOME_ASSISTANT_VERIFY_SSL)%')]
private bool $verifySSL,
) {
}
public function getStates(): array
{
return $this->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);
}
}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\HomeAssistant;
use App\Core\Services\Home\HomeEntityInterface;
use App\Core\Services\Home\HomeEntityType;
use DateTimeImmutable;
final readonly class HomeAssistantEntity implements HomeEntityInterface
{
public function __construct(
private EntityState $entityState,
) {
}
public function getId(): string
{
return $this->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);
}
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\HomeAssistant;
use RuntimeException;
final class HomeAssistantException extends RuntimeException
{
}

View File

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Services\HomeAssistant;
use App\Core\Services\Home\HomeEntityInterface;
use App\Core\Services\Home\HomeEntityType;
use App\Core\Services\Home\HomeServiceInterface;
use function array_filter;
use function array_keys;
use function array_map;
use function explode;
use function str_contains;
final readonly class HomeAssistantHomeService implements HomeServiceInterface
{
public function __construct(
private HomeAssistantClient $client,
) {
}
public function findEntity(string $entityId): HomeEntityInterface
{
$entityState = $this->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);
}
}

View File

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