Added some abstractions
This commit is contained in:
parent
e8547bd341
commit
9590992c08
6
.gitignore
vendored
6
.gitignore
vendored
@ -1,3 +1,7 @@
|
||||
vendor/
|
||||
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
67
.php-cs-fixer.dist.php
Normal 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)
|
||||
;
|
||||
@ -59,7 +59,8 @@
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@auto-scripts"
|
||||
]
|
||||
],
|
||||
"fix": "PHP_CS_FIXER_IGNORE_ENV=1 php-cs-fixer fix --config=.php-cs-fixer.dist.php"
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/symfony": "*"
|
||||
@ -71,6 +72,7 @@
|
||||
}
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.42",
|
||||
"symfony/maker-bundle": "^1.62"
|
||||
}
|
||||
}
|
||||
|
||||
1163
composer.lock
generated
1163
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@ -24,16 +24,16 @@ services:
|
||||
- '../src/Entity/'
|
||||
- '../src/Kernel.php'
|
||||
|
||||
# Calendar configuration services
|
||||
App\Core\Home\Calendar\CalendarConfig:
|
||||
factory: ['@App\Core\Home\Calendar\CalendarConfigFactory', 'createCalendarConfig']
|
||||
|
||||
App\Core\Home\Calendar\CalendarConfigFactory:
|
||||
App\Core\Services\Calendar\CalendarService:
|
||||
arguments:
|
||||
$providers:
|
||||
- '@App\Services\Calendar\IcsCalendarProvider'
|
||||
|
||||
App\Services\Calendar\IcsCalendarProvider:
|
||||
arguments:
|
||||
$icsCalendars: '%app.calendars.ics%'
|
||||
|
||||
App\Core\Home\Calendar\CalendarService:
|
||||
factory: ['@App\Core\Home\Calendar\CalendarFactory', 'createCalendarService']
|
||||
$icsClient: '@App\Services\Calendar\IcsClient'
|
||||
$icsParser: '@App\Services\Calendar\IcsParser'
|
||||
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@ -17,7 +20,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
class ChatGPTCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ChatGPTService $chatGPTService
|
||||
private readonly ChatServiceInterface $chatService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@ -28,34 +31,35 @@ class ChatGPTCommand extends Command
|
||||
'system-prompt',
|
||||
's',
|
||||
InputOption::VALUE_OPTIONAL,
|
||||
'Initial system prompt to set the context'
|
||||
'Initial system prompt to set the context',
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
|
||||
$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)');
|
||||
|
||||
while (true) {
|
||||
$userMessage = $io->ask('You');
|
||||
|
||||
|
||||
if ($userMessage === 'exit') {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->chatGPTService->sendMessage($userMessage, $conversation);
|
||||
$this->chatGPTService->addMessageToConversation($conversation, $userMessage, 'user');
|
||||
$this->chatGPTService->addMessageToConversation($conversation, $response, 'assistant');
|
||||
|
||||
$io->text(['ChatGPT > ' . $response, '']);
|
||||
} catch (\Exception $e) {
|
||||
$response = $this->chatService->sendMessage($userMessage, $conversation);
|
||||
$this->chatService->addMessageToConversation($conversation, $userMessage, 'user');
|
||||
$this->chatService->addMessageToConversation($conversation, $response, 'assistant');
|
||||
|
||||
$io->text(['ChatGPT > '.$response, '']);
|
||||
} catch (Exception $e) {
|
||||
$io->error($e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
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\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@ -13,13 +17,13 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:home-assistant',
|
||||
description: 'Interact with Home Assistant',
|
||||
name: 'app:home',
|
||||
description: 'Interact with the configured Home Service',
|
||||
)]
|
||||
final class HomeAssistantCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly HomeAssistantService $homeAssistant
|
||||
private readonly HomeServiceInterface $homeService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@ -27,96 +31,46 @@ final class HomeAssistantCommand extends Command
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('list-domains', null, InputOption::VALUE_NONE, 'List all available domains')
|
||||
->addOption('list-entities', null, InputOption::VALUE_NONE, 'List all entities')
|
||||
->addOption('domain', null, InputOption::VALUE_REQUIRED, 'Filter entities by domain')
|
||||
->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
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
if ($input->getOption('list-domains')) {
|
||||
return $this->listDomains($io);
|
||||
}
|
||||
|
||||
if ($input->getOption('list-entities')) {
|
||||
return $this->listEntities($io, $input->getOption('domain'));
|
||||
return $this->listEntities($io);
|
||||
}
|
||||
|
||||
$entityId = $input->getOption('entity-id');
|
||||
|
||||
if ($entityId === null) {
|
||||
$io->error('You must specify an entity ID using --entity-id option');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private function listDomains(SymfonyStyle $io): int
|
||||
private function listEntities(SymfonyStyle $io): int
|
||||
{
|
||||
$domains = $this->homeAssistant->getAvailableDomains();
|
||||
$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();
|
||||
$entities = $this->homeService->findAllEntities();
|
||||
|
||||
$rows = array_map(
|
||||
static fn($entity) => [
|
||||
$entity->entityId,
|
||||
static fn (HomeEntityInterface $entity) => [
|
||||
$entity->getId(),
|
||||
$entity->getName(),
|
||||
$entity->state,
|
||||
$entity->getState(),
|
||||
],
|
||||
$entities
|
||||
$entities,
|
||||
);
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
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],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
@ -17,47 +19,31 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
class ReadCalendarCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CalendarFactory $calendarFactory
|
||||
private readonly CalendarService $calendarService,
|
||||
) {
|
||||
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
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$days = (int)$input->getOption('days');
|
||||
$group = $input->getOption('group');
|
||||
|
||||
$from = new \DateTime();
|
||||
$to = (new \DateTime())->modify("+$days days");
|
||||
|
||||
$calendarService = $this->calendarFactory->createCalendarService();
|
||||
|
||||
if ($group) {
|
||||
$events = $calendarService->getEventsGroupedByCalendar($from, $to);
|
||||
|
||||
foreach ($events as $calendarName => $calendarEvents) {
|
||||
$io->section($calendarName);
|
||||
$this->displayEvents($io, $calendarEvents);
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
$days = 7;
|
||||
|
||||
$from = new DateTime();
|
||||
$to = (new DateTime())->modify("+$days days");
|
||||
|
||||
$calendars = $this->calendarService->getCalendars();
|
||||
|
||||
foreach ($calendars as $calendar) {
|
||||
$events = $calendar->getEvents($from, $to);
|
||||
$io->section($calendar->getName());
|
||||
$this->displayEvents($io, $events);
|
||||
}
|
||||
|
||||
$events = $calendarService->getEvents($from, $to);
|
||||
$this->displayEvents($io, $events);
|
||||
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
|
||||
private function displayEvents(SymfonyStyle $io, array $events): void
|
||||
{
|
||||
$rows = [];
|
||||
@ -67,13 +53,13 @@ class ReadCalendarCommand extends Command
|
||||
$event->getEnd()->format('Y-m-d H:i'),
|
||||
$event->getTitle(),
|
||||
$event->getLocation(),
|
||||
$event->isAllDay() ? 'Yes' : 'No'
|
||||
$event->isAllDay() ? 'Yes' : 'No',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
$io->table(
|
||||
['Start', 'End', 'Title', 'Location', 'All Day'],
|
||||
$rows
|
||||
$rows,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Core\Agent\Agent;
|
||||
use Exception;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@ -15,7 +18,7 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
class RunAgentCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Agent $agent
|
||||
private readonly Agent $agent,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@ -26,11 +29,11 @@ class RunAgentCommand extends Command
|
||||
$result = $this->agent->run();
|
||||
$output->writeln($result['prompt']);
|
||||
$output->writeln($result['response']);
|
||||
|
||||
|
||||
return Command::SUCCESS;
|
||||
} catch (\Exception $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
|
||||
} catch (Exception $e) {
|
||||
$output->writeln('<error>'.$e->getMessage().'</error>');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@ -13,7 +19,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
class ChatController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ChatGPTService $chatGPTService
|
||||
private readonly ChatServiceInterface $chatGPTService,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -21,32 +27,31 @@ class ChatController extends AbstractController
|
||||
public function chat(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
|
||||
if (!isset($data['message'])) {
|
||||
return $this->json(['error' => 'Missing message parameter'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
|
||||
$previousMessages = $data['conversation'] ?? [];
|
||||
|
||||
|
||||
try {
|
||||
$response = $this->chatGPTService->sendMessage($data['message'], $previousMessages);
|
||||
|
||||
// Add user message and AI response to the conversation history
|
||||
|
||||
if (empty($previousMessages)) {
|
||||
$conversation = $this->chatGPTService->createChatConversation();
|
||||
} else {
|
||||
$conversation = $previousMessages;
|
||||
}
|
||||
|
||||
|
||||
$this->chatGPTService->addMessageToConversation($conversation, $data['message'], 'user');
|
||||
$this->chatGPTService->addMessageToConversation($conversation, $response, 'assistant');
|
||||
|
||||
|
||||
return $this->json([
|
||||
'response' => $response,
|
||||
'conversation' => $conversation
|
||||
'conversation' => $conversation,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@ -13,4 +15,4 @@ class WebController extends AbstractController
|
||||
{
|
||||
return $this->render('chat/index.html.twig');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,18 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core\Agent;
|
||||
|
||||
use App\Core\Agent\PromptProvider;
|
||||
use App\Core\Home\Calendar\CalendarService;
|
||||
use App\Core\OpenAI\ChatGPTService;
|
||||
use App\Core\Services\AI\ChatServiceInterface;
|
||||
use App\Core\Services\Calendar\CalendarService;
|
||||
use DateTimeImmutable;
|
||||
|
||||
use function sprintf;
|
||||
use function strtr;
|
||||
|
||||
class Agent
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PromptProvider $promptProvider,
|
||||
private readonly CalendarService $calendarService,
|
||||
private readonly ChatGPTService $chatGPTService
|
||||
private readonly ChatServiceInterface $chatGPTService,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -23,7 +27,7 @@ class Agent
|
||||
|
||||
return [
|
||||
'prompt' => $prompt,
|
||||
'response' => $response
|
||||
'response' => $response,
|
||||
];
|
||||
}
|
||||
|
||||
@ -33,22 +37,20 @@ class Agent
|
||||
$from = $now->modify('-1 day');
|
||||
$to = $now->modify('+7 days');
|
||||
|
||||
$events = $this->calendarService->getEvents($from, $to);
|
||||
$calendars = $this->calendarService->getCalendars();
|
||||
$calendarEventsText = '';
|
||||
|
||||
foreach ($events as $event) {
|
||||
|
||||
foreach ($calendars as $calendar) {
|
||||
$calendarEventsText .= sprintf(
|
||||
"- %s: %s from %s to %s\n",
|
||||
$event->getCalendarName(),
|
||||
$event->getTitle(),
|
||||
$event->getStart()->format('Y-m-d H:i'),
|
||||
$event->getEnd()->format('Y-m-d H:i')
|
||||
"- %s: %s\n",
|
||||
$calendar->getName(),
|
||||
$calendar->getDescription(),
|
||||
);
|
||||
}
|
||||
|
||||
return strtr($this->promptProvider->getPromptTemplate(), [
|
||||
'{calendar_events}' => $calendarEventsText,
|
||||
'{current_time}' => $now->format('Y-m-d H:i:s')
|
||||
'{current_time}' => $now->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core\Agent;
|
||||
|
||||
class PromptProvider
|
||||
@ -7,23 +9,23 @@ class PromptProvider
|
||||
public function getPromptTemplate(): string
|
||||
{
|
||||
return <<<'EOT'
|
||||
You are a high level smart home assistant.
|
||||
You can answer questions and help with tasks.
|
||||
You can also control the smart home devices.
|
||||
You can also control the calendar.
|
||||
Tim and Cara are both members of the household.
|
||||
You have access to the following calendars:
|
||||
- Tim's calendar
|
||||
- Cara's calendar
|
||||
- Household calendar
|
||||
These are the events from the calendars:
|
||||
{calendar_events}
|
||||
|
||||
Its currently {current_time}
|
||||
I will ask you every 5 minutes to perform actions in the smart home.
|
||||
Sometimes you have to do something, but sometimes you dont.
|
||||
So its your turn. What actions to you want to perform?
|
||||
Answer with a JSON array of actions.
|
||||
EOT;
|
||||
You are a high level smart home assistant.
|
||||
You can answer questions and help with tasks.
|
||||
You can also control the smart home devices.
|
||||
You can also control the calendar.
|
||||
Tim and Cara are both members of the household.
|
||||
You have access to the following calendars:
|
||||
- Tim's calendar
|
||||
- Cara's calendar
|
||||
- Household calendar
|
||||
These are the events from the calendars:
|
||||
{calendar_events}
|
||||
|
||||
Its currently {current_time}
|
||||
I will ask you every 5 minutes to perform actions in the smart home.
|
||||
Sometimes you have to do something, but sometimes you dont.
|
||||
So its your turn. What actions to you want to perform?
|
||||
Answer with a JSON array of actions.
|
||||
EOT;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
14
src/Core/Services/AI/ChatServiceInterface.php
Normal file
14
src/Core/Services/AI/ChatServiceInterface.php
Normal 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;
|
||||
}
|
||||
66
src/Core/Services/Calendar/Calendar.php
Normal file
66
src/Core/Services/Calendar/Calendar.php
Normal 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;
|
||||
}
|
||||
}
|
||||
62
src/Core/Services/Calendar/CalendarEvent.php
Normal file
62
src/Core/Services/Calendar/CalendarEvent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
13
src/Core/Services/Calendar/CalendarProviderInterface.php
Normal file
13
src/Core/Services/Calendar/CalendarProviderInterface.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core\Services\Calendar;
|
||||
|
||||
interface CalendarProviderInterface
|
||||
{
|
||||
/**
|
||||
* @return Calendar[]
|
||||
*/
|
||||
public function getCalendars(): array;
|
||||
}
|
||||
35
src/Core/Services/Calendar/CalendarService.php
Normal file
35
src/Core/Services/Calendar/CalendarService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
22
src/Core/Services/Home/HomeEntityInterface.php
Normal file
22
src/Core/Services/Home/HomeEntityInterface.php
Normal 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;
|
||||
}
|
||||
25
src/Core/Services/Home/HomeEntityType.php
Normal file
25
src/Core/Services/Home/HomeEntityType.php
Normal 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';
|
||||
}
|
||||
17
src/Core/Services/Home/HomeServiceInterface.php
Normal file
17
src/Core/Services/Home/HomeServiceInterface.php
Normal 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;
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||
|
||||
48
src/Services/Calendar/IcsCalendarProvider.php
Normal file
48
src/Services/Calendar/IcsCalendarProvider.php
Normal 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;
|
||||
}
|
||||
}
|
||||
37
src/Services/Calendar/IcsClient.php
Normal file
37
src/Services/Calendar/IcsClient.php
Normal 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;
|
||||
}
|
||||
}
|
||||
119
src/Services/Calendar/IcsParser.php
Normal file
119
src/Services/Calendar/IcsParser.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core\HomeAssistant;
|
||||
namespace App\Services\HomeAssistant;
|
||||
|
||||
use function explode;
|
||||
use function in_array;
|
||||
|
||||
final readonly class EntityState
|
||||
{
|
||||
@ -12,10 +15,10 @@ final readonly class EntityState
|
||||
public array $attributes,
|
||||
public string $lastChanged,
|
||||
public string $lastUpdated,
|
||||
public array|string|null $context = null
|
||||
public array|string|null $context = null,
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
@ -24,28 +27,29 @@ final readonly class EntityState
|
||||
$data['attributes'] ?? [],
|
||||
$data['last_changed'] ?? '',
|
||||
$data['last_updated'] ?? '',
|
||||
$data['context'] ?? null
|
||||
$data['context'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
public function isOn(): bool
|
||||
{
|
||||
return in_array($this->state, ['on', 'home', 'open', 'unlocked', 'active'], true);
|
||||
}
|
||||
|
||||
|
||||
public function isOff(): bool
|
||||
{
|
||||
return in_array($this->state, ['off', 'away', 'closed', 'locked', 'inactive'], true);
|
||||
}
|
||||
|
||||
|
||||
public function getDomain(): string
|
||||
{
|
||||
$parts = explode('.', $this->entityId, 2);
|
||||
|
||||
return $parts[0];
|
||||
}
|
||||
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->attributes['friendly_name'] ?? $this->entityId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,11 +2,14 @@
|
||||
|
||||
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\ResponseInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
final class HomeAssistantClient
|
||||
{
|
||||
public function __construct(
|
||||
@ -16,7 +19,7 @@ final class HomeAssistantClient
|
||||
#[Autowire('%env(HOME_ASSISTANT_TOKEN)%')]
|
||||
private readonly string $token,
|
||||
#[Autowire('%env(HOME_ASSISTANT_VERIFY_SSL)%')]
|
||||
private readonly bool $verifySSL
|
||||
private readonly bool $verifySSL,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -24,34 +27,36 @@ final class HomeAssistantClient
|
||||
{
|
||||
return $this->request('GET', '/api/states');
|
||||
}
|
||||
|
||||
|
||||
public function getServices(): array
|
||||
{
|
||||
return $this->request('GET', '/api/services');
|
||||
}
|
||||
|
||||
|
||||
public function getEntityState(string $entityId): array
|
||||
{
|
||||
return $this->request('GET', "/api/states/{$entityId}");
|
||||
}
|
||||
|
||||
|
||||
public function callService(string $domain, string $service, array $data = []): array
|
||||
{
|
||||
return $this->request('POST', "/api/services/{$domain}/{$service}", $data);
|
||||
}
|
||||
|
||||
|
||||
public function turnOn(string $entityId): array
|
||||
{
|
||||
$domain = explode('.', $entityId)[0];
|
||||
|
||||
return $this->callService($domain, 'turn_on', ['entity_id' => $entityId]);
|
||||
}
|
||||
|
||||
|
||||
public function turnOff(string $entityId): array
|
||||
{
|
||||
$domain = explode('.', $entityId)[0];
|
||||
|
||||
return $this->callService($domain, 'turn_off', ['entity_id' => $entityId]);
|
||||
}
|
||||
|
||||
|
||||
private function request(string $method, string $endpoint, array $data = []): array
|
||||
{
|
||||
$options = [
|
||||
@ -62,29 +67,30 @@ final class HomeAssistantClient
|
||||
'verify_peer' => $this->verifySSL,
|
||||
'verify_host' => $this->verifySSL,
|
||||
];
|
||||
|
||||
|
||||
if (!empty($data)) {
|
||||
$options['json'] = $data;
|
||||
}
|
||||
|
||||
|
||||
$response = $this->httpClient->request(
|
||||
$method,
|
||||
$this->baseUrl . $endpoint,
|
||||
$options
|
||||
$this->baseUrl.$endpoint,
|
||||
$options,
|
||||
);
|
||||
|
||||
|
||||
return $this->handleResponse($response);
|
||||
}
|
||||
|
||||
|
||||
private function handleResponse(ResponseInterface $response): array
|
||||
{
|
||||
$statusCode = $response->getStatusCode();
|
||||
|
||||
|
||||
if ($statusCode >= 200 && $statusCode < 300) {
|
||||
return $response->toArray();
|
||||
}
|
||||
|
||||
|
||||
$content = $response->getContent(false);
|
||||
|
||||
throw new HomeAssistantException($content, $statusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
67
src/Services/HomeAssistant/HomeAssistantEntity.php
Normal file
67
src/Services/HomeAssistant/HomeAssistantEntity.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -2,10 +2,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core\HomeAssistant;
|
||||
namespace App\Services\HomeAssistant;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class HomeAssistantException extends RuntimeException
|
||||
{
|
||||
}
|
||||
}
|
||||
112
src/Services/HomeAssistant/HomeAssistantHomeService.php
Normal file
112
src/Services/HomeAssistant/HomeAssistantHomeService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,17 @@
|
||||
<?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;
|
||||
|
||||
class ChatGPTService
|
||||
class ChatGPTService implements ChatServiceInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OpenAIClient $openAIClient
|
||||
private readonly OpenAIClient $openAIClient,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -21,31 +25,31 @@ class ChatGPTService
|
||||
|
||||
try {
|
||||
$response = $this->openAIClient->chat($messages);
|
||||
|
||||
|
||||
if (!isset($response['choices'][0]['message']['content'])) {
|
||||
throw new BadRequestException('Invalid response from OpenAI API');
|
||||
}
|
||||
|
||||
return $response['choices'][0]['message']['content'];
|
||||
} catch (\Exception $e) {
|
||||
throw new BadRequestException('Error communicating with OpenAI API: ' . $e->getMessage());
|
||||
} catch (Exception $e) {
|
||||
throw new BadRequestException('Error communicating with OpenAI API: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function createChatConversation(array $systemPrompt = []): array
|
||||
{
|
||||
$conversation = [];
|
||||
|
||||
|
||||
if (!empty($systemPrompt)) {
|
||||
$conversation[] = [
|
||||
'role' => 'system',
|
||||
'content' => $systemPrompt,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
return $conversation;
|
||||
}
|
||||
|
||||
|
||||
public function addMessageToConversation(array &$conversation, string $message, string $role = 'user'): void
|
||||
{
|
||||
$conversation[] = [
|
||||
@ -53,4 +57,4 @@ class ChatGPTService
|
||||
'content' => $message,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core\OpenAI;
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\OpenAI;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
@ -41,4 +43,4 @@ class OpenAIClient
|
||||
'json' => $data,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
symfony.lock
12
symfony.lock
@ -26,6 +26,18 @@
|
||||
"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": {
|
||||
"version": "7.2",
|
||||
"recipe": {
|
||||
|
||||
@ -1,24 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Core\Home\Calendar;
|
||||
|
||||
use App\Core\Home\Calendar\CalendarEvent;
|
||||
use App\Core\Home\Calendar\CalendarInterface;
|
||||
use App\Core\Home\Calendar\CalendarService;
|
||||
use DateTime;
|
||||
|
||||
use function md5;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class CalendarServiceTest extends TestCase
|
||||
{
|
||||
private CalendarService $calendarService;
|
||||
|
||||
private HttpClientInterface $httpClient;
|
||||
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->httpClient = $this->createMock(HttpClientInterface::class);
|
||||
$this->calendarService = new CalendarService($this->httpClient);
|
||||
}
|
||||
|
||||
|
||||
public function testGetEventsFromMultipleCalendars(): void
|
||||
{
|
||||
// Create mock calendar providers
|
||||
@ -28,53 +35,53 @@ class CalendarServiceTest extends TestCase
|
||||
$this->createEvent('Event 1', '2023-01-01 10:00', '2023-01-01 11:00', 'Calendar 1'),
|
||||
$this->createEvent('Event 2', '2023-01-02 15:00', '2023-01-02 16:00', 'Calendar 1'),
|
||||
]);
|
||||
|
||||
|
||||
$calendar2 = $this->createMock(CalendarInterface::class);
|
||||
$calendar2->method('getName')->willReturn('Calendar 2');
|
||||
$calendar2->method('getEvents')->willReturn([
|
||||
$this->createEvent('Event 3', '2023-01-01 12:00', '2023-01-01 13:00', 'Calendar 2'),
|
||||
$this->createEvent('Event 4', '2023-01-03 09:00', '2023-01-03 10:00', 'Calendar 2'),
|
||||
]);
|
||||
|
||||
|
||||
// Add calendar providers to service
|
||||
$this->calendarService->addCalendar($calendar1);
|
||||
$this->calendarService->addCalendar($calendar2);
|
||||
|
||||
|
||||
// Test getting all events
|
||||
$from = new \DateTime('2023-01-01');
|
||||
$to = new \DateTime('2023-01-03');
|
||||
|
||||
$from = new DateTime('2023-01-01');
|
||||
$to = new DateTime('2023-01-03');
|
||||
|
||||
$events = $this->calendarService->getEvents($from, $to);
|
||||
|
||||
|
||||
// Assertions
|
||||
$this->assertCount(4, $events);
|
||||
|
||||
|
||||
// Check if events are sorted by start date
|
||||
$this->assertEquals('Event 1', $events[0]->getTitle());
|
||||
$this->assertEquals('Event 3', $events[1]->getTitle());
|
||||
$this->assertEquals('Event 2', $events[2]->getTitle());
|
||||
$this->assertEquals('Event 4', $events[3]->getTitle());
|
||||
|
||||
|
||||
// Test getting events grouped by calendar
|
||||
$groupedEvents = $this->calendarService->getEventsGroupedByCalendar($from, $to);
|
||||
|
||||
|
||||
$this->assertCount(2, $groupedEvents);
|
||||
$this->assertArrayHasKey('Calendar 1', $groupedEvents);
|
||||
$this->assertArrayHasKey('Calendar 2', $groupedEvents);
|
||||
$this->assertCount(2, $groupedEvents['Calendar 1']);
|
||||
$this->assertCount(2, $groupedEvents['Calendar 2']);
|
||||
}
|
||||
|
||||
|
||||
private function createEvent(string $title, string $start, string $end, string $calendarName): CalendarEvent
|
||||
{
|
||||
return new CalendarEvent(
|
||||
md5($title . $start),
|
||||
md5($title.$start),
|
||||
$title,
|
||||
new \DateTime($start),
|
||||
new \DateTime($end),
|
||||
new DateTime($start),
|
||||
new DateTime($end),
|
||||
'Description',
|
||||
'Location',
|
||||
$calendarName
|
||||
$calendarName,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
126
tests/Services/HomeAssistant/HomeAssistantEntityTest.php
Normal file
126
tests/Services/HomeAssistant/HomeAssistantEntityTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
108
tests/Services/HomeAssistant/HomeAssistantHomeServiceTest.php
Normal file
108
tests/Services/HomeAssistant/HomeAssistantHomeServiceTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user