Added lovense functionality
This commit is contained in:
@@ -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 ###
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260701213700 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\LovenseConnection;
|
||||
use App\Repository\LovenseConnectionRepository;
|
||||
use App\Service\Lovense\LovenseApiException;
|
||||
use App\Service\Lovense\LovenseClient;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class LovensePairingController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LovenseClient $lovense,
|
||||
private readonly LovenseConnectionRepository $connections,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a QR code the given uid can scan (in the Lovense Remote app) to pair
|
||||
* their toy. Pairing itself completes asynchronously via {@see callback()}.
|
||||
*/
|
||||
#[Route('/lovense/pair/{uid}', name: 'lovense_pair', methods: ['POST'])]
|
||||
public function pair(string $uid, Request $request): JsonResponse
|
||||
{
|
||||
$nickname = $request->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\LovenseConnectionRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* A platform user's paired Lovense toy(s), as reported by Lovense's pairing callback.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: LovenseConnectionRepository::class)]
|
||||
class LovenseConnection
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
/**
|
||||
* The id you assigned this user when requesting their pairing QR code.
|
||||
*/
|
||||
#[ORM\Column(length: 64, unique: true)]
|
||||
private string $uid;
|
||||
|
||||
/**
|
||||
* Verification token Lovense includes on every callback for this uid.
|
||||
*/
|
||||
#[ORM\Column(length: 64)]
|
||||
private string $utoken;
|
||||
|
||||
#[ORM\Column(type: Types::JSON)]
|
||||
private array $toys = [];
|
||||
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
private ?string $platform = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct(string $uid, string $utoken)
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\LovenseConnection;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<LovenseConnection>
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Lovense;
|
||||
|
||||
class LovenseApiException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Lovense;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* Wraps Lovense's Standard (Server) API: https://github.com/lovense/Standard_solutions.
|
||||
*
|
||||
* This talks to Lovense's cloud, which relays commands to a user's Lovense Remote
|
||||
* app over the internet — it does not require the caller and the toy owner to be
|
||||
* on the same network. Requires a developer token from
|
||||
* https://www.lovense.com/user/developer/info.
|
||||
*/
|
||||
class LovenseClient
|
||||
{
|
||||
private const API_BASE = 'https://api.lovense.com/api/lan';
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
#[Autowire(env: 'LOVENSE_DEVELOPER_TOKEN')]
|
||||
private readonly string $developerToken,
|
||||
#[Autowire(env: 'LOVENSE_CALLBACK_SALT')]
|
||||
private readonly string $callbackSalt,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The utoken Lovense expects alongside a uid, used both when requesting a QR
|
||||
* code and when verifying the callback Lovense sends after pairing.
|
||||
*/
|
||||
public function userToken(string $uid): string
|
||||
{
|
||||
return md5($uid.$this->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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user