diff --git a/.env b/.env index 526d986..e590c93 100644 --- a/.env +++ b/.env @@ -41,3 +41,11 @@ MESSENGER_TRANSPORT_DSN=amqp://guest:guest@rabbitmq:5672/%2f/messages ###> symfony/mailer ### MAILER_DSN=null://null ###< symfony/mailer ### + +###> lovense ### +# Developer token from https://www.lovense.com/user/developer/info +LOVENSE_DEVELOPER_TOKEN= +# Any secret string of your choosing; used to derive/verify the utoken Lovense +# sends back with pairing callbacks (see LovenseClient::userToken()). +LOVENSE_CALLBACK_SALT= +###< lovense ### diff --git a/migrations/Version20260701213700.php b/migrations/Version20260701213700.php new file mode 100644 index 0000000..13b03e7 --- /dev/null +++ b/migrations/Version20260701213700.php @@ -0,0 +1,31 @@ +addSql('CREATE TABLE lovense_connection (id INT AUTO_INCREMENT NOT NULL, uid VARCHAR(64) NOT NULL, utoken VARCHAR(64) NOT NULL, toys JSON NOT NULL, platform VARCHAR(20) DEFAULT NULL, updated_at DATETIME NOT NULL, UNIQUE INDEX UNIQ_5B2D850539B0606 (uid), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE lovense_connection'); + } +} diff --git a/src/Controller/LovensePairingController.php b/src/Controller/LovensePairingController.php new file mode 100644 index 0000000..7a833da --- /dev/null +++ b/src/Controller/LovensePairingController.php @@ -0,0 +1,78 @@ +request->get('nickname', $uid); + + try { + $qr = $this->lovense->requestQrCode($uid, $nickname); + } catch (LovenseApiException $e) { + return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_GATEWAY); + } + + return $this->json($qr); + } + + /** + * Lovense posts here after a user scans their pairing QR code (or whenever + * their toy connection state changes), as configured on the developer + * dashboard's Callback URL. See https://github.com/lovense/Standard_solutions. + */ + #[Route('/lovense/callback', name: 'lovense_callback', methods: ['POST'])] + public function callback(Request $request): Response + { + $payload = json_decode($request->getContent(), true); + + if (!\is_array($payload) || !isset($payload['uid'], $payload['utoken'])) { + return new Response('Invalid payload', Response::HTTP_BAD_REQUEST); + } + + if (!hash_equals($this->lovense->userToken($payload['uid']), (string) $payload['utoken'])) { + return new Response('Invalid utoken', Response::HTTP_FORBIDDEN); + } + + $connection = $this->connections->findOneByUid($payload['uid']); + + if (null === $connection) { + $connection = new LovenseConnection($payload['uid'], $payload['utoken']); + $this->em->persist($connection); + } + + $connection->updateFromCallback( + $payload['utoken'], + $payload['toys'] ?? [], + $payload['platform'] ?? null, + ); + + $this->em->flush(); + + return new Response('OK'); + } +} diff --git a/src/Entity/LovenseConnection.php b/src/Entity/LovenseConnection.php new file mode 100644 index 0000000..de7fab9 --- /dev/null +++ b/src/Entity/LovenseConnection.php @@ -0,0 +1,98 @@ +uid = $uid; + $this->utoken = $utoken; + $this->updatedAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUid(): string + { + return $this->uid; + } + + public function getUtoken(): string + { + return $this->utoken; + } + + public function getToys(): array + { + return $this->toys; + } + + public function getPlatform(): ?string + { + return $this->platform; + } + + public function getUpdatedAt(): \DateTimeImmutable + { + return $this->updatedAt; + } + + public function updateFromCallback(string $utoken, array $toys, ?string $platform): static + { + $this->utoken = $utoken; + $this->toys = $toys; + $this->platform = $platform; + $this->updatedAt = new \DateTimeImmutable(); + + return $this; + } + + public function isOnline(): bool + { + foreach ($this->toys as $toy) { + if (1 === (int) ($toy['status'] ?? 0)) { + return true; + } + } + + return false; + } +} diff --git a/src/Repository/LovenseConnectionRepository.php b/src/Repository/LovenseConnectionRepository.php new file mode 100644 index 0000000..54dcc7f --- /dev/null +++ b/src/Repository/LovenseConnectionRepository.php @@ -0,0 +1,23 @@ + + */ +class LovenseConnectionRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, LovenseConnection::class); + } + + public function findOneByUid(string $uid): ?LovenseConnection + { + return $this->findOneBy(['uid' => $uid]); + } +} diff --git a/src/Service/Lovense/LovenseApiException.php b/src/Service/Lovense/LovenseApiException.php new file mode 100644 index 0000000..29ac467 --- /dev/null +++ b/src/Service/Lovense/LovenseApiException.php @@ -0,0 +1,7 @@ +callbackSalt); + } + + /** + * Requests a pairing QR code for the given platform user. Scanning it in the + * Lovense Remote app links that user's toy(s) to $uid and triggers Lovense's + * callback to your configured Callback URL. + * + * @return array{qr: string, code: string} + */ + public function requestQrCode(string $uid, string $nickname): array + { + return $this->request('getQrCode', [ + 'uid' => $uid, + 'uname' => $nickname, + 'utoken' => $this->userToken($uid), + 'v' => 2, + ])['data']; + } + + /** + * Sends a Vibrate/Rotate/Pump command, or Stop, to one or more paired users. + * + * @param string|string[] $uid one uid, or several to command at once + */ + public function sendFunctionCommand( + string|array $uid, + string $action, + int $timeSec = 0, + ?int $loopRunningSec = null, + ?int $loopPauseSec = null, + bool $stopPrevious = true, + ): array { + $params = [ + 'command' => 'Function', + 'action' => $action, + 'timeSec' => $timeSec, + 'stopPrevious' => $stopPrevious ? 1 : 0, + ]; + + if (null !== $loopRunningSec) { + $params['loopRunningSec'] = $loopRunningSec; + } + + if (null !== $loopPauseSec) { + $params['loopPauseSec'] = $loopPauseSec; + } + + return $this->sendCommand($uid, $params); + } + + /** + * @param string|string[] $uid + */ + public function stop(string|array $uid): array + { + return $this->sendCommand($uid, ['command' => 'Function', 'action' => 'Stop']); + } + + /** + * @param string|string[] $uid + */ + private function sendCommand(string|array $uid, array $params): array + { + return $this->request('command', [ + ...$params, + 'uid' => \is_array($uid) ? implode(',', $uid) : $uid, + 'apiVer' => 1, + ]); + } + + /** + * @throws LovenseApiException on transport failure or a non-success API response + */ + private function request(string $path, array $params): array + { + try { + $response = $this->httpClient->request('POST', self::API_BASE.'/'.$path, [ + 'json' => [ + 'token' => $this->developerToken, + ...$params, + ], + ]); + + $data = $response->toArray(); + } catch (\Throwable $e) { + throw new LovenseApiException('Lovense API request failed: '.$e->getMessage(), previous: $e); + } + + if (($data['code'] ?? null) !== 0 && ($data['code'] ?? null) !== 200) { + throw new LovenseApiException($data['message'] ?? 'Unknown Lovense API error', $data['code'] ?? 0); + } + + return $data; + } +}