} */ function edge_gateway_relay_timer_start_php_server(int $port, string $router, array $environment = []): array { $command = escapeshellarg(PHP_BINARY) . ' -S 127.0.0.1:' . $port . ' ' . escapeshellarg($router); $descriptors = [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ]; $processEnvironment = []; foreach (array_merge($_ENV, $_SERVER, $environment) as $key => $value) { if (is_scalar($value) || $value === null) { $processEnvironment[(string)$key] = (string)$value; } } $process = proc_open( $command, $descriptors, $pipes, app_path(), $processEnvironment ); if (!is_resource($process)) { throw new RuntimeException('Unable to start PHP test server'); } foreach ($pipes as $pipe) { stream_set_blocking($pipe, false); } $deadline = microtime(true) + 5.0; do { set_error_handler(static fn(): bool => true); $connection = @fsockopen('127.0.0.1', $port, $errno, $errstr, 0.1); restore_error_handler(); if (is_resource($connection)) { fclose($connection); return ['process' => $process, 'pipes' => $pipes]; } usleep(50000); } while (microtime(true) < $deadline); edge_gateway_relay_timer_stop_php_server(['process' => $process, 'pipes' => $pipes]); throw new RuntimeException('Timed out waiting for PHP test server on port ' . $port); } /** * @param array{process:resource,pipes:array}|null $server */ function edge_gateway_relay_timer_stop_php_server(?array $server): void { if ($server === null) { return; } foreach ($server['pipes'] as $pipe) { if (is_resource($pipe)) { fclose($pipe); } } if (is_resource($server['process'])) { proc_terminate($server['process']); proc_close($server['process']); } } /** * @return array{status:int,body:array,raw:string} */ function edge_gateway_relay_timer_post_json(int $port, string $path, array $payload): array { $context = stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => implode("\r\n", [ 'Content-Type: application/json', 'X-TruckWash-Worker-Token: worker-token', ]) . "\r\n", 'content' => json_encode($payload, JSON_UNESCAPED_SLASHES), 'ignore_errors' => true, 'timeout' => 5, ], ]); $raw = file_get_contents('http://127.0.0.1:' . $port . $path, false, $context); if ($raw === false) { throw new RuntimeException('Worker request failed'); } $status = 0; foreach ($http_response_header ?? [] as $header) { if (preg_match('/^HTTP\/\S+\s+(\d+)/', $header, $matches) === 1) { $status = (int)$matches[1]; break; } } $decoded = json_decode($raw, true); if (!is_array($decoded)) { throw new RuntimeException('Worker returned invalid JSON: ' . $raw); } return ['status' => $status, 'body' => $decoded, 'raw' => $raw]; } function edge_gateway_relay_timer_fake_shelly_router(string $directory): string { $router = $directory . DIRECTORY_SEPARATOR . 'fake-shelly.php'; file_put_contents($router, <<<'PHP' true], JSON_UNESCAPED_SLASHES); return; } if (preg_match('#/relay/\d+#', $path) === 1) { echo json_encode(['ison' => true], JSON_UNESCAPED_SLASHES); return; } echo json_encode(['ok' => true], JSON_UNESCAPED_SLASHES); PHP); return $router; } it('passes direct relay switch timers through the LAN worker and prefers legacy timers for Gen1 relays', function (): void { $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'edge-gateway-relay-timer-' . bin2hex(random_bytes(6)); mkdir($tempDir, 0777, true); $logPath = $tempDir . DIRECTORY_SEPARATOR . 'shelly.log'; $fakeServer = null; $workerServer = null; try { $fakePort = edge_gateway_relay_timer_free_port(); $workerPort = edge_gateway_relay_timer_free_port(); $fakeServer = edge_gateway_relay_timer_start_php_server( $fakePort, edge_gateway_relay_timer_fake_shelly_router($tempDir), ['SHELLY_REQUEST_LOG' => $logPath] ); $workerServer = edge_gateway_relay_timer_start_php_server( $workerPort, app_path('resources/edge-gateway-agent/lan-worker.php'), ['TRUCKWASH_WORKER_TOKEN' => 'worker-token'] ); $response = edge_gateway_relay_timer_post_json($workerPort, '/relay/switch', [ 'local_ip' => '127.0.0.1:' . $fakePort, 'channel' => 0, 'on' => true, 'timer' => 3, 'device_generation' => 1, ]); $requests = file($logPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; expect($response['status'])->toBe(200) ->and($response['body']['on'])->toBeTrue() ->and($requests[0] ?? null)->toBe('/relay/0?turn=on&timer=3'); } finally { edge_gateway_relay_timer_stop_php_server($workerServer); edge_gateway_relay_timer_stop_php_server($fakeServer); array_map('unlink', glob($tempDir . DIRECTORY_SEPARATOR . '*') ?: []); @rmdir($tempDir); } }); it('caps batch relay switch timers and sends Gen3 relays through Shelly RPC first', function (): void { $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'edge-gateway-relay-batch-' . bin2hex(random_bytes(6)); mkdir($tempDir, 0777, true); $logPath = $tempDir . DIRECTORY_SEPARATOR . 'shelly.log'; $fakeServer = null; $workerServer = null; try { $fakePort = edge_gateway_relay_timer_free_port(); $workerPort = edge_gateway_relay_timer_free_port(); $fakeServer = edge_gateway_relay_timer_start_php_server( $fakePort, edge_gateway_relay_timer_fake_shelly_router($tempDir), ['SHELLY_REQUEST_LOG' => $logPath] ); $workerServer = edge_gateway_relay_timer_start_php_server( $workerPort, app_path('resources/edge-gateway-agent/lan-worker.php'), ['TRUCKWASH_WORKER_TOKEN' => 'worker-token'] ); $response = edge_gateway_relay_timer_post_json($workerPort, '/relay/batch-switch', [ 'batch_id' => 'batch-1', 'commands' => [[ 'target' => 'EXIT', 'relay_id' => 'relay-out', 'local_ip' => '127.0.0.1:' . $fakePort, 'channel' => 0, 'on' => true, 'toggle_after' => 999, 'device_model' => 'S3SW-001X8EU', ]], ]); $requests = file($logPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; expect($response['status'])->toBe(200) ->and($response['body']['results'][0]['ok'])->toBeTrue() ->and($response['body']['results'][0]['relay_id'])->toBe('relay-out') ->and($requests[0] ?? null)->toBe('/rpc/Switch.Set?id=0&on=true&toggle_after=5'); } finally { edge_gateway_relay_timer_stop_php_server($workerServer); edge_gateway_relay_timer_stop_php_server($fakeServer); array_map('unlink', glob($tempDir . DIRECTORY_SEPARATOR . '*') ?: []); @rmdir($tempDir); } }); it('keeps PHP edge-agent relay fallback propagation aligned with LAN worker timer handling', function (): void { $agentSource = (string)file_get_contents(app_path('resources/edge-gateway-agent/agent.php')); expect($agentSource)->toContain('private const MAX_RELAY_TOGGLE_AFTER_SECONDS = 5;') ->and($agentSource)->toContain('$workerPayload[\'toggle_after\'] = $toggleAfter;') ->and($agentSource)->toContain('$workerPayload[\'device_generation\'] = $deviceGeneration;') ->and($agentSource)->toContain('private function resolveRelayToggleAfterSeconds(array $request): ?int') ->and($agentSource)->toContain('private function resolveShellyCommandGeneration(array $request): ?int') ->and($agentSource)->toContain('$deviceGeneration === 1') ->and($agentSource)->toContain('rawurlencode((string)$toggleAfter)') ->and($agentSource)->toContain("'&timer='"); });