diff --git a/server/SHServ/Controllers/CronController.php b/server/SHServ/Controllers/CronController.php index 2fb8bf7..937d5c6 100644 --- a/server/SHServ/Controllers/CronController.php +++ b/server/SHServ/Controllers/CronController.php @@ -22,6 +22,10 @@ } } + protected function createDeviceScanner(): DeviceScanner { + return new DeviceScanner(); + } + protected function run_script_cli(String $alias): int { $output = []; $returnCode = 0; @@ -53,53 +57,63 @@ public function status_update_scanning() { $this -> ensure_localhost_only(); - $device_scanner = new DeviceScanner(); - $found_devices = $device_scanner -> scan_range(FCONF["device_ip_range"][0], FCONF["device_ip_range"][1]); - $devices_model = new Devices(); + $threshold = FCONF["device_offline_threshold"] ?? 300; + $now = time(); + + $devices_model = new Devices(); $active_devices = $devices_model -> get_device_list(); - foreach($active_devices as $active_device) { - $is_active = false; + // 1. Помечаем оффлайн устройства с устаревшим last_contact + foreach($active_devices as $device) { + $last_contact_ts = strtotime($device -> last_contact); + if($last_contact_ts === false) { + continue; + } + + $seconds_since_contact = $now - $last_contact_ts; + + if($seconds_since_contact > $threshold) { + if($device -> connection_status != "lost") { + $device -> connection_status = "lost"; + $device -> update(); + } + } + } + + // 2. Сканируем сеть — восстанавливаем lost и обновляем IP при роуминге + $device_scanner = $this -> createDeviceScanner(); + $found_devices = $device_scanner -> scan_range(FCONF["device_ip_range"][0], FCONF["device_ip_range"][1]); + + $found_by_hard_id = []; + foreach($found_devices as $found) { + $found_by_hard_id[$found["device_id"]] = $found; + } + + foreach($active_devices as $device) { + if(!isset($found_by_hard_id[$device -> device_hard_id])) { + continue; + } + + $found = $found_by_hard_id[$device -> device_hard_id]; $is_changed = false; - foreach($found_devices as $found_device) { - if($active_device -> device_hard_id != $found_device["device_id"]) { - continue; - } - - $is_active = true; - - if($active_device -> connection_status == "lost") { - $active_device -> connection_status = "active"; - $is_changed = true; - } - - if($active_device -> device_ip != $found_device["ip_address"]) { - $active_device -> device_ip = $found_device["ip_address"]; - $is_changed = true; - } - - break; + if($device -> device_ip != $found["ip_address"]) { + $device -> device_ip = $found["ip_address"]; + $is_changed = true; } - if(!$is_active) { - for($i = 0; $i < 2; $i++) { - if($active_device -> ping()) { - $is_active = true; - break; - } - } + if($device -> connection_status != "active") { + $device -> connection_status = "active"; + $is_changed = true; } - if(!$is_active) { - if($active_device -> connection_status == "active") { - $active_device -> connection_status = "lost"; - $is_changed = true; - } + if($is_changed) { + $device -> update(); } - - $is_changed and $active_device -> update(); + + // HTTP-ответ от устройства = контакт + $device -> touch_last_contact(); } } -} \ No newline at end of file +} diff --git a/server/SHServ/Controllers/DevicesRESTAPIController.php b/server/SHServ/Controllers/DevicesRESTAPIController.php index 6f942f5..add788d 100644 --- a/server/SHServ/Controllers/DevicesRESTAPIController.php +++ b/server/SHServ/Controllers/DevicesRESTAPIController.php @@ -181,14 +181,9 @@ $device_status = $device -> device_api() -> get_status(); if(!$device_status || ($device_status["status"] ?? null) !== "ok") { - if($device -> connection_status != "lost") { - $device -> connection_status = "lost"; - $device -> update(); - } - return $this -> utils() -> response_error("device_request_fail", [], [ "device_id" => $device -> id(), - "connection_status" => "lost" + "connection_status" => $device -> connection_status ]); } diff --git a/server/SHServ/Controllers/EventsController.php b/server/SHServ/Controllers/EventsController.php index bc22ce7..6156a19 100644 --- a/server/SHServ/Controllers/EventsController.php +++ b/server/SHServ/Controllers/EventsController.php @@ -28,6 +28,11 @@ $device -> touch_last_contact(); + if($device -> connection_status != "active") { + $device -> connection_status = "active"; + $device -> update(); + } + ignore_user_abort(true); set_time_limit(10); diff --git a/server/SHServ/Tools/DeviceScanner.php b/server/SHServ/Tools/DeviceScanner.php index 9817e7d..2d977a2 100644 --- a/server/SHServ/Tools/DeviceScanner.php +++ b/server/SHServ/Tools/DeviceScanner.php @@ -19,7 +19,7 @@ * @param float $timeout Таймаут на IP (сек) * @return array Найденные устройства */ - public function scan_range(string $start_ip, string $end_ip, int $port = 80, float $timeout = 1): 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 []; @@ -66,7 +66,7 @@ return $ips; } - private const BATCH_SIZE = 32; + private const BATCH_SIZE = 8; /** * Параллельное сканирование IP с curl_multi и ограничением на размер батча. @@ -79,15 +79,40 @@ foreach ($batches as $batch) { $batch_devices = $this -> scan_batch($batch, $port, $timeout); $all_devices = array_merge($all_devices, $batch_devices); + usleep(200000); } return $all_devices; } /** - * Сканирует один батч IP через curl_multi. + * Сканирует один батч IP через curl_multi с retry для неответивших. */ private function scan_batch(array $ips, int $port, float $timeout): array { + $devices = $this -> scan_batch_multi($ips, $port, $timeout); + + // Собираем 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 = []; diff --git a/server/SHServ/config.php b/server/SHServ/config.php index 62ea1bd..d7d8343 100644 --- a/server/SHServ/config.php +++ b/server/SHServ/config.php @@ -45,5 +45,6 @@ $env['DEVICE_IP_RANGE_END'] ?? "192.168.2.254" ], "device_api_connect_timeout" => (float)($env['DEVICE_API_CONNECT_TIMEOUT'] ?? "1"), - "device_api_timeout" => (float)($env['DEVICE_API_TIMEOUT'] ?? "5") + "device_api_timeout" => (float)($env['DEVICE_API_TIMEOUT'] ?? "5"), + "device_offline_threshold" => (int)($env['DEVICE_OFFLINE_THRESHOLD'] ?? "300") ]; diff --git a/server/tests/CronControllerTest.php b/server/tests/CronControllerTest.php index 50ba1e3..08da8d8 100644 --- a/server/tests/CronControllerTest.php +++ b/server/tests/CronControllerTest.php @@ -3,14 +3,30 @@ use PHPUnit\Framework\TestCase; use SHServ\Controllers\CronController; use SHServ\Middleware\ControlScripts; +use SHServ\Tools\DeviceScanner; + +class TestableDeviceScanner extends DeviceScanner { + public $mockResults = []; + public function scan_range(string $start_ip, string $end_ip, int $port = 80, float $timeout = 1): array { + return $this -> mockResults; + } +} class TestableCronController extends CronController { public $cliCalls = []; public $returnCodes = []; + public $mockScannerResults = []; + protected function run_script_cli(String $alias): int { $this -> cliCalls[] = $alias; return $this -> returnCodes[$alias] ?? 0; } + + protected function createDeviceScanner(): DeviceScanner { + $scanner = new TestableDeviceScanner(); + $scanner -> mockResults = $this -> mockScannerResults; + return $scanner; + } } class CronControllerTest extends TestCase { @@ -27,11 +43,29 @@ create_at TEXT, update_at TEXT )"); + $this -> tb -> query("CREATE TABLE devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + area_id INTEGER DEFAULT 0, + alias TEXT, + name TEXT, + device_type TEXT, + device_ip TEXT, + device_mac TEXT, + device_hard_id TEXT, + firmware_version TEXT, + connection_status TEXT, + status TEXT, + description TEXT, + last_contact TEXT, + create_at TEXT, + update_at TEXT + )"); ControlScripts::flush_statics(); } protected function tearDown(): void { $this -> tb -> query("DROP TABLE IF EXISTS scripts"); + $this -> tb -> query("DROP TABLE IF EXISTS devices"); ControlScripts::flush_statics(); } @@ -102,4 +136,106 @@ $this -> assertSame([], $controller -> cliCalls); } + + public function test_marks_stale_devices_as_lost(): void { + $this -> tb -> insert('devices', [ + 'alias' => 'stale_device', + 'name' => 'Stale Device', + 'device_type' => 'relay', + 'device_ip' => '192.168.1.10', + 'device_hard_id' => 'hard_stale_001', + 'connection_status' => 'active', + 'status' => 'active', + 'last_contact' => date('Y-m-d H:i:s', time() - 600), // 10 минут назад + 'create_at' => date('Y-m-d H:i:s'), + ]); + + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + $controller = new TestableCronController(); + $controller -> mockScannerResults = []; + $controller -> status_update_scanning(); + + $row = $this -> tb -> query("SELECT connection_status FROM devices WHERE device_hard_id = 'hard_stale_001'") -> fetch(\PDO::FETCH_ASSOC); + $this -> assertSame('lost', $row['connection_status']); + } + + public function test_keeps_fresh_devices_active(): void { + $this -> tb -> insert('devices', [ + 'alias' => 'fresh_device', + 'name' => 'Fresh Device', + 'device_type' => 'relay', + 'device_ip' => '192.168.1.20', + 'device_hard_id' => 'hard_fresh_001', + 'connection_status' => 'active', + 'status' => 'active', + 'last_contact' => date('Y-m-d H:i:s', time() - 60), // 1 минуту назад + 'create_at' => date('Y-m-d H:i:s'), + ]); + + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + $controller = new TestableCronController(); + $controller -> mockScannerResults = []; + $controller -> status_update_scanning(); + + $row = $this -> tb -> query("SELECT connection_status FROM devices WHERE device_hard_id = 'hard_fresh_001'") -> fetch(\PDO::FETCH_ASSOC); + $this -> assertSame('active', $row['connection_status']); + } + + public function test_scanner_recovers_lost_device(): void { + $this -> tb -> insert('devices', [ + 'alias' => 'lost_device', + 'name' => 'Lost Device', + 'device_type' => 'relay', + 'device_ip' => '192.168.1.30', + 'device_hard_id' => 'hard_lost_001', + 'connection_status' => 'lost', + 'status' => 'active', + 'last_contact' => date('Y-m-d H:i:s', time() - 600), + 'create_at' => date('Y-m-d H:i:s'), + ]); + + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + $controller = new TestableCronController(); + $controller -> mockScannerResults = [ + [ + 'device_id' => 'hard_lost_001', + 'ip_address' => '192.168.1.30', + 'device_type' => 'relay', + ] + ]; + $controller -> status_update_scanning(); + + $row = $this -> tb -> query("SELECT connection_status, device_ip FROM devices WHERE device_hard_id = 'hard_lost_001'") -> fetch(\PDO::FETCH_ASSOC); + $this -> assertSame('active', $row['connection_status']); + $this -> assertSame('192.168.1.30', $row['device_ip']); + } + + public function test_scanner_updates_ip_on_roaming(): void { + $this -> tb -> insert('devices', [ + 'alias' => 'roaming_device', + 'name' => 'Roaming Device', + 'device_type' => 'relay', + 'device_ip' => '192.168.1.40', + 'device_hard_id' => 'hard_roam_001', + 'connection_status' => 'active', + 'status' => 'active', + 'last_contact' => date('Y-m-d H:i:s', time() - 60), + 'create_at' => date('Y-m-d H:i:s'), + ]); + + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + $controller = new TestableCronController(); + $controller -> mockScannerResults = [ + [ + 'device_id' => 'hard_roam_001', + 'ip_address' => '192.168.1.99', + 'device_type' => 'relay', + ] + ]; + $controller -> status_update_scanning(); + + $row = $this -> tb -> query("SELECT connection_status, device_ip FROM devices WHERE device_hard_id = 'hard_roam_001'") -> fetch(\PDO::FETCH_ASSOC); + $this -> assertSame('active', $row['connection_status']); + $this -> assertSame('192.168.1.99', $row['device_ip']); + } } diff --git a/server/tests/bootstrap.php b/server/tests/bootstrap.php index 4bdb02f..9a89217 100644 --- a/server/tests/bootstrap.php +++ b/server/tests/bootstrap.php @@ -60,5 +60,7 @@ ], 'logs_enable' => false, 'logs_folder' => __DIR__ . '/Logs', + 'device_ip_range' => ['192.168.1.2', '192.168.1.254'], + 'device_offline_threshold' => 300, ]); }