Implement relay toggle handling with configurable timers and device generation resolution

This commit is contained in:
Jeppe Bundgaard
2026-06-30 15:53:29 +02:00
parent ac7da807bd
commit 8ea10ef808
3 changed files with 527 additions and 14 deletions
@@ -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='");
});