Added some abstractions

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

6
.gitignore vendored
View File

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

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

View File

@ -59,7 +59,8 @@
],
"post-update-cmd": [
"@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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -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],
]
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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'),
]);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Core\HomeAssistant;
final readonly class HomeAssistantService
{
public function __construct(
private HomeAssistantClient $client
) {
}
/**
* @return EntityState[]
*/
public function getAllEntityStates(): array
{
$states = $this->client->getStates();
return array_map(
static fn (array $state): EntityState => EntityState::fromArray($state),
$states
);
}
public function getEntityState(string $entityId): EntityState
{
$state = $this->client->getEntityState($entityId);
return EntityState::fromArray($state);
}
/**
* @return EntityState[]
*/
public function getEntitiesByDomain(string $domain): array
{
$allStates = $this->getAllEntityStates();
return array_filter(
$allStates,
static fn (EntityState $state): bool => $state->getDomain() === $domain
);
}
public function turnOn(string $entityId): EntityState
{
$result = $this->client->turnOn($entityId);
return $this->getEntityState($entityId);
}
public function turnOff(string $entityId): EntityState
{
$result = $this->client->turnOff($entityId);
return $this->getEntityState($entityId);
}
public function callService(string $domain, string $service, array $data = []): array
{
return $this->client->callService($domain, $service, $data);
}
/**
* @return string[]
*/
public function getAvailableDomains(): array
{
$services = $this->client->getServices();
return array_keys($services);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Core\Services\Home;
enum HomeEntityType: string
{
case LIGHT = 'light';
case SWITCH = 'switch';
case SENSOR = 'sensor';
case BINARY_SENSOR = 'binary_sensor';
case CLIMATE = 'climate';
case MEDIA_PLAYER = 'media_player';
case SCENE = 'scene';
case SCRIPT = 'script';
case AUTOMATION = 'automation';
case CAMERA = 'camera';
case COVER = 'cover';
case FAN = 'fan';
case LOCK = 'lock';
case VACUUM = 'vacuum';
case WEATHER = 'weather';
case ZONE = 'zone';
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,10 @@
declare(strict_types=1);
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;
}
}
}

View File

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

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Services\HomeAssistant;
use App\Core\Services\Home\HomeEntityInterface;
use App\Core\Services\Home\HomeEntityType;
use DateTimeImmutable;
final readonly class HomeAssistantEntity implements HomeEntityInterface
{
public function __construct(
private EntityState $entityState,
) {
}
public function getId(): string
{
return $this->entityState->entityId;
}
public function getState(): mixed
{
return $this->entityState->state;
}
public function getName(): string
{
return $this->entityState->getName();
}
public function getType(): HomeEntityType
{
$domain = $this->entityState->getDomain();
return match ($domain) {
'light' => HomeEntityType::LIGHT,
'switch' => HomeEntityType::SWITCH,
'sensor' => HomeEntityType::SENSOR,
'binary_sensor' => HomeEntityType::BINARY_SENSOR,
'climate' => HomeEntityType::CLIMATE,
'media_player' => HomeEntityType::MEDIA_PLAYER,
'scene' => HomeEntityType::SCENE,
'script' => HomeEntityType::SCRIPT,
'automation' => HomeEntityType::AUTOMATION,
'camera' => HomeEntityType::CAMERA,
'cover' => HomeEntityType::COVER,
'fan' => HomeEntityType::FAN,
'lock' => HomeEntityType::LOCK,
'vacuum' => HomeEntityType::VACUUM,
'weather' => HomeEntityType::WEATHER,
'zone' => HomeEntityType::ZONE,
default => throw new HomeAssistantException("Unknown entity type: {$domain}")
};
}
public function getLastChanged(): DateTimeImmutable
{
return new DateTimeImmutable($this->entityState->lastChanged);
}
public function getLastUpdated(): DateTimeImmutable
{
return new DateTimeImmutable($this->entityState->lastUpdated);
}
}

View File

@ -2,10 +2,10 @@
declare(strict_types=1);
namespace App\Core\HomeAssistant;
namespace App\Services\HomeAssistant;
use RuntimeException;
final class HomeAssistantException extends RuntimeException
{
}
}

View File

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Services\HomeAssistant;
use App\Core\Services\Home\HomeEntityInterface;
use App\Core\Services\Home\HomeServiceInterface;
use function array_filter;
use function array_keys;
use function array_map;
use function explode;
use function str_contains;
final readonly class HomeAssistantHomeService implements HomeServiceInterface
{
public function __construct(
private HomeAssistantClient $client,
) {
}
public function findEntity(string $entityId): HomeEntityInterface
{
$entityState = $this->getEntityState($entityId);
return new HomeAssistantEntity($entityState);
}
public function findAllEntities(): array
{
$states = $this->getAllEntityStates();
if (empty($states)) {
throw new HomeAssistantException('No entities found');
}
return array_map(
fn (EntityState $state) => new HomeAssistantEntity($state),
$states,
);
}
public function callService(string $service, array $data = []): array
{
// Extract domain and service name from the service string
if (str_contains($service, '.')) {
[$domain, $serviceName] = explode('.', $service, 2);
return $this->client->callService($domain, $serviceName, $data);
}
throw new HomeAssistantException("Invalid service format. Expected 'domain.service'");
}
/**
* @return EntityState[]
*/
public function getAllEntityStates(): array
{
$states = $this->client->getStates();
return array_map(
static fn (array $state): EntityState => EntityState::fromArray($state),
$states,
);
}
public function getEntityState(string $entityId): EntityState
{
$state = $this->client->getEntityState($entityId);
return EntityState::fromArray($state);
}
/**
* @return EntityState[]
*/
public function getEntitiesByDomain(string $domain): array
{
$allStates = $this->getAllEntityStates();
return array_filter(
$allStates,
static fn (EntityState $state): bool => $state->getDomain() === $domain,
);
}
public function turnOn(string $entityId): EntityState
{
$this->client->turnOn($entityId);
return $this->getEntityState($entityId);
}
public function turnOff(string $entityId): EntityState
{
$this->client->turnOff($entityId);
return $this->getEntityState($entityId);
}
/**
* @return string[]
*/
public function getAvailableDomains(): array
{
$services = $this->client->getServices();
return array_keys($services);
}
}

View File

@ -1,13 +1,17 @@
<?php
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,
];
}
}
}

View File

@ -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,
]);
}
}
}

View File

@ -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": {

View File

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

View File

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

View File

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