- Implement `createSelfServeScenario` to generate comprehensive self-serve test fixtures, including departments, lanes, tasks, and sessions. - Add `syncRelayState` parameter to `synchronizeSession` for decoupled relay hardware synchronization. - Update relevant routes and tests to reflect changes in session synchronization methods. - Enhance Shelly request handling by blocking real device interactions in test mode with detailed logging.
376 lines
11 KiB
PHP
376 lines
11 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
require_once WD . '/modules/shelly/shelly_c.php';
|
|
/** Actions */
|
|
require_once WD . '/modules/shelly/actions/shelly_search_a.php';
|
|
|
|
use Exception;
|
|
use interfaces\shelly_i;
|
|
use shelly\actions\shelly_search_a;
|
|
use shelly\shelly_c;
|
|
|
|
class shelly implements shelly_i
|
|
{
|
|
private const SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS = 20;
|
|
private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000;
|
|
private const SHELLY_RATE_LIMIT_GATE_KEY = 'shelly_cloud_rate_limit_gate';
|
|
/**
|
|
* @var array<int,array<string,mixed>>
|
|
*/
|
|
private static array $blocked_request_log = [];
|
|
|
|
/**
|
|
* Configuration of the shelly module
|
|
* @var shelly_c
|
|
*/
|
|
public shelly_c $config;
|
|
|
|
/**
|
|
* ACTION: shelly_SEARCH
|
|
* @see shelly_search_a
|
|
* @notation This action is when a company search request is made, and logs the request in the database
|
|
* @var shelly_search_a $shelly_search
|
|
*/
|
|
private shelly_search_a $shelly_search;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->config = new shelly_c();
|
|
/** Actions */
|
|
$this->shelly_search = new shelly_search_a();
|
|
}
|
|
|
|
public static function resetBlockedRequestLog(): void
|
|
{
|
|
self::$blocked_request_log = [];
|
|
}
|
|
|
|
/**
|
|
* @return array<int,array<string,mixed>>
|
|
*/
|
|
public static function blockedRequestLog(): array
|
|
{
|
|
return self::$blocked_request_log;
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
* @throws Exception If the module is not enabled, the license plate is invalid, the daily limit is exceeded, or the secret key is invalid
|
|
*/
|
|
function sendRequest(
|
|
string $endpoint,
|
|
array $data = [],
|
|
string $method = 'GET'
|
|
): object
|
|
{
|
|
// Validate the module is enabled
|
|
self::requireModuleEnabled();
|
|
// Validate the secret key
|
|
self::requireValidSecretKey();
|
|
// Send the request
|
|
$response = match ($method) {
|
|
'GET' => self::sendGetRequest($endpoint, $data),
|
|
'POST' => self::sendPostRequest($endpoint, $data),
|
|
//'PUT' => self::sendPutRequest($endpoint, $data),
|
|
//'DELETE' => self::sendDeleteRequest($endpoint, $data),
|
|
default => self::exception(
|
|
[
|
|
'method' => $method,
|
|
'endpoint' => $endpoint,
|
|
'data' => $data,
|
|
'response' => null,
|
|
'status_code' => 400,
|
|
'error' => 'Invalid request method',
|
|
],
|
|
500
|
|
),
|
|
};
|
|
// Add the usage to the request log
|
|
$this->shelly_search->shelly_search($response, 200);
|
|
// Return the response
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
*/
|
|
function requireModuleEnabled(): void
|
|
{
|
|
// Check if the module is enabled
|
|
if (!$this->config->enabled->isTrue()) {
|
|
throw new Exception('The shelly module is not enabled');
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* @inheritDoc
|
|
*/
|
|
function requireValidSecretKey(): void
|
|
{
|
|
// Check if the secret key is valid
|
|
if ($this->config->secret_key->getVariableValue() === null) {
|
|
self::exception(
|
|
[
|
|
'status_code' => 500,
|
|
'error' => 'Invalid secret key defined in the config (shelly_secret_key_c)',
|
|
],
|
|
500
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
function exception(array $data, int $status_code = 500): object
|
|
{
|
|
// Check if the response is valid JSON
|
|
if (!json_decode($data['response'])) {
|
|
throw new Exception('Invalid response');
|
|
}
|
|
// Add the request to the log
|
|
$this->shelly_search->shelly_search($data, $status_code);;
|
|
return throw new Exception($data['error'] ?? 'An error occurred while processing the request, in ' . $this->config->getModuleName() . ' module');
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
* @throws Exception
|
|
*/
|
|
function requireValidServerURL(): void
|
|
{
|
|
// Check if the server URL is valid
|
|
if ($this->config->server_url->getVariableValue() === null) {
|
|
self::exception(
|
|
[
|
|
'status_code' => 500,
|
|
]
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
* @throws Exception
|
|
*/
|
|
function sendPostRequest(string $endpoint, array $data): array|object|null
|
|
{
|
|
/**
|
|
* curl -X POST 'https://<HOST>/v2/devices/api/get?auth_key=<AUTH_KEY>' \
|
|
* -H 'Content-Type: application/json' \
|
|
* -d '<BODY>'
|
|
*/
|
|
$this->guardRealShellyRequest('POST', $endpoint, $data);
|
|
// Require the module to be enabled
|
|
self::requireModuleEnabled();
|
|
self::requireValidSecretKey();
|
|
self::requireValidServerURL();
|
|
$this->waitForShellyRateLimitWindow();
|
|
// Send the request
|
|
$ch = curl_init();
|
|
curl_setopt($ch, CURLOPT_URL, self::appendAuthKeyToQuery($this->config->server_url->getVariableValue() . $endpoint));
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_POST, true);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
|
'Content-Type: application/json',
|
|
]);
|
|
// Execute the request
|
|
$response = curl_exec($ch);
|
|
// Get the status code
|
|
$status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
// Check for errors
|
|
if (curl_errno($ch)) {
|
|
self::exception(
|
|
[
|
|
'method' => 'POST',
|
|
'endpoint' => $endpoint,
|
|
'data' => $data,
|
|
'response' => $response,
|
|
],
|
|
$status_code
|
|
);
|
|
}
|
|
// Close the cURL session
|
|
curl_close($ch);
|
|
// Check if there is a response (some endpoints don't return a response)
|
|
if ($response === false) {
|
|
return null;
|
|
}
|
|
// Return the response
|
|
return json_decode($response);
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
* @throws Exception
|
|
*/
|
|
function sendGetRequest(string $endpoint, array $data): array|object|null
|
|
{
|
|
$this->guardRealShellyRequest('GET', $endpoint, $data);
|
|
self::requireModuleEnabled();
|
|
self::requireValidSecretKey();
|
|
self::requireValidServerURL();
|
|
$this->waitForShellyRateLimitWindow();
|
|
|
|
$ch = curl_init();
|
|
curl_setopt(
|
|
$ch,
|
|
CURLOPT_URL,
|
|
self::appendAuthKeyToQuery($this->config->server_url->getVariableValue() . $endpoint, $data)
|
|
);
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_HTTPGET, true);
|
|
|
|
$response = curl_exec($ch);
|
|
$status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
|
|
if (curl_errno($ch)) {
|
|
self::exception(
|
|
[
|
|
'method' => 'GET',
|
|
'endpoint' => $endpoint,
|
|
'data' => $data,
|
|
'response' => $response,
|
|
],
|
|
$status_code
|
|
);
|
|
}
|
|
|
|
curl_close($ch);
|
|
|
|
if ($response === false) {
|
|
return null;
|
|
}
|
|
|
|
return json_decode($response);
|
|
}
|
|
|
|
function appendAuthKeyToQuery(string $url, array $query = []): string
|
|
{
|
|
$query['auth_key'] = $this->config->secret_key->getVariableValue();
|
|
$separator = str_contains($url, '?') ? '&' : '?';
|
|
|
|
return $url . $separator . http_build_query($query);
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $data
|
|
* @throws Exception
|
|
*/
|
|
private function guardRealShellyRequest(string $method, string $endpoint, array $data): void
|
|
{
|
|
if (!$this->shouldBlockRealShellyRequest()) {
|
|
return;
|
|
}
|
|
|
|
$record = [
|
|
'method' => strtoupper($method),
|
|
'endpoint' => $endpoint,
|
|
'data' => $data,
|
|
'at' => date('c'),
|
|
];
|
|
self::$blocked_request_log[] = $record;
|
|
|
|
$log_path = trim((string)(getenv('TRUCKWASH_TEST_SHELLY_GUARD_LOG') ?: ''));
|
|
if ($log_path !== '') {
|
|
$encoded = json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
if (is_string($encoded)) {
|
|
@file_put_contents($log_path, $encoded . PHP_EOL, FILE_APPEND | LOCK_EX);
|
|
}
|
|
}
|
|
|
|
throw new Exception('Real Shelly requests are blocked in test mode: ' . strtoupper($method) . ' ' . $endpoint);
|
|
}
|
|
|
|
private function shouldBlockRealShellyRequest(): bool
|
|
{
|
|
return trim((string)(getenv('TRUCKWASH_TEST_BLOCK_REAL_SHELLY') ?: '')) === '1';
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private function waitForShellyRateLimitWindow(): void
|
|
{
|
|
$deadline = $this->nowTimestamp() + self::SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS;
|
|
|
|
do {
|
|
if ($this->tryAcquireShellyRateLimitSlot()) {
|
|
return;
|
|
}
|
|
|
|
if ($this->nowTimestamp() >= $deadline) {
|
|
throw new Exception('Shelly rate limit gate wait timed out');
|
|
}
|
|
|
|
$remaining_ms = $this->getShellyRateLimitSlotRemainingMs();
|
|
if ($remaining_ms <= 0) {
|
|
$remaining_ms = 50;
|
|
}
|
|
$this->sleepMicroseconds(min($remaining_ms, 250) * 1000);
|
|
} while (true);
|
|
}
|
|
|
|
private function tryAcquireShellyRateLimitSlot(): bool
|
|
{
|
|
$redis = $this->redisFacade();
|
|
if ($redis === null) {
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
$result = $redis->get_client()->set(
|
|
self::SHELLY_RATE_LIMIT_GATE_KEY,
|
|
(string)$this->nowTimestamp(),
|
|
'PX',
|
|
self::SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS,
|
|
'NX'
|
|
);
|
|
return $result === true || strtoupper((string)$result) === 'OK';
|
|
} catch (\Throwable) {
|
|
// If Redis gate can't be evaluated, fail open to avoid blocking API traffic completely.
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private function getShellyRateLimitSlotRemainingMs(): int
|
|
{
|
|
$redis = $this->redisFacade();
|
|
if ($redis === null) {
|
|
return 0;
|
|
}
|
|
|
|
try {
|
|
$ttl = $redis->get_client()->pttl(self::SHELLY_RATE_LIMIT_GATE_KEY);
|
|
if (!is_numeric($ttl)) {
|
|
return 0;
|
|
}
|
|
$ttl = (int)$ttl;
|
|
return $ttl > 0 ? $ttl : 0;
|
|
} catch (\Throwable) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
protected function redisFacade(): mixed
|
|
{
|
|
return defined('redis') ? redis : null;
|
|
}
|
|
|
|
protected function nowTimestamp(): float
|
|
{
|
|
return microtime(true);
|
|
}
|
|
|
|
protected function sleepMicroseconds(int $microseconds): void
|
|
{
|
|
if ($microseconds > 0) {
|
|
usleep($microseconds);
|
|
}
|
|
}
|
|
}
|