From 8ea10ef808a9e09ffbeab8ac666d6a702483a643 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Tue, 30 Jun 2026 15:53:29 +0200 Subject: [PATCH] Implement relay toggle handling with configurable timers and device generation resolution --- .../resources/edge-gateway-agent/agent.php | 149 +++++++++- .../edge-gateway-agent/lan-worker.php | 137 +++++++++- .../EdgeGatewayRelayExecutionTimerTest.php | 255 ++++++++++++++++++ 3 files changed, 527 insertions(+), 14 deletions(-) create mode 100644 services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRelayExecutionTimerTest.php diff --git a/services/nginx/app/resources/edge-gateway-agent/agent.php b/services/nginx/app/resources/edge-gateway-agent/agent.php index 8797a448..6589bc5c 100644 --- a/services/nginx/app/resources/edge-gateway-agent/agent.php +++ b/services/nginx/app/resources/edge-gateway-agent/agent.php @@ -1000,6 +1000,7 @@ final class TruckwashEdgeAgent private const BROKER_MESSAGE_PUMP_LIMIT = 12; private const LOOP_STALE_AFTER_SECONDS = 30; private const CONTROL_PLANE_SYNC_STALE_AFTER_SECONDS = 90; + private const MAX_RELAY_TOGGLE_AFTER_SECONDS = 5; private AgentConfig $config; private HttpJsonClient $http; @@ -2010,26 +2011,156 @@ final class TruckwashEdgeAgent $localIp = (string)($request['localIp'] ?? $request['local_ip'] ?? ''); $channel = (int)($request['channel'] ?? 0); $on = (bool)($request['on'] ?? false); + $toggleAfter = $this->resolveRelayToggleAfterSeconds($request); + $deviceGeneration = $this->resolveShellyCommandGeneration($request); + $workerPayload = [ + 'local_ip' => $localIp, + 'channel' => $channel, + 'on' => $on, + ]; + if ($toggleAfter !== null) { + $workerPayload['toggle_after'] = $toggleAfter; + $workerPayload['toggleAfter'] = $toggleAfter; + } + if ($deviceGeneration !== null) { + $workerPayload['device_generation'] = $deviceGeneration; + $workerPayload['deviceGeneration'] = $deviceGeneration; + } try { - return $this->workerHttp->post('/relay/switch', [ - 'local_ip' => $localIp, - 'channel' => $channel, - 'on' => $on, - ], 8) ?? []; + return $this->workerHttp->post('/relay/switch', $workerPayload, 8) ?? []; } catch (Throwable) { - $rpcUrl = sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s', $localIp, $channel, $on ? 'true' : 'false'); + $timerQuery = $toggleAfter === null ? '' : '&toggle_after=' . rawurlencode((string)$toggleAfter); + $legacyTimerQuery = $toggleAfter === null ? '' : '&timer=' . rawurlencode((string)$toggleAfter); + $runRpcSwitch = fn(): array => $this->http->getJson( + sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s%s', $localIp, $channel, $on ? 'true' : 'false', $timerQuery), + 8 + ); + $runLegacySwitch = fn(): array => $this->http->getJson( + sprintf('http://%s/relay/%d?turn=%s%s', $localIp, $channel, $on ? 'on' : 'off', $legacyTimerQuery), + 8 + ); + + if ($toggleAfter !== null) { + $attempts = $deviceGeneration === 1 + ? [$runLegacySwitch, $runRpcSwitch] + : [$runRpcSwitch, $runLegacySwitch]; + $lastError = null; + foreach ($attempts as $attempt) { + try { + $attempt(); + return $this->fetchShellyState($localIp, $channel); + } catch (Throwable $throwable) { + $lastError = $throwable; + } + } + + throw $lastError ?? new RuntimeException('Unable to switch relay'); + } + try { - $this->http->getJson($rpcUrl, 8); + $runRpcSwitch(); } catch (Throwable) { - $legacyUrl = sprintf('http://%s/relay/%d?turn=%s', $localIp, $channel, $on ? 'on' : 'off'); - $this->http->getJson($legacyUrl, 8); + $runLegacySwitch(); } return $this->fetchShellyState($localIp, $channel); } } + private function resolveRelayToggleAfterSeconds(array $request): ?int + { + $configured = $request['toggleAfter'] ?? $request['toggle_after'] ?? $request['timer'] ?? null; + if (!is_int($configured) && !is_float($configured) && !(is_string($configured) && is_numeric(trim($configured)))) { + return null; + } + + $seconds = (int)floor((float)$configured); + if ($seconds <= 0) { + return null; + } + + return min($seconds, self::MAX_RELAY_TOGGLE_AFTER_SECONDS); + } + + private function normalizeShellyDeviceGeneration(mixed $value): ?int + { + if (is_int($value) || is_float($value) || (is_string($value) && is_numeric(trim($value)))) { + $generation = (int)$value; + return $generation > 0 ? $generation : null; + } + + return null; + } + + private function inferShellyDeviceGenerationFromString(mixed $value): ?int + { + $normalized = trim((string)$value); + if ($normalized === '') { + return null; + } + + if (preg_match('/\bgen(?:eration)?\s*([1-9]\d*)\b/i', $normalized, $explicit) === 1) { + return (int)$explicit[1]; + } + + $upper = strtoupper($normalized); + if (preg_match('/\bS([3-9])(?:[A-Z0-9]+)?-[A-Z0-9-]+\b/', $upper, $series) === 1) { + return (int)$series[1]; + } + + if (preg_match('/\b(?:SHELLY\s+)?(?:PLUS|PRO)\b/i', $normalized) === 1 || preg_match('/\bSP[A-Z0-9]+-[A-Z0-9-]+\b/', $upper) === 1) { + return 2; + } + + if (preg_match('/\bSH[A-Z0-9]+-?[A-Z0-9-]*\b/', $upper) === 1) { + return 1; + } + + return null; + } + + private function resolveShellyCommandGeneration(array $request): ?int + { + foreach ([ + $request['gen'] ?? null, + $request['generation'] ?? null, + $request['deviceGeneration'] ?? null, + $request['device_generation'] ?? null, + is_array($request['capabilities'] ?? null) ? ($request['capabilities']['generation'] ?? null) : null, + ] as $candidate) { + $generation = $this->normalizeShellyDeviceGeneration($candidate); + if ($generation !== null) { + return $generation; + } + } + + foreach ([ + $request['model'] ?? null, + $request['deviceModel'] ?? null, + $request['device_model'] ?? null, + $request['type'] ?? null, + $request['deviceType'] ?? null, + $request['device_type'] ?? null, + $request['app'] ?? null, + $request['name'] ?? null, + $request['deviceName'] ?? null, + $request['device_name'] ?? null, + $request['deviceId'] ?? null, + $request['device_id'] ?? null, + $request['relayId'] ?? null, + $request['relay_id'] ?? null, + $request['mac'] ?? null, + ] as $candidate) { + $generation = $this->inferShellyDeviceGenerationFromString($candidate); + if ($generation !== null) { + return $generation; + } + } + + return null; + } + private function downloadToFileAtomic(string $url, string $path, string $expectedSha256 = ''): array { $directory = dirname($path); diff --git a/services/nginx/app/resources/edge-gateway-agent/lan-worker.php b/services/nginx/app/resources/edge-gateway-agent/lan-worker.php index 1237aa7d..2d3b685d 100644 --- a/services/nginx/app/resources/edge-gateway-agent/lan-worker.php +++ b/services/nginx/app/resources/edge-gateway-agent/lan-worker.php @@ -2,6 +2,8 @@ declare(strict_types=1); +const WORKER_MAX_RELAY_TOGGLE_AFTER_SECONDS = 5; + function worker_json_response(int $status, array $payload): void { http_response_code($status); @@ -156,12 +158,136 @@ function worker_fetch_shelly_input_state(string $localIp, int $channel): ?array } } -function worker_switch_shelly_state(string $localIp, int $channel, bool $on): array +function worker_normalize_shelly_device_generation(mixed $value): ?int { + if (is_int($value) || is_float($value) || (is_string($value) && is_numeric(trim($value)))) { + $generation = (int)$value; + return $generation > 0 ? $generation : null; + } + + return null; +} + +function worker_infer_shelly_device_generation_from_string(mixed $value): ?int +{ + $normalized = trim((string)$value); + if ($normalized === '') { + return null; + } + + if (preg_match('/\bgen(?:eration)?\s*([1-9]\d*)\b/i', $normalized, $explicit) === 1) { + return (int)$explicit[1]; + } + + $upper = strtoupper($normalized); + if (preg_match('/\bS([3-9])(?:[A-Z0-9]+)?-[A-Z0-9-]+\b/', $upper, $series) === 1) { + return (int)$series[1]; + } + + if (preg_match('/\b(?:SHELLY\s+)?(?:PLUS|PRO)\b/i', $normalized) === 1 || preg_match('/\bSP[A-Z0-9]+-[A-Z0-9-]+\b/', $upper) === 1) { + return 2; + } + + if (preg_match('/\bSH[A-Z0-9]+-?[A-Z0-9-]*\b/', $upper) === 1) { + return 1; + } + + return null; +} + +function worker_resolve_shelly_command_generation(array $command): ?int +{ + foreach ([ + $command['gen'] ?? null, + $command['generation'] ?? null, + $command['deviceGeneration'] ?? null, + $command['device_generation'] ?? null, + is_array($command['capabilities'] ?? null) ? ($command['capabilities']['generation'] ?? null) : null, + ] as $candidate) { + $generation = worker_normalize_shelly_device_generation($candidate); + if ($generation !== null) { + return $generation; + } + } + + foreach ([ + $command['model'] ?? null, + $command['deviceModel'] ?? null, + $command['device_model'] ?? null, + $command['type'] ?? null, + $command['deviceType'] ?? null, + $command['device_type'] ?? null, + $command['app'] ?? null, + $command['name'] ?? null, + $command['deviceName'] ?? null, + $command['device_name'] ?? null, + $command['deviceId'] ?? null, + $command['device_id'] ?? null, + $command['relayId'] ?? null, + $command['relay_id'] ?? null, + $command['mac'] ?? null, + ] as $candidate) { + $generation = worker_infer_shelly_device_generation_from_string($candidate); + if ($generation !== null) { + return $generation; + } + } + + return null; +} + +function worker_resolve_relay_toggle_after_seconds(array $command): ?int +{ + $configured = $command['toggleAfter'] ?? $command['toggle_after'] ?? $command['timer'] ?? null; + if (!is_int($configured) && !is_float($configured) && !(is_string($configured) && is_numeric(trim($configured)))) { + return null; + } + + $seconds = (int)floor((float)$configured); + if ($seconds <= 0) { + return null; + } + + return min($seconds, WORKER_MAX_RELAY_TOGGLE_AFTER_SECONDS); +} + +function worker_switch_shelly_state(string $localIp, int $channel, bool $on, array $command = []): array +{ + if ($localIp === '') { + throw new RuntimeException('Missing Shelly IP address'); + } + + $toggleAfter = worker_resolve_relay_toggle_after_seconds($command); + $timerQuery = $toggleAfter === null ? '' : '&toggle_after=' . rawurlencode((string)$toggleAfter); + $legacyTimerQuery = $toggleAfter === null ? '' : '&timer=' . rawurlencode((string)$toggleAfter); + $runRpcSwitch = static fn(): array => worker_http_get_json( + sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s%s', $localIp, $channel, $on ? 'true' : 'false', $timerQuery) + ); + $runLegacySwitch = static fn(): array => worker_http_get_json( + sprintf('http://%s/relay/%d?turn=%s%s', $localIp, $channel, $on ? 'on' : 'off', $legacyTimerQuery) + ); + + if ($toggleAfter !== null) { + $attempts = worker_resolve_shelly_command_generation($command) === 1 + ? [$runLegacySwitch, $runRpcSwitch] + : [$runRpcSwitch, $runLegacySwitch]; + $lastError = null; + foreach ($attempts as $attempt) { + try { + $attempt(); + return worker_fetch_shelly_state($localIp, $channel); + } catch (Throwable $throwable) { + $lastError = $throwable; + } + } + + throw $lastError ?? new RuntimeException('Unable to switch relay'); + } + try { - worker_http_get_json(sprintf('http://%s/rpc/Switch.Set?id=%d&on=%s', $localIp, $channel, $on ? 'true' : 'false')); + $runRpcSwitch(); } catch (Throwable) { - worker_http_get_json(sprintf('http://%s/relay/%d?turn=%s', $localIp, $channel, $on ? 'on' : 'off')); + $runLegacySwitch(); } return worker_fetch_shelly_state($localIp, $channel); @@ -276,7 +402,7 @@ try { $localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? '')); $channel = (int)($body['channel'] ?? 0); $on = (bool)($body['on'] ?? false); - worker_json_response(200, worker_switch_shelly_state($localIp, $channel, $on)); + worker_json_response(200, worker_switch_shelly_state($localIp, $channel, $on, $body)); return; } @@ -310,7 +436,8 @@ try { fn(string $localIp, int $channel, array $entry): array => worker_switch_shelly_state( $localIp, $channel, - (bool)($entry['on'] ?? false) + (bool)($entry['on'] ?? false), + $entry ) ), worker_normalize_relay_commands($body) diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRelayExecutionTimerTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRelayExecutionTimerTest.php new file mode 100644 index 00000000..be08384c --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRelayExecutionTimerTest.php @@ -0,0 +1,255 @@ +} + */ +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='"); +});