Added some abstractions

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

4
.gitignore vendored
View File

@ -1,3 +1,7 @@
vendor/
var/
.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\Services\Calendar\CalendarService:
arguments:
$providers:
- '@App\Services\Calendar\IcsCalendarProvider'
App\Core\Home\Calendar\CalendarConfigFactory:
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,7 +31,7 @@ class ChatGPTCommand extends Command
'system-prompt',
's',
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);
$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)');
@ -49,13 +52,14 @@ class ChatGPTCommand extends Command
}
try {
$response = $this->chatGPTService->sendMessage($userMessage, $conversation);
$this->chatGPTService->addMessageToConversation($conversation, $userMessage, 'user');
$this->chatGPTService->addMessageToConversation($conversation, $response, 'assistant');
$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) {
} 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,43 +19,27 @@ 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');
$days = 7;
$from = new \DateTime();
$to = (new \DateTime())->modify("+$days days");
$from = new DateTime();
$to = (new DateTime())->modify("+$days days");
$calendarService = $this->calendarFactory->createCalendarService();
$calendars = $this->calendarService->getCalendars();
if ($group) {
$events = $calendarService->getEventsGroupedByCalendar($from, $to);
foreach ($events as $calendarName => $calendarEvents) {
$io->section($calendarName);
$this->displayEvents($io, $calendarEvents);
}
return Command::SUCCESS;
}
$events = $calendarService->getEvents($from, $to);
foreach ($calendars as $calendar) {
$events = $calendar->getEvents($from, $to);
$io->section($calendar->getName());
$this->displayEvents($io, $events);
}
return Command::SUCCESS;
}
@ -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();
}
@ -28,7 +31,7 @@ class RunAgentCommand extends Command
$output->writeln($result['response']);
return Command::SUCCESS;
} catch (\Exception $e) {
} 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,
) {
}
@ -31,7 +37,6 @@ class ChatController extends AbstractController
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 {
@ -43,9 +48,9 @@ class ChatController extends AbstractController
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;

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

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,7 +15,7 @@ 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,
) {
}
@ -24,7 +27,7 @@ final readonly class EntityState
$data['attributes'] ?? [],
$data['last_changed'] ?? '',
$data['last_updated'] ?? '',
$data['context'] ?? null
$data['context'] ?? null,
);
}
@ -41,6 +44,7 @@ final readonly class EntityState
public function getDomain(): string
{
$parts = explode('.', $this->entityId, 2);
return $parts[0];
}

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,
) {
}
@ -43,12 +46,14 @@ final class HomeAssistantClient
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]);
}
@ -70,7 +75,7 @@ final class HomeAssistantClient
$response = $this->httpClient->request(
$method,
$this->baseUrl.$endpoint,
$options
$options,
);
return $this->handleResponse($response);
@ -85,6 +90,7 @@ final class HomeAssistantClient
}
$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,7 +2,7 @@
declare(strict_types=1);
namespace App\Core\HomeAssistant;
namespace App\Services\HomeAssistant;
use RuntimeException;

View File

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

View File

@ -1,13 +1,17 @@
<?php
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,
) {
}
@ -27,7 +31,7 @@ class ChatGPTService
}
return $response['choices'][0]['message']['content'];
} catch (\Exception $e) {
} catch (Exception $e) {
throw new BadRequestException('Error communicating with OpenAI API: '.$e->getMessage());
}
}

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;

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,16 +1,23 @@
<?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
@ -41,8 +48,8 @@ class CalendarServiceTest extends TestCase
$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);
@ -70,11 +77,11 @@ class CalendarServiceTest extends TestCase
return new CalendarEvent(
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);
}
}