Implement relay toggle handling with configurable timers and device generation resolution
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
<?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='");
|
||||
});
|
||||
Reference in New Issue
Block a user