Newer
Older
smart-home-server / server / SHServ / Tools / DeviceScanner.php
<?php

namespace SHServ\Tools;

/**
 * DeviceScanner — инструмент для сканирования локальной сети
 * и поиска устройств, отвечающих JSON на /about.
 *
 * Требует расширение php-curl.
 */

class DeviceScanner {
  /**
   * Сканирует диапазон IP.
   *
   * @param string $start_ip     Начальный IP (например "192.168.1.1")
   * @param string $end_ip       Конечный IP (например "192.168.1.254")
   * @param int    $port         HTTP порт (по умолчанию 80)
   * @param float  $timeout      Таймаут на IP (сек)
   * @return array               Найденные устройства
   */
  public function scan_range(string $start_ip, string $end_ip, int $port = 80, float $timeout = 2): array {
    $ips = $this -> generate_ip_range($start_ip, $end_ip);
    if (empty($ips)) {
      return [];
    }

    return $this -> scan_ips($ips, $port, $timeout);
  }

  /**
   * Фильтр по статусу.
   *
   * @param array            $devices
   * @param string|string[]  $status
   * @return array
   */
  public function filter_by_status(array $devices, $status): array {
    $statuses = is_array($status) ? $status : [$status];
    $statuses = array_map('strval', $statuses);

    return array_values(array_filter($devices, function ($device) use ($statuses) {
      if (!isset($device['status'])) {
        return false;
      }
      return in_array((string)$device['status'], $statuses, true);
    }));
  }

  /**
   * Генерация диапазона IP (start..end включительно).
   */
  private function generate_ip_range(string $start_ip, string $end_ip): array {
    $start = ip2long($start_ip);
    $end   = ip2long($end_ip);

    if ($start === false || $end === false || $start > $end) {
      return [];
    }

    $ips = [];
    for ($ip_long = $start; $ip_long <= $end; $ip_long++) {
      $ips[] = long2ip($ip_long);
    }

    return $ips;
  }

  private const BATCH_SIZE = 8;

  /**
   * Параллельное сканирование IP с curl_multi и ограничением на размер батча.
   * Прерывается если клиент разорвал соединение (connection_aborted).
   */
  private function scan_ips(array $ips, int $port, float $timeout): array {
    $all_devices = [];

    $batches = array_chunk($ips, self::BATCH_SIZE);

    foreach ($batches as $batch) {
      if (connection_aborted() || connection_status() !== CONNECTION_NORMAL) {
        return $all_devices;
      }
      $batch_devices = $this -> scan_batch($batch, $port, $timeout);
      $all_devices = array_merge($all_devices, $batch_devices);
      usleep(200000);
    }

    return $all_devices;
  }

  /**
   * Сканирует один батч IP через curl_multi с retry для неответивших.
   * Retry пропускается если соединение прервано.
   */
  private function scan_batch(array $ips, int $port, float $timeout): array {
    $devices = $this -> scan_batch_multi($ips, $port, $timeout);

    if (connection_aborted() || connection_status() !== CONNECTION_NORMAL) {
      return $devices;
    }

    // Собираем IP, которые не ответили, для retry
    $responded_ips = [];
    foreach ($devices as $device) {
      if (isset($device['_meta']['ip'])) {
        $responded_ips[] = $device['_meta']['ip'];
      }
    }
    $failed_ips = array_values(array_diff($ips, $responded_ips));

    if (!empty($failed_ips)) {
      usleep(500000); // 500ms backoff перед retry
      $retry_devices = $this -> scan_batch_multi($failed_ips, $port, $timeout);
      $devices = array_merge($devices, $retry_devices);
    }

    return $devices;
  }

  /**
   * Внутренний curl_multi проход по батчу.
   * Прерывается если клиент разорвал соединение.
   */
  private function scan_batch_multi(array $ips, int $port, float $timeout): array {
    $multi = curl_multi_init();
    $handles = [];

    foreach ($ips as $ip) {
      $url = "http://{$ip}:{$port}/about";

      $ch = curl_init();
      $timeoutMs = (int) round($timeout * 1000);
      curl_setopt_array($ch, [
        CURLOPT_URL               => $url,
        CURLOPT_RETURNTRANSFER    => true,
        CURLOPT_CONNECTTIMEOUT_MS => $timeoutMs,
        CURLOPT_TIMEOUT_MS        => $timeoutMs,
        CURLOPT_FOLLOWLOCATION    => false,
        CURLOPT_NOBODY            => false,
        CURLOPT_HEADER            => false,
      ]);

      curl_multi_add_handle($multi, $ch);

      $handles[] = [
        'handle' => $ch,
        'ip'     => $ip,
        'url'    => $url,
      ];
    }

    $running = null;
    do {
      $status = curl_multi_exec($multi, $running);
      if ($status > 0) {
        break;
      }

      if (connection_aborted() || connection_status() !== CONNECTION_NORMAL) {
        break;
      }

      $select_result = curl_multi_select($multi, 0.1);
      if ($select_result === -1) {
        usleep(100000);
      }
    } while ($running > 0);

    $devices = [];

    foreach ($handles as $item) {
      $ch  = $item['handle'];
      $ip  = $item['ip'];
      $url = $item['url'];

      $body     = curl_multi_getcontent($ch);
      $curl_err = curl_errno($ch);
      $http     = curl_getinfo($ch, CURLINFO_HTTP_CODE);

      curl_multi_remove_handle($multi, $ch);
      curl_close($ch);

      if ($curl_err !== 0 || $http !== 200 || !$body) {
        continue;
      }

      $json = json_decode($body, true);
      if (!is_array($json)) {
        continue;
      }

      if (!isset($json['ip_address'])) {
        $json['ip_address'] = $ip;
      }
      if (!isset($json['server'])) {
        $json['server'] = "http://{$ip}";
      }

      $json['_meta'] = [
        'ip'        => $ip,
        'url'       => $url,
        'http_code' => $http,
      ];

      $devices[] = $json;
    }

    curl_multi_close($multi);

    return $devices;
  }

}