256 lines
9.1 KiB
PHP
256 lines
9.1 KiB
PHP
<?php
|
|
|
|
function edge_gateway_relay_timer_free_port(): int
|
|
{
|
|
$socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);
|
|
if ($socket === false) {
|
|
throw new RuntimeException('Unable to allocate test port: ' . $errstr);
|
|
}
|
|
|
|
$address = (string)stream_socket_get_name($socket, false);
|
|
fclose($socket);
|
|
|
|
return (int)substr(strrchr($address, ':'), 1);
|
|
}
|
|
|
|
/**
|
|
* @return array{process:resource,pipes:array<int,resource>}
|
|
*/
|
|
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<int,resource>}|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<string,mixed>,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'
|
|
<?php
|
|
|
|
$logPath = (string)getenv('SHELLY_REQUEST_LOG');
|
|
if ($logPath !== '') {
|
|
file_put_contents($logPath, (string)($_SERVER['REQUEST_URI'] ?? '/') . PHP_EOL, FILE_APPEND);
|
|
}
|
|
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
$path = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?? '/');
|
|
if (str_contains($path, '/rpc/Switch.Set') || str_contains($path, '/rpc/Switch.GetStatus')) {
|
|
echo json_encode(['output' => 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='");
|
|
});
|