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,44 +19,28 @@ 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) { $this->displayEvents($io, $events);
$io->section($calendarName);
$this->displayEvents($io, $calendarEvents);
}
return Command::SUCCESS;
} }
$events = $calendarService->getEvents($from, $to);
$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
@ -7,23 +9,23 @@ class PromptProvider
public function getPromptTemplate(): string public function getPromptTemplate(): string
{ {
return <<<'EOT' return <<<'EOT'
You are a high level smart home assistant. You are a high level smart home assistant.
You can answer questions and help with tasks. You can answer questions and help with tasks.
You can also control the smart home devices. You can also control the smart home devices.
You can also control the calendar. You can also control the calendar.
Tim and Cara are both members of the household. Tim and Cara are both members of the household.
You have access to the following calendars: You have access to the following calendars:
- Tim's calendar - Tim's calendar
- Cara's calendar - Cara's calendar
- Household calendar - Household calendar
These are the events from the calendars: These are the events from the calendars:
{calendar_events} {calendar_events}
Its currently {current_time} Its currently {current_time}
I will ask you every 5 minutes to perform actions in the smart home. I will ask you every 5 minutes to perform actions in the smart home.
Sometimes you have to do something, but sometimes you dont. Sometimes you have to do something, but sometimes you dont.
So its your turn. What actions to you want to perform? So its your turn. What actions to you want to perform?
Answer with a JSON array of actions. Answer with a JSON array of actions.
EOT; EOT;
} }
} }

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);
}
}