*/ private array $last_timings = []; /** * The configuration of the module * @var licenseplaterecognizer_c */ public licenseplaterecognizer_c $config; /** * API URL * @var string */ private string $api_url; /** * @var array{enabled: bool, api_key: string}|null */ private ?array $runtime_config = null; /** * @var array{values: array{enabled: bool, api_key: string}, cached_at: float}|null */ private static ?array $runtime_config_cache = null; public function __construct(bool $load_config = true, ?string $api_url = null) { $this->api_url = self::normalizeApiUrl($api_url ?? self::configuredApiUrl()); if ($load_config) { $this->config = new licenseplaterecognizer_c(); } } /** * @inheritDoc */ public function requireModuleEnabled(): void { if (!$this->runtimeConfig()['enabled']) { throw new Exception('licenseplaterecognizer module is not enabled.'); } } /** * @inheritDoc * @throws Exception If the module is not enabled or if there is an error in the API request * @throws Exception If the API response cannot be parsed */ public function licenseplaterecognizer(string $base64_image): array { return $this->recognizePlate( fn () => $this->buildPlateReaderPayload($base64_image), fn () => $this->buildResultCacheKeyFromUploadString($base64_image) ); } public function licenseplaterecognizerUpload(string $image_data, string $mime_type = 'image/jpeg'): array { return $this->recognizePlate( fn () => $this->buildPlateReaderPayloadFromUpload( $this->buildUploadValueFromBytes($image_data, $mime_type) ), fn () => $this->buildResultCacheKeyFromBytes($image_data) ); } public function licenseplaterecognizerUploadUncached(string $image_data, string $mime_type = 'image/jpeg'): array { return $this->recognizePlate( fn () => $this->buildPlateReaderPayloadFromUpload( $this->buildUploadValueFromBytes($image_data, $mime_type) ) ); } public function licenseplaterecognizerUploadFile(string $image_path, string $mime_type = 'image/jpeg'): array { return $this->recognizePlate( fn () => $this->buildPlateReaderPayloadFromUpload( $this->buildUploadValueFromFile($image_path, $mime_type) ) ); } /** * @throws Exception */ private function recognizePlate(callable $payload_factory, ?callable $result_cache_key_factory = null): array { $started_at = microtime(true); $this->last_timings = []; $result_cache = null; $result_cache_key = null; try { $config_started_at = microtime(true); $runtime_config = $this->runtimeConfig(); if (!$runtime_config['enabled']) { throw new Exception('licenseplaterecognizer module is not enabled.'); } $api_key = $runtime_config['api_key']; $this->last_timings['config'] = $this->elapsedMs($config_started_at); if ($result_cache_key_factory !== null) { $cache_started_at = microtime(true); try { $result_cache = $this->resultCacheStore(); if ($result_cache !== null) { $result_cache_key = $result_cache_key_factory(); if ($result_cache_key !== null) { $cached_result = $this->readRecognitionResultCache($result_cache, $result_cache_key); if ($cached_result !== null) { $this->last_timings['cache_hit'] = 1; return $cached_result; } } } $this->last_timings['cache_miss'] = 1; } finally { $this->last_timings['cache'] = $this->elapsedMs($cache_started_at); } } $payload_started_at = microtime(true); $data = $payload_factory(); $this->last_timings['payload'] = $this->elapsedMs($payload_started_at); $ch = curl_init($this->api_url . '/v1/plate-reader/'); if (!$ch instanceof \CurlHandle) { throw new Exception('Error initializing API request.'); } $curl_options = [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $data, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS, CURLOPT_CONNECTTIMEOUT_MS => self::PLATE_READER_CONNECT_TIMEOUT_MS, CURLOPT_TIMEOUT_MS => self::PLATE_READER_TOTAL_TIMEOUT_MS, CURLOPT_NOSIGNAL => true, CURLOPT_NOPROGRESS => false, CURLOPT_XFERINFOFUNCTION => self::clientDisconnectAbortCallback(), CURLOPT_HTTPHEADER => [ "Authorization: Token " . $api_key, 'Expect:', ], ]; if (defined('CURLOPT_TCP_NODELAY')) { $curl_options[(int)constant('CURLOPT_TCP_NODELAY')] = true; } curl_setopt_array($ch, $curl_options); // Submit the POST request and close cURL session handle $upstream_started_at = microtime(true); $result = curl_exec($ch); $this->last_timings['upstream'] = $this->elapsedMs($upstream_started_at); $this->recordCurlTimings($ch); curl_close($ch); // Print the response from the server if ($result === false) { throw new Exception('Error in API request.'); } $parse_started_at = microtime(true); $response_data = json_decode($result, true); $this->last_timings['parse'] = $this->elapsedMs($parse_started_at); $this->recordResponseTimings($response_data); if (isset($response_data['results']) && count($response_data['results']) > 0) { $recognized_result = [ 'success' => true, 'license_plate_number' => $response_data['results'][0]['plate'] ?? null, 'confidence' => $response_data['results'][0]['score'] ?? null, ]; $this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result); return $recognized_result; } $recognized_result = [ 'success' => false, 'message' => 'No license plate detected.', ]; $this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result); return $recognized_result; } finally { $this->last_timings['total'] = $this->elapsedMs($started_at); } } private static function clientDisconnectAbortCallback(): callable { return static function (): int { return connection_aborted() ? 1 : 0; }; } public function getLastTimings(): array { return $this->last_timings; } private function elapsedMs(float $started_at): float { return (microtime(true) - $started_at) * 1000; } private static function configuredApiUrl(): string { $configured = getenv('PLATE_RECOGNIZER_API_URL'); if ($configured === false || trim((string)$configured) === '') { $configured = $_ENV['PLATE_RECOGNIZER_API_URL'] ?? $_SERVER['PLATE_RECOGNIZER_API_URL'] ?? self::DEFAULT_API_URL; } return (string)$configured; } private static function normalizeApiUrl(string $api_url): string { $api_url = trim($api_url); if ($api_url === '') { return self::DEFAULT_API_URL; } return rtrim($api_url, '/'); } private function recordCurlTimings(\CurlHandle $curl_handle): void { $mapping = [ CURLINFO_NAMELOOKUP_TIME => 'upstream_dns', CURLINFO_CONNECT_TIME => 'upstream_connect', CURLINFO_APPCONNECT_TIME => 'upstream_tls', CURLINFO_PRETRANSFER_TIME => 'upstream_pretransfer', CURLINFO_STARTTRANSFER_TIME => 'upstream_ttfb', CURLINFO_TOTAL_TIME => 'upstream_total', ]; foreach ($mapping as $curl_info_option => $timing_key) { $value = curl_getinfo($curl_handle, $curl_info_option); if (!is_numeric($value)) { continue; } $this->last_timings[$timing_key] = max(0, (float)$value * 1000); } } private function recordResponseTimings(mixed $response_data): void { if (!is_array($response_data) || !isset($response_data['processing_time']) || !is_numeric($response_data['processing_time'])) { return; } $this->last_timings['upstream_processing'] = max(0, (float)$response_data['processing_time']); } private function buildResultCacheKeyFromUploadString(string $base64_image): string { $base64_image = trim($base64_image); if (preg_match('/^data:image\/[a-zA-Z0-9.+-]+;base64,(.*)$/s', $base64_image, $matches) === 1) { $image_data = base64_decode((string)$matches[1], true); if (is_string($image_data)) { return $this->buildResultCacheKeyFromBytes($image_data); } } return $this->buildResultCacheKeyFromBytes($base64_image); } private function buildResultCacheKeyFromBytes(string $image_data): string { $context = hash_init('sha256'); hash_update($context, $this->resultCacheContext()); hash_update($context, "\0"); hash_update($context, $image_data); return self::RESULT_CACHE_REDIS_KEY_PREFIX . hash_final($context); } private function resultCacheContext(): string { return self::RESULT_CACHE_CONTEXT; } protected function resultCacheStore(): ?object { return $this->runtimeConfigCacheStore(); } private function readRecognitionResultCache(?object $cache, ?string $key): ?array { if ($cache === null || $key === null || !method_exists($cache, 'get')) { return null; } try { $cached = $cache->get($key); } catch (\Throwable) { return null; } if (!is_string($cached) || trim($cached) === '') { return null; } $decoded = json_decode($cached, true); if (!is_array($decoded) || !array_key_exists('success', $decoded)) { return null; } return $decoded; } private function writeRecognitionResultCache(?object $cache, ?string $key, array $result): void { if ($cache === null || $key === null || !method_exists($cache, 'setEx')) { return; } try { $encoded = json_encode($result, JSON_UNESCAPED_SLASHES); if (is_string($encoded)) { $cache->setEx($key, $encoded, self::RESULT_CACHE_TTL_SECONDS); } } catch (\Throwable) { // Scanner result cache is best-effort; Plate Recognizer remains the source of truth. } } protected function buildPlateReaderPayload(string $base64_image): array { return $this->buildPlateReaderPayloadFromUpload($this->buildUploadValue($base64_image)); } protected function buildPlateReaderPayloadFromUpload(string|\CURLFile|\CURLStringFile $upload): array { return [ 'upload' => $upload, 'config' => self::PLATE_READER_CONFIG_JSON, 'regions' => self::PLATE_READER_REGIONS, ]; } private function buildUploadValue(string $base64_image): string|\CURLStringFile { $base64_image = trim($base64_image); if (preg_match('/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.*)$/s', $base64_image, $matches) !== 1) { return $base64_image; } $image_data = base64_decode((string)$matches[2], true); if ($image_data === false || !class_exists(\CURLStringFile::class)) { return (string)$matches[2]; } return new \CURLStringFile($image_data, self::DEFAULT_UPLOAD_FILE_NAME, (string)$matches[1]); } private function buildUploadValueFromBytes(string $image_data, string $mime_type): string|\CURLStringFile { $mime_type = trim($mime_type) !== '' ? trim($mime_type) : 'image/jpeg'; if (!str_starts_with($mime_type, 'image/')) { $mime_type = 'image/jpeg'; } if (!class_exists(\CURLStringFile::class)) { return $image_data; } return new \CURLStringFile($image_data, self::DEFAULT_UPLOAD_FILE_NAME, $mime_type); } /** * @throws Exception */ private function buildUploadValueFromFile(string $image_path, string $mime_type): \CURLFile { $image_path = trim($image_path); $mime_type = trim($mime_type) !== '' ? trim($mime_type) : 'image/jpeg'; if (!str_starts_with($mime_type, 'image/')) { $mime_type = 'image/jpeg'; } if ($image_path === '' || !is_file($image_path) || !class_exists(\CURLFile::class)) { throw new Exception('Image upload file is invalid.'); } return new \CURLFile($image_path, $mime_type, self::DEFAULT_UPLOAD_FILE_NAME); } protected function runtimeConfig(): array { if ($this->runtime_config !== null) { return $this->runtime_config; } if ($this->shouldUseSharedRuntimeConfigCache()) { $cached_config = self::getSharedRuntimeConfigCache(); if ($cached_config !== null) { $this->runtime_config = $cached_config; return $this->runtime_config; } $cached_config = $this->readRuntimeConfigCacheStore(); if ($cached_config !== null) { self::$runtime_config_cache = [ 'values' => $cached_config, 'cached_at' => microtime(true), ]; $this->runtime_config = $cached_config; return $this->runtime_config; } } $values = $this->readRuntimeModuleConfig(); $this->runtime_config = [ 'enabled' => $this->parseModuleConfigBool($values['enabled'] ?? false), 'api_key' => (string)($values['api_key'] ?? ''), ]; if ($this->shouldUseSharedRuntimeConfigCache()) { self::$runtime_config_cache = [ 'values' => $this->runtime_config, 'cached_at' => microtime(true), ]; $this->writeRuntimeConfigCacheStore($this->runtime_config); } return $this->runtime_config; } protected function shouldUseSharedRuntimeConfigCache(): bool { return static::class === self::class; } private static function getSharedRuntimeConfigCache(): ?array { if (self::$runtime_config_cache === null) { return null; } $cache_age_seconds = microtime(true) - self::$runtime_config_cache['cached_at']; if ($cache_age_seconds > self::RUNTIME_CONFIG_CACHE_TTL_SECONDS) { self::$runtime_config_cache = null; return null; } return self::$runtime_config_cache['values']; } protected function runtimeConfigCacheStore(): ?object { return defined('redis') ? constant('redis') : null; } private function readRuntimeConfigCacheStore(): ?array { $cache = $this->runtimeConfigCacheStore(); if ($cache === null || !method_exists($cache, 'get')) { return null; } try { $cached = $cache->get(self::RUNTIME_CONFIG_REDIS_CACHE_KEY); } catch (\Throwable) { return null; } if (!is_string($cached) || trim($cached) === '') { return null; } $decoded = json_decode($cached, true); if (!is_array($decoded)) { return null; } if (!array_key_exists('enabled', $decoded) || !array_key_exists('api_key', $decoded)) { return null; } return [ 'enabled' => $this->parseModuleConfigBool($decoded['enabled']), 'api_key' => (string)$decoded['api_key'], ]; } /** * @param array{enabled: bool, api_key: string} $config */ private function writeRuntimeConfigCacheStore(array $config): void { $cache = $this->runtimeConfigCacheStore(); if ($cache === null || !method_exists($cache, 'setEx')) { return; } try { $encoded = json_encode($config, JSON_UNESCAPED_SLASHES); if (is_string($encoded)) { $cache->setEx(self::RUNTIME_CONFIG_REDIS_CACHE_KEY, $encoded, self::RUNTIME_CONFIG_CACHE_TTL_SECONDS); } } catch (\Throwable) { // Scanner config cache is best-effort; DB remains the source of truth. } } private function parseModuleConfigBool(mixed $value): bool { if (is_bool($value)) { return $value; } if (is_numeric($value)) { return (int)$value === 1; } return strtolower(trim((string)$value)) === 'true'; } protected function readRuntimeModuleConfig(): array { global $db; if ($db instanceof db) { $module = $db->escape_string('licenseplaterecognizer'); $result = $db->query("SELECT variable, value FROM module_config WHERE module = '$module' AND variable IN ('enabled', 'api_key')"); $values = []; if ($result instanceof \mysqli_result) { while ($row = $result->fetch_assoc()) { $variable = (string)($row['variable'] ?? ''); if ($variable !== '') { $values[$variable] = (string)($row['value'] ?? ''); } } } return $values; } if (!isset($this->config)) { $this->config = new licenseplaterecognizer_c(); } return [ 'enabled' => (string)$this->config->enabled->getVariableValue(), 'api_key' => (string)$this->config->api_key->getVariableValue(), ]; } /** * @inheritDoc * @throws Exception If the module is not enabled or if there is an error in the API request * @throws Exception If the API response cannot be parsed */ public function get_usage(): licenseplaterecognizer_info { $this->requireModuleEnabled(); $api_key = $this->runtimeConfig()['api_key']; $curl = curl_init(); curl_setopt_array($curl, array( CURLOPT_URL => $this->api_url . '/info/', CURLOPT_RETURNTRANSFER => true, CURLOPT_ENCODING => '', CURLOPT_MAXREDIRS => 10, CURLOPT_TIMEOUT => 0, CURLOPT_FOLLOWLOCATION => true, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS, CURLOPT_CUSTOMREQUEST => 'GET', CURLOPT_HTTPHEADER => [ 'Authorization: Token ' . $api_key, ], )); $response = curl_exec($curl); curl_close($curl); $response_data = json_decode($response, true); if ($response_data === null) { throw new Exception('Error parsing API response.'); } return new licenseplaterecognizer_info($response_data); } }