diff --git a/server/SHServ/Controllers/FirmwareRESTAPIController.php b/server/SHServ/Controllers/FirmwareRESTAPIController.php new file mode 100644 index 0000000..ad39dfd --- /dev/null +++ b/server/SHServ/Controllers/FirmwareRESTAPIController.php @@ -0,0 +1,158 @@ +getAll(); + + $firmwares = array_values(array_map(function ($entry) { + return $entry['manifest']; + }, $all)); + + return $this->utils()->response_success([ + 'firmwares' => $firmwares, + 'total' => count($firmwares), + ]); + } + + public function firmware_detail($firmware_id) { + $catalog = new FirmwareCatalog(); + $entry = $catalog->getById($firmware_id); + + if (!$entry) { + return $this->utils()->response_error('firmware_not_found'); + } + + return $this->utils()->response_success([ + 'firmware' => $entry['manifest'], + ]); + } + + public function firmware_download($firmware_id) { + $catalog = new FirmwareCatalog(); + $binPath = $catalog->getBinPath($firmware_id); + + if (!$binPath) { + return $this->utils()->response_error('firmware_not_found'); + } + + $entry = $catalog->getById($firmware_id); + $filename = $entry['manifest']['bin_filename'] ?? 'firmware.bin'; + + header('Content-Type: application/octet-stream'); + header('Content-Disposition: attachment; filename="' . $filename . '"'); + header('Content-Length: ' . filesize($binPath)); + readfile($binPath); + + // cleanup + $tempDir = dirname($binPath); + if (strpos($tempDir, sys_get_temp_dir()) === 0) { + @unlink($binPath); + @rmdir($tempDir); + } + exit; + } + + public function firmware_refresh() { + $catalog = new FirmwareCatalog(); + $catalog->scan(); + + return $this->utils()->response_success(); + } + + public function device_firmware_compatibility($device_id) { + $device_id = intval($device_id); + if ($device_id < 1) { + return $this->utils()->response_error('invalid_id', ['device_id']); + } + + $devices_model = new Devices(); + $device = $devices_model->by_id($device_id); + + if (!$device) { + return $this->utils()->response_error('device_not_found'); + } + + $about = $device->device_api()->get_about(); + if (($about['http_code'] ?? 0) !== 200 || !is_array($about['data'] ?? null)) { + return $this->utils()->response_error('device_request_fail'); + } + + $catalog = new FirmwareCatalog(); + $compatible = $catalog->findCompatible($about['data']); + + return $this->utils()->response_success([ + 'compatible' => $compatible, + 'current_version' => $about['data']['firmware_version'] ?? 'unknown', + 'current_platform' => $about['data']['platform'] ?? null, + 'current_channels' => $about['data']['channels'] ?? null, + ]); + } + + public function device_update_firmware($device_id, $firmware_id) { + $device_id = intval($device_id); + if ($device_id < 1) { + return $this->utils()->response_error('invalid_id', ['device_id']); + } + + $devices_model = new Devices(); + $device = $devices_model->by_id($device_id); + + if (!$device) { + return $this->utils()->response_error('device_not_found'); + } + + $catalog = new FirmwareCatalog(); + $binPath = $catalog->getBinPath($firmware_id); + + if (!$binPath) { + return $this->utils()->response_error('firmware_not_found'); + } + + // Проверка совместимости + $about = $device->device_api()->get_about(); + if (($about['http_code'] ?? 0) !== 200 || !is_array($about['data'] ?? null)) { + $this->cleanupBin($binPath); + return $this->utils()->response_error('device_request_fail'); + } + + $compatible = $catalog->findCompatible($about['data']); + $ids = array_column($compatible, 'id'); + if (!in_array($firmware_id, $ids, true)) { + $this->cleanupBin($binPath); + return $this->utils()->response_error('firmware_not_compatible'); + } + + // Push OTA + $result = $device->device_api()->updateFirmware($binPath); + $this->cleanupBin($binPath); + + if (($result['http_code'] ?? 0) !== 200) { + return $this->utils()->response_error('ota_failed', [], [ + 'device_msg' => $result['error'] ?? '', + ]); + } + + return $this->utils()->response_success([ + 'device_msg' => $result['raw'] ?? 'Update OK', + ]); + } + + // ------------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------------ + + private function cleanupBin(string $binPath): void { + $tempDir = dirname($binPath); + if (strpos($tempDir, sys_get_temp_dir()) === 0) { + @unlink($binPath); + @rmdir($tempDir); + } + } +} diff --git a/server/SHServ/Routes.php b/server/SHServ/Routes.php index c8a408b..8280b35 100644 --- a/server/SHServ/Routes.php +++ b/server/SHServ/Routes.php @@ -8,6 +8,7 @@ use \SHServ\Routes\DevicesRESTAPI_v1; use \SHServ\Routes\ScriptsRESTAPI_v1; use \SHServ\Routes\AreasRESTAPI_v1; + use \SHServ\Routes\FirmwareRESTAPI_v1; /** * Instance of Router module @@ -47,6 +48,10 @@ $this -> areas_restapi_get_routes(); $this -> areas_restapi_post_routes(); + $this -> firmware_restapi_uri_routes(); + $this -> firmware_restapi_get_routes(); + $this -> firmware_restapi_post_routes(); + // DEV MODE if(FCONF["devmode"]) { $this -> devmode_uri_routes(); diff --git a/server/SHServ/Routes/FirmwareRESTAPI_v1.php b/server/SHServ/Routes/FirmwareRESTAPI_v1.php new file mode 100644 index 0000000..f358567 --- /dev/null +++ b/server/SHServ/Routes/FirmwareRESTAPI_v1.php @@ -0,0 +1,29 @@ +router->uri("/api/v1/firmwares", "{$this->cn}\\FirmwareRESTAPIController@firmwares_list"); + $this->router->uri('/api/v1/firmwares/id/$firmware_id', "{$this->cn}\\FirmwareRESTAPIController@firmware_detail"); + $this->router->uri('/api/v1/firmwares/id/$firmware_id/download', "{$this->cn}\\FirmwareRESTAPIController@firmware_download"); + $this->router->uri('/api/v1/devices/id/$device_id/firmware-compatibility', "{$this->cn}\\FirmwareRESTAPIController@device_firmware_compatibility"); + } + + protected function firmware_restapi_post_routes() { + $this->router->post( + [], + "{$this->cn}\\FirmwareRESTAPIController@firmware_refresh", + "/api/v1/firmwares/refresh" + ); + + $this->router->post( + ["device_id", "firmware_id"], + "{$this->cn}\\FirmwareRESTAPIController@device_update_firmware", + "/api/v1/devices/update-firmware" + ); + } + + protected function firmware_restapi_get_routes() { + } +} diff --git a/server/SHServ/Tools/DeviceAPI/Base.php b/server/SHServ/Tools/DeviceAPI/Base.php index 04e7e19..1b91764 100644 --- a/server/SHServ/Tools/DeviceAPI/Base.php +++ b/server/SHServ/Tools/DeviceAPI/Base.php @@ -123,6 +123,68 @@ } /** + * POST /update + * + * Отправляет бинарный файл прошивки на устройство через multipart/form-data. + * Требует токен в normal-режиме (хотя endpoint сейчас открыт, токен отправляется если есть). + * + * @param string $binPath Абсолютный путь к .bin файлу. + */ + public function updateFirmware(string $binPath): array { + $url = $this->base_url . '/update'; + + $ch = curl_init(); + if ($ch === false) { + throw new \RuntimeException('Failed to initialize cURL'); + } + + $file = new \CURLFile($binPath, 'application/octet-stream', basename($binPath)); + $postFields = ['firmware' => $file]; + + $headers = ['Accept: text/plain']; + if ($this->token !== null) { + $headers[] = 'Authorization: Bearer ' . $this->token; + } + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $postFields, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + CURLOPT_CONNECTTIMEOUT => $this->connect_timeout, + CURLOPT_TIMEOUT => 60, + CURLOPT_HTTPHEADER => $headers, + ]); + + $raw = curl_exec($ch); + $err = $raw === false ? curl_error($ch) : null; + $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($raw === false) { + return [ + 'http_code' => 0, + 'headers' => [], + 'raw' => null, + 'data' => null, + 'error' => $err ?? 'Unknown cURL error', + ]; + } + + $rawBody = substr($raw, $headerSize); + + return [ + 'http_code' => (int) $httpCode, + 'headers' => [], + 'raw' => $rawBody, + 'data' => null, + 'error' => null, + ]; + } + + /** * POST /set_device_name * * Требует токен в normal-режиме. diff --git a/server/SHServ/Tools/FirmwareCatalog.php b/server/SHServ/Tools/FirmwareCatalog.php new file mode 100644 index 0000000..84b09d7 --- /dev/null +++ b/server/SHServ/Tools/FirmwareCatalog.php @@ -0,0 +1,238 @@ + firmwaresDir = FCONF['firmwares_dir'] ?? dirname(__DIR__, 2) . '/firmwares'; + $this -> tempBaseDir = sys_get_temp_dir() . '/shserv_firmwares'; + + if (!is_dir($this -> firmwaresDir)) { + @mkdir($this -> firmwaresDir, 0777, true); + } + } + + /** + * Принудительно пересканировать директорию с прошивками. + * + * @return array Ассоциативный массив [id => entry] + */ + public function scan(): array { + self::$cache = []; + + if (!is_dir($this -> firmwaresDir)) { + return []; + } + + $files = glob($this -> firmwaresDir . '/*.zip'); + if (!$files) { + return []; + } + + foreach ($files as $file) { + $manifest = $this -> extractManifest($file); + if ($manifest !== null) { + self::$cache[$manifest['id']] = [ + 'manifest' => $manifest, + 'zip_path' => $file, + ]; + } + } + + return self::$cache; + } + + /** + * Получить все записи каталога (сканирует лениво, если кэш пуст). + */ + public function getAll(): array { + if (self::$cache === null) { + $this -> scan(); + } + return self::$cache ?? []; + } + + /** + * Получить одну запись каталога по ID манифеста. + */ + public function getById(string $id): ?array { + $all = $this -> getAll(); + return $all[$id] ?? null; + } + + /** + * Распаковать .bin из ZIP во временный файл и вернуть путь к нему. + * Вызывающий обязан удалить файл и временную директорию после использования. + */ + public function getBinPath(string $id): ?string { + $entry = $this -> getById($id); + if (!$entry) { + return null; + } + + $zipPath = $entry['zip_path']; + $binFilename = $entry['manifest']['bin_filename'] ?? 'firmware.bin'; + $tempDir = $this -> tempBaseDir . '/' . $id . '_' . uniqid(); + + @mkdir($tempDir, 0777, true); + + // Попытка 1: ZipArchive + $zip = new \ZipArchive(); + if ($zip -> open($zipPath) === true) { + $zip -> extractTo($tempDir, [$binFilename]); + $zip -> close(); + $path = $tempDir . '/' . $binFilename; + if (file_exists($path)) { + return $path; + } + } + + // Попытка 2: unzip CLI + exec('unzip -o ' . escapeshellarg($zipPath) . ' ' . escapeshellarg($binFilename) . ' -d ' . escapeshellarg($tempDir) . ' 2>/dev/null', $out, $rc); + $path = $tempDir . '/' . $binFilename; + if ($rc === 0 && file_exists($path)) { + return $path; + } + + $this -> recursiveDelete($tempDir); + return null; + } + + /** + * Найти прошивки, совместимые с данным устройством и новее текущей версии. + * + * @param array $deviceAbout Ответ устройства на GET /about + */ + public function findCompatible(array $deviceAbout): array { + $all = $this -> getAll(); + + $deviceType = $deviceAbout['device_type'] ?? ''; + $platform = $deviceAbout['platform'] ?? null; + $channels = isset($deviceAbout['channels']) ? (int)$deviceAbout['channels'] : null; + $currentVersion = $deviceAbout['firmware_version'] ?? '0.0.0'; + + $compatible = []; + foreach ($all as $id => $entry) { + $m = $entry['manifest']; + + if (($m['device_type'] ?? '') !== $deviceType) { + continue; + } + if (isset($m['platform']) && $m['platform'] !== $platform) { + continue; + } + if (isset($m['channels']) && (int)$m['channels'] !== $channels) { + continue; + } + if ($this -> versionCompare($m['version'] ?? '0.0.0', $currentVersion) <= 0) { + continue; + } + + $compatible[] = [ + 'id' => $id, + 'version' => $m['version'], + 'description' => $m['description'] ?? '', + 'changelog' => $m['changelog'] ?? '', + ]; + } + + return $compatible; + } + + /** + * Сбросить in-memory кэш. + */ + public function clearCache(): void { + self::$cache = null; + } + + // ------------------------------------------------------------------------ + // Внутренние helpers + // ------------------------------------------------------------------------ + + private function extractManifest(string $zipPath): ?array { + $tempDir = $this -> tempBaseDir . '/' . basename($zipPath, '.zip') . '_' . uniqid(); + @mkdir($tempDir, 0777, true); + + $manifest = null; + + // Попытка 1: ZipArchive + $zip = new \ZipArchive(); + if ($zip -> open($zipPath) === true) { + $content = $zip -> getFromName('manifest.json'); + $zip -> close(); + if ($content !== false) { + $manifest = $this -> parseManifest($content); + } + } + + // Попытка 2: unzip CLI + if ($manifest === null) { + exec('unzip -o ' . escapeshellarg($zipPath) . ' manifest.json -d ' . escapeshellarg($tempDir) . ' 2>/dev/null', $out, $rc); + if ($rc === 0) { + $manifestPath = $tempDir . '/manifest.json'; + if (file_exists($manifestPath)) { + $content = file_get_contents($manifestPath); + $manifest = $this -> parseManifest($content); + } + } + } + + $this -> recursiveDelete($tempDir); + return $manifest; + } + + private function parseManifest(?string $content): ?array { + if ($content === null || $content === '') { + return null; + } + $manifest = json_decode($content, true); + if (!is_array($manifest)) { + return null; + } + if (empty($manifest['id']) || empty($manifest['device_type']) || empty($manifest['version']) || empty($manifest['bin_filename'])) { + return null; + } + return $manifest; + } + + private function versionCompare(string $a, string $b): int { + $va = array_map('intval', explode('.', $a)); + $vb = array_map('intval', explode('.', $b)); + for ($i = 0; $i < 3; $i++) { + $ai = $va[$i] ?? 0; + $bi = $vb[$i] ?? 0; + if ($ai !== $bi) { + return $ai <=> $bi; + } + } + return 0; + } + + private function recursiveDelete(string $dir): void { + if (!is_dir($dir)) { + return; + } + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($files as $fileInfo) { + $action = $fileInfo -> isDir() ? 'rmdir' : 'unlink'; + $action($fileInfo -> getRealPath()); + } + rmdir($dir); + } +} diff --git a/server/SHServ/config.php b/server/SHServ/config.php index d7d8343..b15454f 100644 --- a/server/SHServ/config.php +++ b/server/SHServ/config.php @@ -46,5 +46,6 @@ ], "device_api_connect_timeout" => (float)($env['DEVICE_API_CONNECT_TIMEOUT'] ?? "1"), "device_api_timeout" => (float)($env['DEVICE_API_TIMEOUT'] ?? "5"), - "device_offline_threshold" => (int)($env['DEVICE_OFFLINE_THRESHOLD'] ?? "300") + "device_offline_threshold" => (int)($env['DEVICE_OFFLINE_THRESHOLD'] ?? "300"), + "firmwares_dir" => $env['FIRMWARES_DIR'] ?? dirname(__DIR__, 2) . "/firmwares" ]; diff --git a/server/SHServ/text-msgs.php b/server/SHServ/text-msgs.php index d56b4d1..39dbe1e 100644 --- a/server/SHServ/text-msgs.php +++ b/server/SHServ/text-msgs.php @@ -47,6 +47,9 @@ "invalid_type" => "", "invalid_ip" => "", "script_not_exists" => "", + "firmware_not_found" => "Прошивка не найдена", + "firmware_not_compatible" => "Прошивка не совместима с устройством", + "ota_failed" => "Не удалось обновить прошивку на устройстве", // Other "accept_removing" => "Подтвердите удаление",