<?php
namespace SHServ\Tools;
/**
* Каталог прошивок OTA.
*
* Сканирует директорию с ZIP-архивами, извлекает manifest.json,
* кэширует метаданные в памяти и предоставляет поиск по совместимости.
*/
class FirmwareCatalog {
private static ?array $cache = null;
private string $firmwaresDir;
private string $tempBaseDir;
public function __construct() {
$this -> 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);
}
}