Added some abstractions

This commit is contained in:
Tim Lappe 2025-03-14 18:27:56 +01:00
parent e8547bd341
commit 9590992c08
44 changed files with 2333 additions and 680 deletions

4
.gitignore vendored
View File

@ -1,3 +1,7 @@
vendor/ vendor/
var/ var/
.env.local .env.local
###> friendsofphp/php-cs-fixer ###
/.php-cs-fixer.php
/.php-cs-fixer.cache
###< friendsofphp/php-cs-fixer ###

67
.php-cs-fixer.dist.php Normal file
View File

@ -0,0 +1,67 @@
<?php
$finder = (new PhpCsFixer\Finder())
->in([
__DIR__ . '/src',
__DIR__ . '/tests',
])
->exclude([
'var',
'vendor',
])
;
return (new PhpCsFixer\Config())
->setRules([
'@Symfony' => true,
'@PSR12' => true,
'@PHP82Migration' => true,
'array_syntax' => ['syntax' => 'short'],
'ordered_imports' => ['sort_algorithm' => 'alpha'],
'no_unused_imports' => true,
'global_namespace_import' => [
'import_classes' => true,
'import_constants' => true,
'import_functions' => true,
],
'blank_line_before_statement' => [
'statements' => ['return', 'throw', 'try', 'if'],
],
'multiline_whitespace_before_semicolons' => [
'strategy' => 'new_line_for_chained_calls',
],
'class_attributes_separation' => [
'elements' => [
'const' => 'one',
'method' => 'one',
'property' => 'one',
'trait_import' => 'none',
],
],
'declare_strict_types' => true,
'void_return' => true,
'native_function_invocation' => [
'include' => ['@all'],
],
'native_constant_invocation' => [
'scope' => 'all',
],
'no_superfluous_phpdoc_tags' => [
'allow_mixed' => true,
'remove_inheritdoc' => true,
],
'phpdoc_align' => [
'align' => 'left',
],
'phpdoc_order' => true,
'phpdoc_separation' => true,
'yoda_style' => false,
'single_line_throw' => false,
'trailing_comma_in_multiline' => [
'elements' => ['arrays', 'arguments', 'parameters'],
],
])
->setFinder($finder)
->setCacheFile(__DIR__ . '/var/.php-cs-fixer.cache')
->setRiskyAllowed(true)
;

View File

@ -59,7 +59,8 @@
], ],
"post-update-cmd": [ "post-update-cmd": [
"@auto-scripts" "@auto-scripts"
] ],
"fix": "PHP_CS_FIXER_IGNORE_ENV=1 php-cs-fixer fix --config=.php-cs-fixer.dist.php"
}, },
"conflict": { "conflict": {
"symfony/symfony": "*" "symfony/symfony": "*"
@ -71,6 +72,7 @@
} }
}, },
"require-dev": { "require-dev": {
"friendsofphp/php-cs-fixer": "^3.42",
"symfony/maker-bundle": "^1.62" "symfony/maker-bundle": "^1.62"
} }
} }

1163
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -24,16 +24,16 @@ services:
- '../src/Entity/' - '../src/Entity/'
- '../src/Kernel.php' - '../src/Kernel.php'
# Calendar configuration services App\Core\Services\Calendar\CalendarService:
App\Core\Home\Calendar\CalendarConfig: arguments:
factory: ['@App\Core\Home\Calendar\CalendarConfigFactory', 'createCalendarConfig'] $providers:
- '@App\Services\Calendar\IcsCalendarProvider'
App\Core\Home\Calendar\CalendarConfigFactory: App\Services\Calendar\IcsCalendarProvider:
arguments: arguments:
$icsCalendars: '%app.calendars.ics%' $icsCalendars: '%app.calendars.ics%'
$icsClient: '@App\Services\Calendar\IcsClient'
App\Core\Home\Calendar\CalendarService: $icsParser: '@App\Services\Calendar\IcsParser'
factory: ['@App\Core\Home\Calendar\CalendarFactory', 'createCalendarService']
# add more service definitions when explicit configuration is needed # add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones # please note that last definitions always *replace* previous ones

View File

@ -1,8 +1,11 @@
<?php <?php
declare(strict_types=1);
namespace App\Command; namespace App\Command;
use App\Core\OpenAI\ChatGPTService; use App\Core\Services\AI\ChatServiceInterface;
use Exception;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
@ -17,7 +20,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
class ChatGPTCommand extends Command class ChatGPTCommand extends Command
{ {
public function __construct( public function __construct(
private readonly ChatGPTService $chatGPTService private readonly ChatServiceInterface $chatService,
) { ) {
parent::__construct(); parent::__construct();
} }
@ -28,7 +31,7 @@ class ChatGPTCommand extends Command
'system-prompt', 'system-prompt',
's', 's',
InputOption::VALUE_OPTIONAL, InputOption::VALUE_OPTIONAL,
'Initial system prompt to set the context' 'Initial system prompt to set the context',
); );
} }
@ -37,7 +40,7 @@ class ChatGPTCommand extends Command
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
$systemPrompt = $input->getOption('system-prompt'); $systemPrompt = $input->getOption('system-prompt');
$conversation = $this->chatGPTService->createChatConversation($systemPrompt ? [$systemPrompt] : []); $conversation = $this->chatService->createChatConversation($systemPrompt ? [$systemPrompt] : []);
$io->info('Starting chat with ChatGPT (type "exit" to quit)'); $io->info('Starting chat with ChatGPT (type "exit" to quit)');
@ -49,13 +52,14 @@ class ChatGPTCommand extends Command
} }
try { try {
$response = $this->chatGPTService->sendMessage($userMessage, $conversation); $response = $this->chatService->sendMessage($userMessage, $conversation);
$this->chatGPTService->addMessageToConversation($conversation, $userMessage, 'user'); $this->chatService->addMessageToConversation($conversation, $userMessage, 'user');
$this->chatGPTService->addMessageToConversation($conversation, $response, 'assistant'); $this->chatService->addMessageToConversation($conversation, $response, 'assistant');
$io->text(['ChatGPT > ' . $response, '']); $io->text(['ChatGPT > '.$response, '']);
} catch (\Exception $e) { } catch (Exception $e) {
$io->error($e->getMessage()); $io->error($e->getMessage());
return Command::FAILURE; return Command::FAILURE;
} }
} }

View File

@ -4,7 +4,11 @@ declare(strict_types=1);
namespace App\Command; namespace App\Command;
use App\Core\HomeAssistant\HomeAssistantService; use App\Core\Services\Home\HomeEntityInterface;
use App\Core\Services\Home\HomeServiceInterface;
use function array_map;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
@ -13,13 +17,13 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand( #[AsCommand(
name: 'app:home-assistant', name: 'app:home',
description: 'Interact with Home Assistant', description: 'Interact with the configured Home Service',
)] )]
final class HomeAssistantCommand extends Command final class HomeAssistantCommand extends Command
{ {
public function __construct( public function __construct(
private readonly HomeAssistantService $homeAssistant private readonly HomeServiceInterface $homeService,
) { ) {
parent::__construct(); parent::__construct();
} }
@ -27,96 +31,46 @@ final class HomeAssistantCommand extends Command
protected function configure(): void protected function configure(): void
{ {
$this $this
->addOption('list-domains', null, InputOption::VALUE_NONE, 'List all available domains')
->addOption('list-entities', null, InputOption::VALUE_NONE, 'List all entities') ->addOption('list-entities', null, InputOption::VALUE_NONE, 'List all entities')
->addOption('domain', null, InputOption::VALUE_REQUIRED, 'Filter entities by domain') ->addOption('domain', null, InputOption::VALUE_REQUIRED, 'Filter entities by domain')
->addOption('entity-id', null, InputOption::VALUE_REQUIRED, 'Entity ID to interact with') ->addOption('entity-id', null, InputOption::VALUE_REQUIRED, 'Entity ID to interact with')
->addOption('turn-on', null, InputOption::VALUE_NONE, 'Turn on the specified entity') ;
->addOption('turn-off', null, InputOption::VALUE_NONE, 'Turn off the specified entity');
} }
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
if ($input->getOption('list-domains')) {
return $this->listDomains($io);
}
if ($input->getOption('list-entities')) { if ($input->getOption('list-entities')) {
return $this->listEntities($io, $input->getOption('domain')); return $this->listEntities($io);
} }
$entityId = $input->getOption('entity-id'); $entityId = $input->getOption('entity-id');
if ($entityId === null) { if ($entityId === null) {
$io->error('You must specify an entity ID using --entity-id option'); $io->error('You must specify an entity ID using --entity-id option');
return Command::FAILURE; return Command::FAILURE;
} }
if ($input->getOption('turn-on')) {
return $this->turnOn($io, $entityId);
}
if ($input->getOption('turn-off')) {
return $this->turnOff($io, $entityId);
}
$this->showEntityState($io, $entityId);
return Command::SUCCESS; return Command::SUCCESS;
} }
private function listDomains(SymfonyStyle $io): int private function listEntities(SymfonyStyle $io): int
{ {
$domains = $this->homeAssistant->getAvailableDomains(); $entities = $this->homeService->findAllEntities();
$io->listing($domains);
return Command::SUCCESS;
}
private function listEntities(SymfonyStyle $io, string|null $domain): int
{
$entities = $domain !== null
? $this->homeAssistant->getEntitiesByDomain($domain)
: $this->homeAssistant->getAllEntityStates();
$rows = array_map( $rows = array_map(
static fn($entity) => [ static fn (HomeEntityInterface $entity) => [
$entity->entityId, $entity->getId(),
$entity->getName(), $entity->getName(),
$entity->state, $entity->getState(),
], ],
$entities $entities,
); );
$io->table(['Entity ID', 'Name', 'State'], $rows); $io->table(['Entity ID', 'Name', 'State'], $rows);
return Command::SUCCESS;
}
private function turnOn(SymfonyStyle $io, string $entityId): int
{
$state = $this->homeAssistant->turnOn($entityId);
$io->success(sprintf('Entity %s turned on. Current state: %s', $entityId, $state->state));
return Command::SUCCESS; return Command::SUCCESS;
} }
private function turnOff(SymfonyStyle $io, string $entityId): int
{
$state = $this->homeAssistant->turnOff($entityId);
$io->success(sprintf('Entity %s turned off. Current state: %s', $entityId, $state->state));
return Command::SUCCESS;
}
private function showEntityState(SymfonyStyle $io, string $entityId): void
{
$state = $this->homeAssistant->getEntityState($entityId);
$io->table(
['Property', 'Value'],
[
['Entity ID', $state->entityId],
['Name', $state->getName()],
['State', $state->state],
['Last Changed', $state->lastChanged],
['Last Updated', $state->lastUpdated],
]
);
}
} }

View File

@ -1,12 +1,14 @@
<?php <?php
declare(strict_types=1);
namespace App\Command; namespace App\Command;
use App\Core\Home\Calendar\CalendarFactory; use App\Core\Services\Calendar\CalendarService;
use DateTime;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
@ -17,43 +19,27 @@ use Symfony\Component\Console\Style\SymfonyStyle;
class ReadCalendarCommand extends Command class ReadCalendarCommand extends Command
{ {
public function __construct( public function __construct(
private readonly CalendarFactory $calendarFactory private readonly CalendarService $calendarService,
) { ) {
parent::__construct(); parent::__construct();
} }
protected function configure(): void
{
$this
->addOption('days', 'd', InputOption::VALUE_OPTIONAL, 'Number of days to look ahead', 7)
->addOption('group', 'g', InputOption::VALUE_NONE, 'Group events by calendar');
}
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
$days = (int)$input->getOption('days'); $days = 7;
$group = $input->getOption('group');
$from = new \DateTime(); $from = new DateTime();
$to = (new \DateTime())->modify("+$days days"); $to = (new DateTime())->modify("+$days days");
$calendarService = $this->calendarFactory->createCalendarService(); $calendars = $this->calendarService->getCalendars();
if ($group) { foreach ($calendars as $calendar) {
$events = $calendarService->getEventsGroupedByCalendar($from, $to); $events = $calendar->getEvents($from, $to);
$io->section($calendar->getName());
foreach ($events as $calendarName => $calendarEvents) {
$io->section($calendarName);
$this->displayEvents($io, $calendarEvents);
}
return Command::SUCCESS;
}
$events = $calendarService->getEvents($from, $to);
$this->displayEvents($io, $events); $this->displayEvents($io, $events);
}
return Command::SUCCESS; return Command::SUCCESS;
} }
@ -67,13 +53,13 @@ class ReadCalendarCommand extends Command
$event->getEnd()->format('Y-m-d H:i'), $event->getEnd()->format('Y-m-d H:i'),
$event->getTitle(), $event->getTitle(),
$event->getLocation(), $event->getLocation(),
$event->isAllDay() ? 'Yes' : 'No' $event->isAllDay() ? 'Yes' : 'No',
]; ];
} }
$io->table( $io->table(
['Start', 'End', 'Title', 'Location', 'All Day'], ['Start', 'End', 'Title', 'Location', 'All Day'],
$rows $rows,
); );
} }
} }

View File

@ -1,8 +1,11 @@
<?php <?php
declare(strict_types=1);
namespace App\Command; namespace App\Command;
use App\Core\Agent\Agent; use App\Core\Agent\Agent;
use Exception;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
@ -15,7 +18,7 @@ use Symfony\Component\Console\Output\OutputInterface;
class RunAgentCommand extends Command class RunAgentCommand extends Command
{ {
public function __construct( public function __construct(
private readonly Agent $agent private readonly Agent $agent,
) { ) {
parent::__construct(); parent::__construct();
} }
@ -28,8 +31,8 @@ class RunAgentCommand extends Command
$output->writeln($result['response']); $output->writeln($result['response']);
return Command::SUCCESS; return Command::SUCCESS;
} catch (\Exception $e) { } catch (Exception $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>'); $output->writeln('<error>'.$e->getMessage().'</error>');
return Command::FAILURE; return Command::FAILURE;
} }

View File

@ -1,8 +1,14 @@
<?php <?php
declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Core\OpenAI\ChatGPTService; use App\Core\Services\AI\ChatServiceInterface;
use Exception;
use function json_decode;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@ -13,7 +19,7 @@ use Symfony\Component\Routing\Attribute\Route;
class ChatController extends AbstractController class ChatController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly ChatGPTService $chatGPTService private readonly ChatServiceInterface $chatGPTService,
) { ) {
} }
@ -31,7 +37,6 @@ class ChatController extends AbstractController
try { try {
$response = $this->chatGPTService->sendMessage($data['message'], $previousMessages); $response = $this->chatGPTService->sendMessage($data['message'], $previousMessages);
// Add user message and AI response to the conversation history
if (empty($previousMessages)) { if (empty($previousMessages)) {
$conversation = $this->chatGPTService->createChatConversation(); $conversation = $this->chatGPTService->createChatConversation();
} else { } else {
@ -43,9 +48,9 @@ class ChatController extends AbstractController
return $this->json([ return $this->json([
'response' => $response, 'response' => $response,
'conversation' => $conversation 'conversation' => $conversation,
]); ]);
} catch (\Exception $e) { } catch (Exception $e) {
return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST); return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST);
} }
} }

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

View File

@ -1,18 +1,22 @@
<?php <?php
declare(strict_types=1);
namespace App\Core\Agent; namespace App\Core\Agent;
use App\Core\Agent\PromptProvider; use App\Core\Services\AI\ChatServiceInterface;
use App\Core\Home\Calendar\CalendarService; use App\Core\Services\Calendar\CalendarService;
use App\Core\OpenAI\ChatGPTService;
use DateTimeImmutable; use DateTimeImmutable;
use function sprintf;
use function strtr;
class Agent class Agent
{ {
public function __construct( public function __construct(
private readonly PromptProvider $promptProvider, private readonly PromptProvider $promptProvider,
private readonly CalendarService $calendarService, private readonly CalendarService $calendarService,
private readonly ChatGPTService $chatGPTService private readonly ChatServiceInterface $chatGPTService,
) { ) {
} }
@ -23,7 +27,7 @@ class Agent
return [ return [
'prompt' => $prompt, 'prompt' => $prompt,
'response' => $response 'response' => $response,
]; ];
} }
@ -33,22 +37,20 @@ class Agent
$from = $now->modify('-1 day'); $from = $now->modify('-1 day');
$to = $now->modify('+7 days'); $to = $now->modify('+7 days');
$events = $this->calendarService->getEvents($from, $to); $calendars = $this->calendarService->getCalendars();
$calendarEventsText = ''; $calendarEventsText = '';
foreach ($events as $event) { foreach ($calendars as $calendar) {
$calendarEventsText .= sprintf( $calendarEventsText .= sprintf(
"- %s: %s from %s to %s\n", "- %s: %s\n",
$event->getCalendarName(), $calendar->getName(),
$event->getTitle(), $calendar->getDescription(),
$event->getStart()->format('Y-m-d H:i'),
$event->getEnd()->format('Y-m-d H:i')
); );
} }
return strtr($this->promptProvider->getPromptTemplate(), [ return strtr($this->promptProvider->getPromptTemplate(), [
'{calendar_events}' => $calendarEventsText, '{calendar_events}' => $calendarEventsText,
'{current_time}' => $now->format('Y-m-d H:i:s') '{current_time}' => $now->format('Y-m-d H:i:s'),
]); ]);
} }
} }

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\Core\Agent; namespace App\Core\Agent;
class PromptProvider class PromptProvider

View File

@ -1,34 +0,0 @@
<?php
namespace App\Core\Home\Calendar;
class CalendarConfig
{
/** @var array<string, string> */
private array $icsCalendars = [];
public function addIcsCalendar(string $name, string $url): self
{
$this->icsCalendars[$name] = $url;
return $this;
}
/**
* @return array<string, string>
*/
public function getIcsCalendars(): array
{
return $this->icsCalendars;
}
public function getIcsCalendarUrl(string $name): ?string
{
return $this->icsCalendars[$name] ?? null;
}
public function hasIcsCalendar(string $name): bool
{
return isset($this->icsCalendars[$name]);
}
}

View File

@ -1,25 +0,0 @@
<?php
namespace App\Core\Home\Calendar;
class CalendarConfigFactory
{
/**
* @param array<string, string> $icsCalendars
*/
public function __construct(
private readonly array $icsCalendars = []
) {
}
public function createCalendarConfig(): CalendarConfig
{
$config = new CalendarConfig();
foreach ($this->icsCalendars as $name => $url) {
$config->addIcsCalendar($name, $url);
}
return $config;
}
}

View File

@ -1,64 +0,0 @@
<?php
namespace App\Core\Home\Calendar;
class CalendarEvent
{
public function __construct(
private readonly string $id,
private readonly string $title,
private readonly \DateTimeInterface $start,
private readonly \DateTimeInterface $end,
private readonly string $description = '',
private readonly string $location = '',
private readonly string $calendarName = '',
private readonly array $attendees = [],
private readonly bool $allDay = false,
) {
}
public function getId(): string
{
return $this->id;
}
public function getTitle(): string
{
return $this->title;
}
public function getStart(): \DateTimeInterface
{
return $this->start;
}
public function getEnd(): \DateTimeInterface
{
return $this->end;
}
public function getDescription(): string
{
return $this->description;
}
public function getLocation(): string
{
return $this->location;
}
public function getCalendarName(): string
{
return $this->calendarName;
}
public function getAttendees(): array
{
return $this->attendees;
}
public function isAllDay(): bool
{
return $this->allDay;
}
}

View File

@ -1,25 +0,0 @@
<?php
namespace App\Core\Home\Calendar;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class CalendarFactory
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly CalendarConfig $config
) {
}
public function createCalendarService(): CalendarService
{
$service = new CalendarService($this->httpClient);
foreach ($this->config->getIcsCalendars() as $name => $url) {
$service->addIcsCalendar($url, $name);
}
return $service;
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Core\Home\Calendar;
/**
* Interface for calendar providers
*/
interface CalendarInterface
{
/**
* Returns all calendar events within the given time range
*/
public function getEvents(\DateTimeInterface $from, \DateTimeInterface $to): array;
/**
* Returns the name of this calendar
*/
public function getName(): string;
}

View File

@ -1,69 +0,0 @@
<?php
namespace App\Core\Home\Calendar;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class CalendarService
{
/** @var CalendarInterface[] */
private array $calendarProviders = [];
public function __construct(
private readonly HttpClientInterface $httpClient
) {
}
public function addCalendar(CalendarInterface $calendar): self
{
$this->calendarProviders[] = $calendar;
return $this;
}
public function addIcsCalendar(string $url, ?string $name = null): self
{
$provider = new IcsCalendarProvider($this->httpClient, $url, $name);
$this->calendarProviders[] = $provider;
return $this;
}
public function getCalendars(): array
{
return $this->calendarProviders;
}
public function getEvents(\DateTimeInterface $from, \DateTimeInterface $to): array
{
$allEvents = [];
foreach ($this->calendarProviders as $calendar) {
$events = $calendar->getEvents($from, $to);
$allEvents = array_merge($allEvents, $events);
}
// Sort events by start date
usort($allEvents, function (CalendarEvent $a, CalendarEvent $b) {
return $a->getStart() <=> $b->getStart();
});
return $allEvents;
}
public function getEventsGroupedByCalendar(\DateTimeInterface $from, \DateTimeInterface $to): array
{
$groupedEvents = [];
foreach ($this->calendarProviders as $calendar) {
$calendarName = $calendar->getName();
$events = $calendar->getEvents($from, $to);
if (!empty($events)) {
$groupedEvents[$calendarName] = $events;
}
}
return $groupedEvents;
}
}

View File

@ -1,136 +0,0 @@
<?php
namespace App\Core\Home\Calendar;
use DateTimeImmutable;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class IcsCalendarProvider implements CalendarInterface
{
private string $url;
private string $name;
private ?string $cachedContent = null;
private ?\DateTimeInterface $lastFetch = null;
public function __construct(
private readonly HttpClientInterface $httpClient,
string $url,
?string $name = null
) {
$this->url = $url;
$this->name = $name ?? parse_url($url, PHP_URL_HOST) ?? 'Unknown';
}
public function getName(): string
{
return $this->name;
}
public function getEvents(\DateTimeInterface $from, \DateTimeInterface $to): array
{
$icsContent = $this->fetchIcsContent();
return $this->parseIcsContent($icsContent, $from, $to);
}
private function fetchIcsContent(): string
{
// Cache for 5 minutes
if ($this->cachedContent !== null && $this->lastFetch !== null &&
$this->lastFetch->getTimestamp() > (time() - 300)) {
return $this->cachedContent;
}
$requestUrl = $this->url;
// Convert webcal:// to https:// for the HTTP client
if (stripos($requestUrl, 'webcal://') === 0) {
$requestUrl = str_replace('webcal://', 'https://', $requestUrl);
}
$response = $this->httpClient->request('GET', $requestUrl);
$content = $response->getContent();
$this->cachedContent = $content;
$this->lastFetch = new \DateTime();
return $content;
}
private function parseIcsContent(string $icsContent, \DateTimeInterface $from, \DateTimeInterface $to): array
{
$events = [];
$lines = explode("\n", $icsContent);
$inEvent = false;
$currentEvent = null;
$eventData = [];
foreach ($lines as $line) {
$line = trim($line);
if ($line === 'BEGIN:VEVENT') {
$inEvent = true;
$eventData = [];
continue;
}
if ($line === 'END:VEVENT') {
$inEvent = false;
if (isset($eventData['DTSTART'], $eventData['DTEND'], $eventData['UID'])) {
$startDate = $this->parseIcsDate($eventData['DTSTART']);
$endDate = $this->parseIcsDate($eventData['DTEND']);
// Skip events outside the requested range
if ($endDate < $from || $startDate > $to) {
continue;
}
$allDay = false;
if (isset($eventData['DTSTART;VALUE=DATE'])) {
$allDay = true;
}
$events[] = new CalendarEvent(
$eventData['UID'],
$eventData['SUMMARY'] ?? 'Untitled Event',
$startDate,
$endDate,
$eventData['DESCRIPTION'] ?? '',
$eventData['LOCATION'] ?? '',
$this->name,
[], // attendees not parsed in this basic implementation
$allDay
);
}
continue;
}
if ($inEvent && strpos($line, ':') !== false) {
[$key, $value] = explode(':', $line, 2);
// Handle property parameters
if (strpos($key, ';') !== false) {
$parts = explode(';', $key);
$key = $parts[0];
}
$eventData[$key] = $value;
}
}
return $events;
}
private function parseIcsDate(string $dateString): \DateTimeInterface
{
$date = new DateTimeImmutable($dateString);
if ($date === false) {
return new \DateTime();
}
return $date;
}
}

View File

@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Core\HomeAssistant;
final readonly class HomeAssistantService
{
public function __construct(
private HomeAssistantClient $client
) {
}
/**
* @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
{
$result = $this->client->turnOn($entityId);
return $this->getEntityState($entityId);
}
public function turnOff(string $entityId): EntityState
{
$result = $this->client->turnOff($entityId);
return $this->getEntityState($entityId);
}
public function callService(string $domain, string $service, array $data = []): array
{
return $this->client->callService($domain, $service, $data);
}
/**
* @return string[]
*/
public function getAvailableDomains(): array
{
$services = $this->client->getServices();
return array_keys($services);
}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Core\Services\AI;
interface ChatServiceInterface
{
public function sendMessage(string $userMessage, array $previousMessages = []): string;
public function createChatConversation(array $systemPrompt = []): array;
public function addMessageToConversation(array &$conversation, string $message, string $role = 'user'): void;
}

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Core\Services\Calendar;
class Calendar
{
/** @var CalendarEvent[] */
private array $events = [];
public function __construct(
private readonly string $name,
private readonly string $description,
private readonly string $url,
private readonly bool $enabled = true,
private readonly bool $isDefault = false,
) {
}
public function getName(): string
{
return $this->name;
}
public function getDescription(): string
{
return $this->description;
}
public function getUrl(): string
{
return $this->url;
}
public function isEnabled(): bool
{
return $this->enabled;
}
public function isDefault(): bool
{
return $this->isDefault;
}
/**
* @return CalendarEvent[]
*/
public function getEvents(): array
{
return $this->events;
}
public function addEvent(CalendarEvent $event): void
{
$this->events[] = $event;
}
/**
* @param CalendarEvent[] $events
*/
public function setEvents(array $events): void
{
$this->events = $events;
}
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Core\Services\Calendar;
use DateTimeInterface;
class CalendarEvent
{
public function __construct(
private readonly string $title,
private readonly DateTimeInterface $start,
private readonly DateTimeInterface $end,
private readonly Calendar $calendar,
private readonly ?string $description = null,
private readonly ?string $location = null,
private readonly ?string $url = null,
private readonly bool $allDay = false,
) {
}
public function getTitle(): string
{
return $this->title;
}
public function getDescription(): ?string
{
return $this->description;
}
public function getStart(): DateTimeInterface
{
return $this->start;
}
public function getEnd(): DateTimeInterface
{
return $this->end;
}
public function getLocation(): ?string
{
return $this->location;
}
public function getUrl(): ?string
{
return $this->url;
}
public function getCalendar(): Calendar
{
return $this->calendar;
}
public function isAllDay(): bool
{
return $this->allDay;
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Core\Services\Calendar;
interface CalendarProviderInterface
{
/**
* @return Calendar[]
*/
public function getCalendars(): array;
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Core\Services\Calendar;
use function array_merge;
use function usort;
class CalendarService
{
/**
* @param array<CalendarProviderInterface> $providers
*/
public function __construct(
private iterable $providers,
) {
}
public function getCalendars(): array
{
$allCalendars = [];
foreach ($this->providers as $provider) {
$calendars = $provider->getCalendars();
$allCalendars = array_merge($allCalendars, $calendars);
}
usort($allCalendars, function (Calendar $a, Calendar $b) {
return $a->getName() <=> $b->getName();
});
return $allCalendars;
}
}

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: string
{
case LIGHT = 'light';
case SWITCH = 'switch';
case SENSOR = 'sensor';
case BINARY_SENSOR = 'binary_sensor';
case CLIMATE = 'climate';
case MEDIA_PLAYER = 'media_player';
case SCENE = 'scene';
case SCRIPT = 'script';
case AUTOMATION = 'automation';
case CAMERA = 'camera';
case COVER = 'cover';
case FAN = 'fan';
case LOCK = 'lock';
case VACUUM = 'vacuum';
case WEATHER = 'weather';
case ZONE = 'zone';
}

View File

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

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App; namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Services\Calendar;
use App\Core\Services\Calendar\Calendar;
use App\Core\Services\Calendar\CalendarProviderInterface;
use function sprintf;
class IcsCalendarProvider implements CalendarProviderInterface
{
/**
* @param array<string, string> $icsCalendars
*/
public function __construct(
private readonly array $icsCalendars,
private readonly IcsClient $icsClient,
private readonly IcsParser $icsParser,
) {
}
/**
* @return Calendar[]
*/
public function getCalendars(): array
{
$calendars = [];
foreach ($this->icsCalendars as $name => $url) {
$calendar = new Calendar(
name: $name,
description: sprintf('ICS Calendar: %s', $name),
url: $url,
enabled: true,
isDefault: false,
);
$content = $this->icsClient->fetchCalendarContent($url);
$this->icsParser->parseEvents($calendar, $content);
$calendars[] = $calendar;
}
return $calendars;
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Services\Calendar;
use function str_replace;
use function str_starts_with;
use function strtolower;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class IcsClient
{
public function __construct(
private readonly HttpClientInterface $httpClient,
) {
}
public function fetchCalendarContent(string $url): string
{
$requestUrl = $this->normalizeUrl($url);
$response = $this->httpClient->request('GET', $requestUrl);
return $response->getContent();
}
private function normalizeUrl(string $url): string
{
if (str_starts_with(strtolower($url), 'webcal://')) {
return str_replace('webcal://', 'https://', $url);
}
return $url;
}
}

View File

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Services\Calendar;
use App\Core\Services\Calendar\Calendar;
use function array_keys;
use DateTimeImmutable;
use function end;
use function explode;
use function str_contains;
use function str_starts_with;
use function substr;
use function trim;
use function usort;
class IcsParser
{
public function parseEvents(Calendar $calendar, string $icsContent): void
{
$lines = explode("\n", $icsContent);
$inEvent = false;
$eventData = [];
$events = [];
foreach ($lines as $line) {
$line = trim($line);
if ($line === 'BEGIN:VEVENT') {
$inEvent = true;
$eventData = [];
continue;
}
if ($line === 'END:VEVENT') {
$inEvent = false;
if (!isset($eventData['DTSTART'], $eventData['SUMMARY'])) {
continue;
}
$startDate = $this->parseIcsDate($eventData['DTSTART']);
// If DTEND is not present, use DTSTART as end date
$endDate = isset($eventData['DTEND'])
? $this->parseIcsDate($eventData['DTEND'])
: $startDate;
$event = new \App\Core\Services\Calendar\CalendarEvent(
$eventData['SUMMARY'],
$startDate,
$endDate,
$calendar,
$eventData['DESCRIPTION'] ?? null,
$eventData['LOCATION'] ?? null,
$eventData['URL'] ?? null,
$eventData['ALLDAY'] ?? false,
);
$events[] = $event;
continue;
}
if ($inEvent && str_contains($line, ':')) {
// Handle line folding according to RFC 5545
if (str_starts_with($line, ' ') || str_starts_with($line, "\t")) {
if (!empty($eventData)) {
$keys = array_keys($eventData);
$lastKey = end($keys);
$eventData[$lastKey] .= substr($line, 1);
}
continue;
}
[$key, $value] = explode(':', $line, 2);
// Handle parameters like DTSTART;TZID=Europe/Berlin
if (str_contains($key, ';')) {
$parts = explode(';', $key);
$key = $parts[0];
}
$eventData[$key] = $value;
}
}
usort($events, fn ($a, $b) => $a->getStart() <=> $b->getStart());
foreach ($events as $event) {
$calendar->addEvent($event);
}
}
private function parseIcsDate(string $dateString): DateTimeImmutable
{
// Try various date formats that can appear in ICS files
$formats = [
'Ymd\THis\Z', // UTC format
'Ymd\THis', // Local time format
'Ymd', // Date-only format
];
foreach ($formats as $format) {
$date = DateTimeImmutable::createFromFormat($format, $dateString);
if ($date !== false) {
return $date;
}
}
// If all parsing attempts fail, return current time
return new DateTimeImmutable();
}
}

View File

@ -2,7 +2,10 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Core\HomeAssistant; namespace App\Services\HomeAssistant;
use function explode;
use function in_array;
final readonly class EntityState final readonly class EntityState
{ {
@ -12,7 +15,7 @@ final readonly class EntityState
public array $attributes, public array $attributes,
public string $lastChanged, public string $lastChanged,
public string $lastUpdated, public string $lastUpdated,
public array|string|null $context = null public array|string|null $context = null,
) { ) {
} }
@ -24,7 +27,7 @@ final readonly class EntityState
$data['attributes'] ?? [], $data['attributes'] ?? [],
$data['last_changed'] ?? '', $data['last_changed'] ?? '',
$data['last_updated'] ?? '', $data['last_updated'] ?? '',
$data['context'] ?? null $data['context'] ?? null,
); );
} }
@ -41,6 +44,7 @@ final readonly class EntityState
public function getDomain(): string public function getDomain(): string
{ {
$parts = explode('.', $this->entityId, 2); $parts = explode('.', $this->entityId, 2);
return $parts[0]; return $parts[0];
} }

View File

@ -2,11 +2,14 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Core\HomeAssistant; namespace App\Services\HomeAssistant;
use function explode;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface; use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final class HomeAssistantClient final class HomeAssistantClient
{ {
public function __construct( public function __construct(
@ -16,7 +19,7 @@ final class HomeAssistantClient
#[Autowire('%env(HOME_ASSISTANT_TOKEN)%')] #[Autowire('%env(HOME_ASSISTANT_TOKEN)%')]
private readonly string $token, private readonly string $token,
#[Autowire('%env(HOME_ASSISTANT_VERIFY_SSL)%')] #[Autowire('%env(HOME_ASSISTANT_VERIFY_SSL)%')]
private readonly bool $verifySSL private readonly bool $verifySSL,
) { ) {
} }
@ -43,12 +46,14 @@ final class HomeAssistantClient
public function turnOn(string $entityId): array public function turnOn(string $entityId): array
{ {
$domain = explode('.', $entityId)[0]; $domain = explode('.', $entityId)[0];
return $this->callService($domain, 'turn_on', ['entity_id' => $entityId]); return $this->callService($domain, 'turn_on', ['entity_id' => $entityId]);
} }
public function turnOff(string $entityId): array public function turnOff(string $entityId): array
{ {
$domain = explode('.', $entityId)[0]; $domain = explode('.', $entityId)[0];
return $this->callService($domain, 'turn_off', ['entity_id' => $entityId]); return $this->callService($domain, 'turn_off', ['entity_id' => $entityId]);
} }
@ -69,8 +74,8 @@ final class HomeAssistantClient
$response = $this->httpClient->request( $response = $this->httpClient->request(
$method, $method,
$this->baseUrl . $endpoint, $this->baseUrl.$endpoint,
$options $options,
); );
return $this->handleResponse($response); return $this->handleResponse($response);
@ -85,6 +90,7 @@ final class HomeAssistantClient
} }
$content = $response->getContent(false); $content = $response->getContent(false);
throw new HomeAssistantException($content, $statusCode); throw new HomeAssistantException($content, $statusCode);
} }
} }

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Services\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

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Core\HomeAssistant; namespace App\Services\HomeAssistant;
use RuntimeException; use RuntimeException;

View File

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Services\HomeAssistant;
use App\Core\Services\Home\HomeEntityInterface;
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');
}
return array_map(
fn (EntityState $state) => new HomeAssistantEntity($state),
$states,
);
}
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

@ -1,13 +1,17 @@
<?php <?php
namespace App\Core\OpenAI; declare(strict_types=1);
namespace App\Services\OpenAI;
use App\Core\Services\AI\ChatServiceInterface;
use Exception;
use Symfony\Component\HttpFoundation\Exception\BadRequestException; use Symfony\Component\HttpFoundation\Exception\BadRequestException;
class ChatGPTService class ChatGPTService implements ChatServiceInterface
{ {
public function __construct( public function __construct(
private readonly OpenAIClient $openAIClient private readonly OpenAIClient $openAIClient,
) { ) {
} }
@ -27,8 +31,8 @@ class ChatGPTService
} }
return $response['choices'][0]['message']['content']; return $response['choices'][0]['message']['content'];
} catch (\Exception $e) { } catch (Exception $e) {
throw new BadRequestException('Error communicating with OpenAI API: ' . $e->getMessage()); throw new BadRequestException('Error communicating with OpenAI API: '.$e->getMessage());
} }
} }

View File

@ -1,6 +1,8 @@
<?php <?php
namespace App\Core\OpenAI; declare(strict_types=1);
namespace App\Services\OpenAI;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;

View File

@ -26,6 +26,18 @@
"migrations/.gitignore" "migrations/.gitignore"
] ]
}, },
"friendsofphp/php-cs-fixer": {
"version": "3.72",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.0",
"ref": "be2103eb4a20942e28a6dd87736669b757132435"
},
"files": [
".php-cs-fixer.dist.php"
]
},
"symfony/console": { "symfony/console": {
"version": "7.2", "version": "7.2",
"recipe": { "recipe": {

View File

@ -1,16 +1,23 @@
<?php <?php
declare(strict_types=1);
namespace App\Tests\Core\Home\Calendar; namespace App\Tests\Core\Home\Calendar;
use App\Core\Home\Calendar\CalendarEvent; use App\Core\Home\Calendar\CalendarEvent;
use App\Core\Home\Calendar\CalendarInterface; use App\Core\Home\Calendar\CalendarInterface;
use App\Core\Home\Calendar\CalendarService; use App\Core\Home\Calendar\CalendarService;
use DateTime;
use function md5;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
class CalendarServiceTest extends TestCase class CalendarServiceTest extends TestCase
{ {
private CalendarService $calendarService; private CalendarService $calendarService;
private HttpClientInterface $httpClient; private HttpClientInterface $httpClient;
protected function setUp(): void protected function setUp(): void
@ -41,8 +48,8 @@ class CalendarServiceTest extends TestCase
$this->calendarService->addCalendar($calendar2); $this->calendarService->addCalendar($calendar2);
// Test getting all events // Test getting all events
$from = new \DateTime('2023-01-01'); $from = new DateTime('2023-01-01');
$to = new \DateTime('2023-01-03'); $to = new DateTime('2023-01-03');
$events = $this->calendarService->getEvents($from, $to); $events = $this->calendarService->getEvents($from, $to);
@ -68,13 +75,13 @@ class CalendarServiceTest extends TestCase
private function createEvent(string $title, string $start, string $end, string $calendarName): CalendarEvent private function createEvent(string $title, string $start, string $end, string $calendarName): CalendarEvent
{ {
return new CalendarEvent( return new CalendarEvent(
md5($title . $start), md5($title.$start),
$title, $title,
new \DateTime($start), new DateTime($start),
new \DateTime($end), new DateTime($end),
'Description', 'Description',
'Location', 'Location',
$calendarName $calendarName,
); );
} }
} }

View File

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Tests\Services\HomeAssistant;
use App\Core\Services\Home\HomeEntityInterface;
use App\Core\Services\Home\HomeEntityType;
use App\Services\HomeAssistant\EntityState;
use App\Services\HomeAssistant\HomeAssistantEntity;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
final class HomeAssistantEntityTest extends TestCase
{
public function testImplementsHomeEntityInterface(): void
{
$entityState = new EntityState(
'light.living_room',
'on',
['friendly_name' => 'Living Room Light'],
'2023-03-14T12:00:00+00:00',
'2023-03-14T12:00:00+00:00',
);
$entity = new HomeAssistantEntity($entityState);
$this->assertInstanceOf(HomeEntityInterface::class, $entity);
}
public function testGetId(): void
{
$entityState = new EntityState(
'light.living_room',
'on',
['friendly_name' => 'Living Room Light'],
'2023-03-14T12:00:00+00:00',
'2023-03-14T12:00:00+00:00',
);
$entity = new HomeAssistantEntity($entityState);
$this->assertEquals('light.living_room', $entity->getId());
}
public function testGetState(): void
{
$entityState = new EntityState(
'light.living_room',
'on',
['friendly_name' => 'Living Room Light'],
'2023-03-14T12:00:00+00:00',
'2023-03-14T12:00:00+00:00',
);
$entity = new HomeAssistantEntity($entityState);
$this->assertEquals('on', $entity->getState());
}
public function testGetName(): void
{
$entityState = new EntityState(
'light.living_room',
'on',
['friendly_name' => 'Living Room Light'],
'2023-03-14T12:00:00+00:00',
'2023-03-14T12:00:00+00:00',
);
$entity = new HomeAssistantEntity($entityState);
$this->assertEquals('Living Room Light', $entity->getName());
}
public function testGetType(): void
{
$entityState = new EntityState(
'light.living_room',
'on',
['friendly_name' => 'Living Room Light'],
'2023-03-14T12:00:00+00:00',
'2023-03-14T12:00:00+00:00',
);
$entity = new HomeAssistantEntity($entityState);
$this->assertEquals(HomeEntityType::LIGHT, $entity->getType());
}
public function testGetLastChanged(): void
{
$timestamp = '2023-03-14T12:00:00+00:00';
$expectedDateTime = new DateTimeImmutable($timestamp);
$entityState = new EntityState(
'light.living_room',
'on',
['friendly_name' => 'Living Room Light'],
$timestamp,
'2023-03-14T12:30:00+00:00',
);
$entity = new HomeAssistantEntity($entityState);
$this->assertEquals($expectedDateTime, $entity->getLastChanged());
}
public function testGetLastUpdated(): void
{
$timestamp = '2023-03-14T12:30:00+00:00';
$expectedDateTime = new DateTimeImmutable($timestamp);
$entityState = new EntityState(
'light.living_room',
'on',
['friendly_name' => 'Living Room Light'],
'2023-03-14T12:00:00+00:00',
$timestamp,
);
$entity = new HomeAssistantEntity($entityState);
$this->assertEquals($expectedDateTime, $entity->getLastUpdated());
}
}

View File

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Tests\Services\HomeAssistant;
use App\Core\Services\Home\HomeEntityInterface;
use App\Core\Services\Home\HomeServiceInterface;
use App\Services\HomeAssistant\HomeAssistantClient;
use App\Services\HomeAssistant\HomeAssistantEntity;
use App\Services\HomeAssistant\HomeAssistantHomeService;
use PHPUnit\Framework\TestCase;
final class HomeAssistantHomeServiceTest extends TestCase
{
private HomeAssistantClient $homeAssistantClient;
private HomeServiceInterface $homeService;
protected function setUp(): void
{
$this->homeAssistantClient = $this->createMock(HomeAssistantClient::class);
$this->homeService = new HomeAssistantHomeService($this->homeAssistantClient);
}
public function testFindEntity(): void
{
// Create expected raw entity state response
$rawEntityState = [
'entity_id' => 'light.living_room',
'state' => 'on',
'attributes' => ['friendly_name' => 'Living Room Light'],
'last_changed' => '2023-03-14T12:00:00+00:00',
'last_updated' => '2023-03-14T12:00:00+00:00',
];
// Configure the mock to return our test entity state
$this->homeAssistantClient->expects($this->once())
->method('getEntityState')
->with('light.living_room')
->willReturn($rawEntityState)
;
// Call the method under test
$entity = $this->homeService->findEntity('light.living_room');
// Assert the result is a HomeEntityInterface
$this->assertInstanceOf(HomeEntityInterface::class, $entity);
$this->assertInstanceOf(HomeAssistantEntity::class, $entity);
// Assert the properties match
$this->assertEquals('light.living_room', $entity->getId());
$this->assertEquals('on', $entity->getState());
$this->assertEquals('Living Room Light', $entity->getName());
}
public function testFindAllEntities(): void
{
// Create expected raw entity state response
$rawEntityState = [
'entity_id' => 'light.living_room',
'state' => 'on',
'attributes' => ['friendly_name' => 'Living Room Light'],
'last_changed' => '2023-03-14T12:00:00+00:00',
'last_updated' => '2023-03-14T12:00:00+00:00',
];
// Configure the mock to return our test entity states
$this->homeAssistantClient->expects($this->once())
->method('getStates')
->willReturn([$rawEntityState])
;
// Call the method under test
$entities = $this->homeService->findAllEntities();
// Verify result is an array with one entity
$this->assertIsArray($entities);
$this->assertCount(1, $entities);
$entity = $entities[0];
// Assert the result is a HomeEntityInterface
$this->assertInstanceOf(HomeEntityInterface::class, $entity);
$this->assertInstanceOf(HomeAssistantEntity::class, $entity);
// Assert it returns the first entity
$this->assertEquals('light.living_room', $entity->getId());
}
public function testCallService(): void
{
// Expected result from HomeAssistant
$expectedResult = ['success' => true];
// Configure the mock to return our test result
$this->homeAssistantClient->expects($this->once())
->method('callService')
->with('light', 'turn_on', ['entity_id' => 'light.living_room'])
->willReturn($expectedResult)
;
// Call the method under test
$result = $this->homeService->callService('light.turn_on', ['entity_id' => 'light.living_room']);
// Assert the result is as expected
$this->assertEquals($expectedResult, $result);
}
}