Added some abstractions
This commit is contained in:
parent
e8547bd341
commit
9590992c08
4
.gitignore
vendored
4
.gitignore
vendored
@ -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
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": [
|
"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
1163
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Command;
|
namespace App\Command;
|
||||||
|
|
||||||
use App\Core\Home\Calendar\CalendarFactory;
|
use App\Core\Services\Calendar\CalendarService;
|
||||||
|
use DateTime;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
use Symfony\Component\Console\Input\InputOption;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
@ -17,43 +19,27 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
|||||||
class ReadCalendarCommand extends Command
|
class ReadCalendarCommand extends Command
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CalendarFactory $calendarFactory
|
private readonly CalendarService $calendarService,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function configure(): void
|
|
||||||
{
|
|
||||||
$this
|
|
||||||
->addOption('days', 'd', InputOption::VALUE_OPTIONAL, 'Number of days to look ahead', 7)
|
|
||||||
->addOption('group', 'g', InputOption::VALUE_NONE, 'Group events by calendar');
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
{
|
{
|
||||||
$io = new SymfonyStyle($input, $output);
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
$days = (int)$input->getOption('days');
|
$days = 7;
|
||||||
$group = $input->getOption('group');
|
|
||||||
|
|
||||||
$from = new \DateTime();
|
$from = new DateTime();
|
||||||
$to = (new \DateTime())->modify("+$days days");
|
$to = (new DateTime())->modify("+$days days");
|
||||||
|
|
||||||
$calendarService = $this->calendarFactory->createCalendarService();
|
$calendars = $this->calendarService->getCalendars();
|
||||||
|
|
||||||
if ($group) {
|
foreach ($calendars as $calendar) {
|
||||||
$events = $calendarService->getEventsGroupedByCalendar($from, $to);
|
$events = $calendar->getEvents($from, $to);
|
||||||
|
$io->section($calendar->getName());
|
||||||
foreach ($events as $calendarName => $calendarEvents) {
|
|
||||||
$io->section($calendarName);
|
|
||||||
$this->displayEvents($io, $calendarEvents);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Command::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
$events = $calendarService->getEvents($from, $to);
|
|
||||||
$this->displayEvents($io, $events);
|
$this->displayEvents($io, $events);
|
||||||
|
}
|
||||||
|
|
||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
@ -67,13 +53,13 @@ class ReadCalendarCommand extends Command
|
|||||||
$event->getEnd()->format('Y-m-d H:i'),
|
$event->getEnd()->format('Y-m-d H:i'),
|
||||||
$event->getTitle(),
|
$event->getTitle(),
|
||||||
$event->getLocation(),
|
$event->getLocation(),
|
||||||
$event->isAllDay() ? 'Yes' : 'No'
|
$event->isAllDay() ? 'Yes' : 'No',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$io->table(
|
$io->table(
|
||||||
['Start', 'End', 'Title', 'Location', 'All Day'],
|
['Start', 'End', 'Title', 'Location', 'All Day'],
|
||||||
$rows
|
$rows,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Core\Agent;
|
namespace App\Core\Agent;
|
||||||
|
|
||||||
class PromptProvider
|
class PromptProvider
|
||||||
|
|||||||
@ -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
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App;
|
namespace App;
|
||||||
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
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);
|
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];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Core\HomeAssistant;
|
namespace App\Services\HomeAssistant;
|
||||||
|
|
||||||
use RuntimeException;
|
use 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
|
<?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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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;
|
||||||
12
symfony.lock
12
symfony.lock
@ -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": {
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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