config = new shelly_c(); /** Actions */ $this->shelly_search = new shelly_search_a(); } /** * @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 '' */ // 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); } function appendAuthKeyToQuery(string $url): string { return $url . '?auth_key=' . $this->config->secret_key->getVariableValue(); } /** * @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); } } }