> */ 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> */ 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:///v2/devices/api/get?auth_key=' \ * -H 'Content-Type: application/json' \ * -d '' */ $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', ]); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, self::SHELLY_CONNECT_TIMEOUT_SECONDS); curl_setopt($ch, CURLOPT_TIMEOUT, self::SHELLY_REQUEST_TIMEOUT_SECONDS); curl_setopt($ch, CURLOPT_NOSIGNAL, true); // 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); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, self::SHELLY_CONNECT_TIMEOUT_SECONDS); curl_setopt($ch, CURLOPT_TIMEOUT, self::SHELLY_REQUEST_TIMEOUT_SECONDS); curl_setopt($ch, CURLOPT_NOSIGNAL, 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 $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); } } }