diff --git a/.gitignore b/.gitignore index d66c7cf..9ca868a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ vendor/ var/ -.env.local \ No newline at end of file +.env.local +###> friendsofphp/php-cs-fixer ### +/.php-cs-fixer.php +/.php-cs-fixer.cache +###< friendsofphp/php-cs-fixer ### diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..c9c7042 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,67 @@ +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) +; \ No newline at end of file diff --git a/composer.json b/composer.json index 748ffd7..dc281ef 100644 --- a/composer.json +++ b/composer.json @@ -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" } } diff --git a/composer.lock b/composer.lock index 79d9caf..20112b4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7d2859d190dfcf0c235a4cf526f044af", + "content-hash": "b91cbe5e5e7804c9053c04c986e1b5cd", "packages": [ { "name": "doctrine/cache", @@ -4329,6 +4329,507 @@ } ], "packages-dev": [ + { + "name": "clue/ndjson-react", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-ndjson.git", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/stream": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", + "react/event-loop": "^1.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\NDJson\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", + "homepage": "https://github.com/clue/reactphp-ndjson", + "keywords": [ + "NDJSON", + "json", + "jsonlines", + "newline", + "reactphp", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/reactphp-ndjson/issues", + "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-12-23T10:58:28+00:00" + }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.3", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", + "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.3" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-09-19T14:15:21+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "8520451a140d3f46ac33042715115e290cf5785f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/8520451a140d3f46ac33042715115e290cf5785f", + "reference": "8520451a140d3f46ac33042715115e290cf5785f", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^1.9.2", + "phpstan/phpstan-deprecation-rules": "^1.0.0", + "phpstan/phpstan-phpunit": "^1.2.2", + "phpstan/phpstan-strict-rules": "^1.4.4", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.2.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2024-08-06T10:04:20+00:00" + }, + { + "name": "friendsofphp/php-cs-fixer", + "version": "v3.72.0", + "source": { + "type": "git", + "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", + "reference": "900389362c43d116fee1ffc51f7878145fa61b57" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/900389362c43d116fee1ffc51f7878145fa61b57", + "reference": "900389362c43d116fee1ffc51f7878145fa61b57", + "shasum": "" + }, + "require": { + "clue/ndjson-react": "^1.0", + "composer/semver": "^3.4", + "composer/xdebug-handler": "^3.0.3", + "ext-filter": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "fidry/cpu-core-counter": "^1.2", + "php": "^7.4 || ^8.0", + "react/child-process": "^0.6.5", + "react/event-loop": "^1.0", + "react/promise": "^2.0 || ^3.0", + "react/socket": "^1.0", + "react/stream": "^1.0", + "sebastian/diff": "^4.0 || ^5.1 || ^6.0 || ^7.0", + "symfony/console": "^5.4 || ^6.4 || ^7.0", + "symfony/event-dispatcher": "^5.4 || ^6.4 || ^7.0", + "symfony/filesystem": "^5.4 || ^6.4 || ^7.0", + "symfony/finder": "^5.4 || ^6.4 || ^7.0", + "symfony/options-resolver": "^5.4 || ^6.4 || ^7.0", + "symfony/polyfill-mbstring": "^1.31", + "symfony/polyfill-php80": "^1.31", + "symfony/polyfill-php81": "^1.31", + "symfony/process": "^5.4 || ^6.4 || ^7.2", + "symfony/stopwatch": "^5.4 || ^6.4 || ^7.0" + }, + "require-dev": { + "facile-it/paraunit": "^1.3.1 || ^2.6", + "infection/infection": "^0.29.14", + "justinrainbow/json-schema": "^5.3 || ^6.2", + "keradus/cli-executor": "^2.1", + "mikey179/vfsstream": "^1.6.12", + "php-coveralls/php-coveralls": "^2.7", + "php-cs-fixer/accessible-object": "^1.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.6", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.6", + "phpunit/phpunit": "^9.6.22 || ^10.5.45 || ^11.5.12", + "symfony/var-dumper": "^5.4.48 || ^6.4.18 || ^7.2.3", + "symfony/yaml": "^5.4.45 || ^6.4.18 || ^7.2.3" + }, + "suggest": { + "ext-dom": "For handling output formats in XML", + "ext-mbstring": "For handling non-UTF8 characters." + }, + "bin": [ + "php-cs-fixer" + ], + "type": "application", + "autoload": { + "psr-4": { + "PhpCsFixer\\": "src/" + }, + "exclude-from-classmap": [ + "src/Fixer/Internal/*" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Dariusz Rumiński", + "email": "dariusz.ruminski@gmail.com" + } + ], + "description": "A tool to automatically fix PHP code style", + "keywords": [ + "Static code analysis", + "fixer", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.72.0" + }, + "funding": [ + { + "url": "https://github.com/keradus", + "type": "github" + } + ], + "time": "2025-03-13T11:25:37+00:00" + }, { "name": "nikic/php-parser", "version": "v5.4.0", @@ -4387,6 +4888,599 @@ }, "time": "2024-12-30T11:07:19+00:00" }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/child-process", + "version": "v0.6.6", + "source": { + "type": "git", + "url": "https://github.com/reactphp/child-process.git", + "reference": "1721e2b93d89b745664353b9cfc8f155ba8a6159" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/1721e2b93d89b745664353b9cfc8f155ba8a6159", + "reference": "1721e2b93d89b745664353b9cfc8f155ba8a6159", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ChildProcess\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.6" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-01-01T16:37:48+00:00" + }, + { + "name": "react/dns", + "version": "v1.13.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/eb8ae001b5a455665c89c1df97f6fb682f8fb0f5", + "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.13.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-13T14:18:03+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.5.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", + "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.5.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2023-11-13T13:48:05+00:00" + }, + { + "name": "react/promise", + "version": "v3.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "8a164643313c71354582dc850b42b33fa12a4b63" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/8a164643313c71354582dc850b42b33fa12a4b63", + "reference": "8a164643313c71354582dc850b42b33fa12a4b63", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.10.39 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-05-24T10:39:05+00:00" + }, + { + "name": "react/socket", + "version": "v1.16.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1", + "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.16.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-07-26T10:38:09+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0", + "symfony/process": "^7.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:46+00:00" + }, { "name": "symfony/maker-bundle", "version": "v1.62.1", @@ -4479,6 +5573,73 @@ ], "time": "2025-01-15T00:21:40+00:00" }, + { + "name": "symfony/options-resolver", + "version": "v7.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "7da8fbac9dcfef75ffc212235d76b2754ce0cf50" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/7da8fbac9dcfef75ffc212235d76b2754ce0cf50", + "reference": "7da8fbac9dcfef75ffc212235d76b2754ce0cf50", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v7.2.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-11-20T11:17:29+00:00" + }, { "name": "symfony/process", "version": "v7.2.4", diff --git a/config/services.yaml b/config/services.yaml index 8247f30..d76d87d 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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 diff --git a/src/Command/ChatGPTCommand.php b/src/Command/ChatGPTCommand.php index c85ec56..70d7bd5 100644 --- a/src/Command/ChatGPTCommand.php +++ b/src/Command/ChatGPTCommand.php @@ -1,8 +1,11 @@ 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; } } diff --git a/src/Command/HomeAssistantCommand.php b/src/Command/HomeAssistantCommand.php index fbcf126..12707c7 100644 --- a/src/Command/HomeAssistantCommand.php +++ b/src/Command/HomeAssistantCommand.php @@ -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], - ] - ); - } } diff --git a/src/Command/ReadCalendarCommand.php b/src/Command/ReadCalendarCommand.php index 913d88f..e36b162 100644 --- a/src/Command/ReadCalendarCommand.php +++ b/src/Command/ReadCalendarCommand.php @@ -1,12 +1,14 @@ 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, ); } } diff --git a/src/Command/RunAgentCommand.php b/src/Command/RunAgentCommand.php index 836497d..abf9ea5 100644 --- a/src/Command/RunAgentCommand.php +++ b/src/Command/RunAgentCommand.php @@ -1,8 +1,11 @@ agent->run(); $output->writeln($result['prompt']); $output->writeln($result['response']); - + return Command::SUCCESS; - } catch (\Exception $e) { - $output->writeln('' . $e->getMessage() . ''); - + } catch (Exception $e) { + $output->writeln(''.$e->getMessage().''); + return Command::FAILURE; } } diff --git a/src/Controller/ChatController.php b/src/Controller/ChatController.php index 8f98720..058c2f9 100644 --- a/src/Controller/ChatController.php +++ b/src/Controller/ChatController.php @@ -1,8 +1,14 @@ 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); } } -} \ No newline at end of file +} diff --git a/src/Controller/WebController.php b/src/Controller/WebController.php index 2b6f50e..054abcf 100644 --- a/src/Controller/WebController.php +++ b/src/Controller/WebController.php @@ -1,5 +1,7 @@ render('chat/index.html.twig'); } -} \ No newline at end of file +} diff --git a/src/Core/Agent/Agent.php b/src/Core/Agent/Agent.php index 0738a47..5a94cd1 100644 --- a/src/Core/Agent/Agent.php +++ b/src/Core/Agent/Agent.php @@ -1,18 +1,22 @@ $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'), ]); } } diff --git a/src/Core/Agent/PromptProvider.php b/src/Core/Agent/PromptProvider.php index 5cfeb1a..3354c92 100644 --- a/src/Core/Agent/PromptProvider.php +++ b/src/Core/Agent/PromptProvider.php @@ -1,5 +1,7 @@ */ - private array $icsCalendars = []; - - public function addIcsCalendar(string $name, string $url): self - { - $this->icsCalendars[$name] = $url; - - return $this; - } - - /** - * @return array - */ - 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]); - } -} \ No newline at end of file diff --git a/src/Core/Home/Calendar/CalendarConfigFactory.php b/src/Core/Home/Calendar/CalendarConfigFactory.php deleted file mode 100644 index 8e4aa1c..0000000 --- a/src/Core/Home/Calendar/CalendarConfigFactory.php +++ /dev/null @@ -1,25 +0,0 @@ - $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; - } -} \ No newline at end of file diff --git a/src/Core/Home/Calendar/CalendarEvent.php b/src/Core/Home/Calendar/CalendarEvent.php deleted file mode 100644 index a58c3e2..0000000 --- a/src/Core/Home/Calendar/CalendarEvent.php +++ /dev/null @@ -1,64 +0,0 @@ -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; - } -} \ No newline at end of file diff --git a/src/Core/Home/Calendar/CalendarFactory.php b/src/Core/Home/Calendar/CalendarFactory.php deleted file mode 100644 index 56c41d4..0000000 --- a/src/Core/Home/Calendar/CalendarFactory.php +++ /dev/null @@ -1,25 +0,0 @@ -httpClient); - - foreach ($this->config->getIcsCalendars() as $name => $url) { - $service->addIcsCalendar($url, $name); - } - - return $service; - } -} \ No newline at end of file diff --git a/src/Core/Home/Calendar/CalendarInterface.php b/src/Core/Home/Calendar/CalendarInterface.php deleted file mode 100644 index 36c4951..0000000 --- a/src/Core/Home/Calendar/CalendarInterface.php +++ /dev/null @@ -1,19 +0,0 @@ -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; - } -} \ No newline at end of file diff --git a/src/Core/Home/Calendar/IcsCalendarProvider.php b/src/Core/Home/Calendar/IcsCalendarProvider.php deleted file mode 100644 index e8ad66d..0000000 --- a/src/Core/Home/Calendar/IcsCalendarProvider.php +++ /dev/null @@ -1,136 +0,0 @@ -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; - } -} \ No newline at end of file diff --git a/src/Core/HomeAssistant/HomeAssistantService.php b/src/Core/HomeAssistant/HomeAssistantService.php deleted file mode 100644 index 6a19818..0000000 --- a/src/Core/HomeAssistant/HomeAssistantService.php +++ /dev/null @@ -1,75 +0,0 @@ -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); - } -} \ No newline at end of file diff --git a/src/Core/Services/AI/ChatServiceInterface.php b/src/Core/Services/AI/ChatServiceInterface.php new file mode 100644 index 0000000..21f34d6 --- /dev/null +++ b/src/Core/Services/AI/ChatServiceInterface.php @@ -0,0 +1,14 @@ +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; + } +} diff --git a/src/Core/Services/Calendar/CalendarEvent.php b/src/Core/Services/Calendar/CalendarEvent.php new file mode 100644 index 0000000..9c62fb1 --- /dev/null +++ b/src/Core/Services/Calendar/CalendarEvent.php @@ -0,0 +1,62 @@ +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; + } +} diff --git a/src/Core/Services/Calendar/CalendarProviderInterface.php b/src/Core/Services/Calendar/CalendarProviderInterface.php new file mode 100644 index 0000000..e480a2b --- /dev/null +++ b/src/Core/Services/Calendar/CalendarProviderInterface.php @@ -0,0 +1,13 @@ + $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; + } +} diff --git a/src/Core/Services/Home/HomeEntityInterface.php b/src/Core/Services/Home/HomeEntityInterface.php new file mode 100644 index 0000000..0cb9b5b --- /dev/null +++ b/src/Core/Services/Home/HomeEntityInterface.php @@ -0,0 +1,22 @@ + + */ + public function findAllEntities(): array; + + public function callService(string $service, array $data = []): array; +} diff --git a/src/Kernel.php b/src/Kernel.php index 779cd1f..ad0fb48 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -1,5 +1,7 @@ $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; + } +} diff --git a/src/Services/Calendar/IcsClient.php b/src/Services/Calendar/IcsClient.php new file mode 100644 index 0000000..9f4a090 --- /dev/null +++ b/src/Services/Calendar/IcsClient.php @@ -0,0 +1,37 @@ +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; + } +} diff --git a/src/Services/Calendar/IcsParser.php b/src/Services/Calendar/IcsParser.php new file mode 100644 index 0000000..7bc1054 --- /dev/null +++ b/src/Services/Calendar/IcsParser.php @@ -0,0 +1,119 @@ +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(); + } +} diff --git a/src/Core/HomeAssistant/EntityState.php b/src/Services/HomeAssistant/EntityState.php similarity index 85% rename from src/Core/HomeAssistant/EntityState.php rename to src/Services/HomeAssistant/EntityState.php index 26ef1c7..d80821c 100644 --- a/src/Core/HomeAssistant/EntityState.php +++ b/src/Services/HomeAssistant/EntityState.php @@ -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; } -} \ No newline at end of file +} diff --git a/src/Core/HomeAssistant/HomeAssistantClient.php b/src/Services/HomeAssistant/HomeAssistantClient.php similarity index 91% rename from src/Core/HomeAssistant/HomeAssistantClient.php rename to src/Services/HomeAssistant/HomeAssistantClient.php index 297b425..dc8923e 100644 --- a/src/Core/HomeAssistant/HomeAssistantClient.php +++ b/src/Services/HomeAssistant/HomeAssistantClient.php @@ -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); } -} \ No newline at end of file +} diff --git a/src/Services/HomeAssistant/HomeAssistantEntity.php b/src/Services/HomeAssistant/HomeAssistantEntity.php new file mode 100644 index 0000000..62e42b9 --- /dev/null +++ b/src/Services/HomeAssistant/HomeAssistantEntity.php @@ -0,0 +1,67 @@ +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); + } +} diff --git a/src/Core/HomeAssistant/HomeAssistantException.php b/src/Services/HomeAssistant/HomeAssistantException.php similarity index 74% rename from src/Core/HomeAssistant/HomeAssistantException.php rename to src/Services/HomeAssistant/HomeAssistantException.php index a9d0784..b70e75a 100644 --- a/src/Core/HomeAssistant/HomeAssistantException.php +++ b/src/Services/HomeAssistant/HomeAssistantException.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace App\Core\HomeAssistant; +namespace App\Services\HomeAssistant; use RuntimeException; final class HomeAssistantException extends RuntimeException { -} \ No newline at end of file +} diff --git a/src/Services/HomeAssistant/HomeAssistantHomeService.php b/src/Services/HomeAssistant/HomeAssistantHomeService.php new file mode 100644 index 0000000..d8ec898 --- /dev/null +++ b/src/Services/HomeAssistant/HomeAssistantHomeService.php @@ -0,0 +1,112 @@ +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); + } +} diff --git a/src/Core/OpenAI/ChatGPTService.php b/src/Services/OpenAI/ChatGPTService.php similarity index 81% rename from src/Core/OpenAI/ChatGPTService.php rename to src/Services/OpenAI/ChatGPTService.php index 3170488..7fa11a1 100644 --- a/src/Core/OpenAI/ChatGPTService.php +++ b/src/Services/OpenAI/ChatGPTService.php @@ -1,13 +1,17 @@ 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, ]; } -} \ No newline at end of file +} diff --git a/src/Core/OpenAI/OpenAIClient.php b/src/Services/OpenAI/OpenAIClient.php similarity index 95% rename from src/Core/OpenAI/OpenAIClient.php rename to src/Services/OpenAI/OpenAIClient.php index 7ecd37a..9cbf5c5 100644 --- a/src/Core/OpenAI/OpenAIClient.php +++ b/src/Services/OpenAI/OpenAIClient.php @@ -1,6 +1,8 @@ $data, ]); } -} \ No newline at end of file +} diff --git a/symfony.lock b/symfony.lock index 76b1f9c..999ff18 100644 --- a/symfony.lock +++ b/symfony.lock @@ -26,6 +26,18 @@ "migrations/.gitignore" ] }, + "friendsofphp/php-cs-fixer": { + "version": "3.72", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "3.0", + "ref": "be2103eb4a20942e28a6dd87736669b757132435" + }, + "files": [ + ".php-cs-fixer.dist.php" + ] + }, "symfony/console": { "version": "7.2", "recipe": { diff --git a/tests/Core/Home/Calendar/CalendarServiceTest.php b/tests/Core/Home/Calendar/CalendarServiceTest.php index 01b7bdf..7e4b1ed 100644 --- a/tests/Core/Home/Calendar/CalendarServiceTest.php +++ b/tests/Core/Home/Calendar/CalendarServiceTest.php @@ -1,24 +1,31 @@ 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, ); } -} \ No newline at end of file +} diff --git a/tests/Services/HomeAssistant/HomeAssistantEntityTest.php b/tests/Services/HomeAssistant/HomeAssistantEntityTest.php new file mode 100644 index 0000000..a8e5c8f --- /dev/null +++ b/tests/Services/HomeAssistant/HomeAssistantEntityTest.php @@ -0,0 +1,126 @@ + '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()); + } +} diff --git a/tests/Services/HomeAssistant/HomeAssistantHomeServiceTest.php b/tests/Services/HomeAssistant/HomeAssistantHomeServiceTest.php new file mode 100644 index 0000000..e572dc4 --- /dev/null +++ b/tests/Services/HomeAssistant/HomeAssistantHomeServiceTest.php @@ -0,0 +1,108 @@ +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); + } +}