From 3cfc9e99fb598a1842ce3e479ff6d260133e8a2e Mon Sep 17 00:00:00 2001 From: Malachi Soord Date: Wed, 25 Jul 2018 21:55:44 +0200 Subject: [PATCH] Add tests and add more functionality --- .gitignore | 3 +- .php_cs.dist | 13 ++ LICENSE | 21 ++++ README.md | 43 ++++++- composer.json | 11 +- phpunit.xml.dist | 12 +- src/ApiClient.php | 116 +++++++++++++++++- src/Device/AbstractDevice.php | 80 ++++++++++++ src/Device/DeviceEvent.php | 43 +++++++ src/Device/DeviceFactory.php | 27 ++++ src/Device/Stateful.php | 21 ++++ src/Device/SwitchDevice.php | 20 +++ src/Exception/TuyaClientException.php | 9 ++ src/Region.php | 23 +++- src/Session.php | 25 +++- src/Token.php | 52 ++++++++ tests/Functional/ApiClientTest.php | 26 ++++ .../BaseTestCase.php} | 12 +- tests/Tests/Unit/RegionTest.php | 54 ++++++++ tests/bootstrap.php | 1 - 20 files changed, 591 insertions(+), 21 deletions(-) create mode 100644 .php_cs.dist create mode 100644 LICENSE create mode 100644 src/Device/AbstractDevice.php create mode 100644 src/Device/DeviceEvent.php create mode 100644 src/Device/DeviceFactory.php create mode 100644 src/Device/Stateful.php create mode 100644 src/Device/SwitchDevice.php create mode 100644 src/Exception/TuyaClientException.php create mode 100644 src/Token.php create mode 100644 tests/Functional/ApiClientTest.php rename tests/{ApiClientTest.php => Functional/BaseTestCase.php} (50%) create mode 100644 tests/Tests/Unit/RegionTest.php diff --git a/.gitignore b/.gitignore index 3ee6f9b..6d2a7e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /vendor/ /composer.lock /.idea/ -/phpunit.xml \ No newline at end of file +/phpunit.xml +.php_cs.cache \ No newline at end of file diff --git a/.php_cs.dist b/.php_cs.dist new file mode 100644 index 0000000..0c18417 --- /dev/null +++ b/.php_cs.dist @@ -0,0 +1,13 @@ +in(['src', 'tests']) +; + +return PhpCsFixer\Config::create() + ->setRules([ + '@PSR2' => true, + ]) + ->setUsingCache(true) + ->setRiskyAllowed(true) + ->setFinder($finder) + ; \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5a0d78f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Malachi Soord + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 506a12d..e146b19 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,47 @@ # API Client for Tuya -An API Client build for [Tuya][1] heavily inspired by [tuyapy][0]. +An WIP API Client build for [Tuya][1] heavily inspired by [tuyapy][0]. + +## Currently Supporting + +- Switch devices + +## Example + +```php +require __DIR__ . '../vendor/autoload.php'; + +use Inverse\TuyaClient\Session; +use Inverse\TuyaClient\ApiClient; +use Inverse\TuyaClient\Device\SwitchDevice; + +// Setup credentials +$username = getenv('TUYA_USERNAME'); +$password = getenv('TUYA_PASSWORD'); +$countryCode = getenv('TUYA_COUNTRYCODE'); + +// Make client +$session = new Session($username, $password, $countryCode); +$apiClient = new ApiClient($session); + +// Get all devices +$devices = $apiClient->discoverDevices(); + +// Switch on all switches +foreach ($devices as $device) { + if ($device instanceOf SwitchDevice) { + $apiClient->sendEvent($device->getOnEvent()); + } +} +``` + +## Testing + +Copy `phpunit.xml.dist` to `phpunit.xml` and replace the server variables with your credentials. + +## Licence + +MIT [0]: https://pypi.org/project/tuyapy [1]: https://www.tuya.com/ \ No newline at end of file diff --git a/composer.json b/composer.json index 45f8393..6cde12d 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,9 @@ "require": { "php": "^7.1", "guzzlehttp/guzzle": "^6.3", - "webmozart/assert": "^1.3", + "webmozart/assert": "^1.3" + }, + "require-dev": { "phpunit/phpunit": "^7.2" }, "autoload": { @@ -22,7 +24,12 @@ }, "autoload-dev": { "psr-4": { - "Tests\\Inverse\\TuyaClient\\": "tests/" + "Tests\\Functional\\Inverse\\TuyaClient\\": "tests/Functional", + "Tests\\Unit\\Inverse\\TuyaClient\\": "tests/unit" } + }, + "scripts": { + "test": "phpunit --testsuite unit", + "cs": "php-cs-fixer fix" } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d31c318..369ab1f 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -8,12 +8,18 @@ convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="true" - syntaxCheck="true" - bootstrap="./tests/bootstrap.php" -> + bootstrap="./tests/bootstrap.php"> + + + tests/Unit + + + tests/Functional + + \ No newline at end of file diff --git a/src/ApiClient.php b/src/ApiClient.php index 71de9ae..7856d88 100644 --- a/src/ApiClient.php +++ b/src/ApiClient.php @@ -5,6 +5,9 @@ use GuzzleHttp\Client; use GuzzleHttp\Psr7\Uri; use GuzzleHttp\Psr7\UriResolver; +use Inverse\TuyaClient\Device\DeviceEvent; +use Inverse\TuyaClient\Device\DeviceFactory; +use Inverse\TuyaClient\Exception\TuyaClientException; use Psr\Http\Message\UriInterface; class ApiClient @@ -21,17 +24,79 @@ class ApiClient */ private $session; + /** + * @var DeviceFactory + */ + private $deviceFactory; + + public function __construct(Session $session, Client $client = null) { $this->session = $session; $this->client = $client ?? new Client(); + $this->deviceFactory = new DeviceFactory(); + } + + public function discoverDevices(): array + { + $response = $this->request('Discovery', 'discovery'); + + $devices = []; + foreach ($response['payload']['devices'] as $device) { + $devices[] = $this->deviceFactory->fromArray($device); + } + + return $devices; + } + + public function sendEvent(DeviceEvent $event, $namespace = 'control') + { + $this->request($event->getAction(), $namespace, $event->getDeviceId(), $event->getPayload()); } - public function getAccessToken() + private function request(string $name, string $namespace, string $deviceId = null, array $payload = []): array { - $baseUrl = $this->getBaseUrl($this->session); + if (!$this->session->hasAccessToken()) { + $this->session->setToken($this->getAccessToken()); + } + + if (!$this->isAccessTokenValid()) { + $this->session->setToken($this->refreshAccessToken()); + } + + $uri = UriResolver::resolve($this->getBaseUrl($this->session), new Uri('homeassistant/skill')); + + $header = [ + 'name' => $name, + 'namespace' => $namespace, + 'payloadVersion' => 1, + ]; + + $payload['accessToken'] = $this->session->getToken()->getAccessToken(); + + if ($deviceId) { + $payload['devId'] = $deviceId; + } + + $data = [ + 'header' => $header, + 'payload' => $payload, + ]; + + $response = $this->client->post($uri, [ + 'json' => $data, + ]); + + $response = json_decode((string)$response->getBody(), true); + + $this->validate($response, sprintf('Failed to get response from %s', $name)); + + return $response; + } - $uri = UriResolver::resolve($baseUrl, new Uri('homeassistant/auth.do')); + private function getAccessToken(): Token + { + $uri = UriResolver::resolve($this->getBaseUrl($this->session), new Uri('homeassistant/auth.do')); $response = $this->client->request('POST', $uri, [ 'form_params' => [ @@ -44,11 +109,50 @@ public function getAccessToken() $response = json_decode((string)$response->getBody(), true); - return $response; + $this->validate($response, 'An error occurred while fetching access token'); + + $token = Token::fromArray($response); + + return $token; + } + + private function refreshAccessToken(): Token + { + $uri = UriResolver::resolve($this->getBaseUrl($this->session), new Uri('homeassistant/access.do')); + + $response = $this->client->request('GET', $uri, [ + 'query' => [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $this->session->getToken()->getRefreshToken(), + ] + ]); + + $response = json_decode((string)$response->getBody(), true); + + $this->validate($response, 'Failed to refresh access token'); + + $token = Token::fromArray($response); + + return $token; + } + + private function isAccessTokenValid(): bool + { + $token = $this->session->getToken(); + + return time() + $token->getExpireTime() > time(); } private function getBaseUrl(Session $session): UriInterface { - return new Uri(sprintf(self::BASE_URL_FORMAT, $session->getRegion()->getValue())); + return new Uri(sprintf(self::BASE_URL_FORMAT, $session->getRegion())); + } + + private function validate(array $response, string $message = null) + { + if (isset($response['responseStatus']) && $response['responseStatus'] === 'error') { + $message = $message ?? $response['responseMsg']; + throw new TuyaClientException($message); + } } -} \ No newline at end of file +} diff --git a/src/Device/AbstractDevice.php b/src/Device/AbstractDevice.php new file mode 100644 index 0000000..d04e8c0 --- /dev/null +++ b/src/Device/AbstractDevice.php @@ -0,0 +1,80 @@ +name = $name; + $this->icon = $icon; + $this->id = $id; + $this->devType = $devType; + $this->haType = $haType; + } + + public function getName(): string + { + return $this->name; + } + + public function getIcon(): string + { + return $this->icon; + } + + public function getId(): string + { + return $this->id; + } + + public function getDevType(): string + { + return $this->devType; + } + + public function getHaType(): string + { + return $this->haType; + } + + public function isOnline(): bool + { + return $this->isOnline; + } + + public function setIsOnline(bool $isOnline): void + { + $this->isOnline = $isOnline; + } +} diff --git a/src/Device/DeviceEvent.php b/src/Device/DeviceEvent.php new file mode 100644 index 0000000..c8178dc --- /dev/null +++ b/src/Device/DeviceEvent.php @@ -0,0 +1,43 @@ +deviceId = $deviceId; + $this->action = $action; + $this->payload = $payload; + } + + public function getDeviceId(): string + { + return $this->deviceId; + } + + public function getAction(): string + { + return $this->action; + } + + public function getPayload(): array + { + return $this->payload; + } +} diff --git a/src/Device/DeviceFactory.php b/src/Device/DeviceFactory.php new file mode 100644 index 0000000..e8b887f --- /dev/null +++ b/src/Device/DeviceFactory.php @@ -0,0 +1,27 @@ +setIsOnline($data['data']['online']); + $device->setState($data['data']['state']); + break; + } + + return $device; + } +} diff --git a/src/Device/Stateful.php b/src/Device/Stateful.php new file mode 100644 index 0000000..82d86f2 --- /dev/null +++ b/src/Device/Stateful.php @@ -0,0 +1,21 @@ +state; + } + + public function setState(bool $state): void + { + $this->state = $state; + } +} diff --git a/src/Device/SwitchDevice.php b/src/Device/SwitchDevice.php new file mode 100644 index 0000000..d8f9b79 --- /dev/null +++ b/src/Device/SwitchDevice.php @@ -0,0 +1,20 @@ +id, self::ACTION_NAME, ['value' => 1]); + } + + public function getOffEvent(): DeviceEvent + { + return new DeviceEvent($this->id, self::ACTION_NAME, ['value' => 0]); + } +} diff --git a/src/Exception/TuyaClientException.php b/src/Exception/TuyaClientException.php new file mode 100644 index 0000000..eeaff65 --- /dev/null +++ b/src/Exception/TuyaClientException.php @@ -0,0 +1,9 @@ +value = $value; } - public function getValue(): string + public static function fromAccessToken(Token $token): self + { + $prefix = substr($token->getAccessToken(), 0, 2); + $value = ''; + switch ($prefix) { + case 'AY': + $value = self::CN; + break; + case 'EU': + $value = self::EU; + break; + case 'US': + $value = self::US; + break; + } + + return new self($value); + } + + public function __toString() { return $this->value; } -} \ No newline at end of file +} diff --git a/src/Session.php b/src/Session.php index 2f0025b..97fa3ed 100644 --- a/src/Session.php +++ b/src/Session.php @@ -24,12 +24,17 @@ class Session */ private $region; - public function __construct(string $username, string $password, int $countryCode) + /** + * @var Token + */ + private $token; + + public function __construct(string $username, string $password, int $countryCode, ?Region $region = null) { $this->username = $username; $this->password = $password; $this->countryCode = $countryCode; - $this->region = new Region(Region::US); + $this->region = $region ?? new Region(Region::US); } public function getUsername(): string @@ -51,4 +56,20 @@ public function getRegion(): Region { return $this->region; } + + public function hasAccessToken(): bool + { + return $this->token instanceof Token; + } + + public function getToken(): Token + { + return $this->token; + } + + public function setToken(Token $token): void + { + $this->token = $token; + $this->region = Region::fromAccessToken($token); + } } diff --git a/src/Token.php b/src/Token.php new file mode 100644 index 0000000..bfe51aa --- /dev/null +++ b/src/Token.php @@ -0,0 +1,52 @@ +accessToken = $accessToken; + $this->refreshToken = $refreshToken; + $this->expireTime = $expireTime; + } + + public function getAccessToken(): string + { + return $this->accessToken; + } + + public function getRefreshToken(): string + { + return $this->refreshToken; + } + + public function getExpireTime(): int + { + return $this->expireTime; + } + + public static function fromArray(array $data): self + { + return new self( + $data['access_token'], + $data['refresh_token'], + $data['expires_in'] + ); + } +} diff --git a/tests/Functional/ApiClientTest.php b/tests/Functional/ApiClientTest.php new file mode 100644 index 0000000..cbae7e1 --- /dev/null +++ b/tests/Functional/ApiClientTest.php @@ -0,0 +1,26 @@ +getApiClient(); + $devices = $apiClient->discoverDevices(); + + /** @var SwitchDevice $switch */ + $switch = $devices[1]; + + $apiClient->sendEvent($switch->getOnEvent()); + + $devices = $apiClient->discoverDevices(); + + /** @var SwitchDevice $switch */ + $switch = $devices[1]; + + $apiClient->sendEvent($switch->getOffEvent()); + } +} diff --git a/tests/ApiClientTest.php b/tests/Functional/BaseTestCase.php similarity index 50% rename from tests/ApiClientTest.php rename to tests/Functional/BaseTestCase.php index 9e0ae1e..ac7111d 100644 --- a/tests/ApiClientTest.php +++ b/tests/Functional/BaseTestCase.php @@ -1,13 +1,19 @@ getSessionFromEnv()); + } + + protected function getSessionFromEnv(): Session { $username = getenv('TUYA_USERNAME'); $password = getenv('TUYA_PASSWORD'); diff --git a/tests/Tests/Unit/RegionTest.php b/tests/Tests/Unit/RegionTest.php new file mode 100644 index 0000000..88c9dd8 --- /dev/null +++ b/tests/Tests/Unit/RegionTest.php @@ -0,0 +1,54 @@ +assertInstanceOf(Region::class, $region); + } + + public function fromAccessTokenProvider(): array + { + return [ + 'EU' => [ + new Token('EU123456', '', 0) + ], + 'China' => [ + new Token('AY123456', '', 0) + ], + ]; + } + + /** + * @param Token $token + * @dataProvider fromAccessTokenProviderInvalid + */ + public function testFromAccessTokenInvalid(Token $token) + { + $this->expectException(\InvalidArgumentException::class); + Region::fromAccessToken($token); + } + + public function fromAccessTokenProviderInvalid(): array + { + return [ + 'Empty' => [ + new Token('', '', 0) + ], + 'Unmapped' => [ + new Token('DJHFHD3847', '', 0) + ], + ]; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e59bb8e..991ea43 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,4 +1,3 @@