'LAN worker authorization is not configured', 'error_code' => 'EDGE_GATEWAY_WORKER_AUTH_UNCONFIGURED', ]); return false; } if (!hash_equals($expectedToken, worker_request_token())) { worker_json_response(401, [ 'message' => 'Unauthorized', 'error_code' => 'EDGE_GATEWAY_WORKER_UNAUTHORIZED', ]); return false; } return true; } function worker_http_get_json(string $url, int $timeoutSeconds = 8): array { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $timeoutSeconds, CURLOPT_CONNECTTIMEOUT => min(5, $timeoutSeconds), CURLOPT_FOLLOWLOCATION => true, CURLOPT_HTTPHEADER => ['Accept: application/json'], ]); $raw = curl_exec($ch); $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $error = curl_error($ch); curl_close($ch); if ($raw === false || $status >= 400) { throw new RuntimeException($error !== '' ? $error : 'HTTP ' . $status); } $decoded = json_decode((string)$raw, true); if (!is_array($decoded)) { throw new RuntimeException('Invalid JSON response from device'); } return $decoded; } function worker_fetch_shelly_state(string $localIp, int $channel, bool $includeInput = false): array { if ($localIp === '') { throw new RuntimeException('Missing Shelly IP address'); } $input = $includeInput ? worker_fetch_shelly_input_state($localIp, $channel) : null; try { $payload = worker_http_get_json(sprintf('http://%s/rpc/Switch.GetStatus?id=%d', $localIp, $channel)); return [ 'online' => true, 'on' => (bool)($payload['output'] ?? false), 'output' => (bool)($payload['output'] ?? false), 'input_state' => $input['state'] ?? null, 'input' => $input, 'raw' => $payload, ]; } catch (Throwable) { $payload = worker_http_get_json(sprintf('http://%s/relay/%d', $localIp, $channel)); return [ 'online' => true, 'on' => (bool)($payload['ison'] ?? false), 'output' => (bool)($payload['ison'] ?? false), 'input_state' => $input['state'] ?? null, 'input' => $input, 'raw' => $payload, ]; } } function worker_fetch_shelly_input_state(string $localIp, int $channel): ?array { if ($localIp === '') { throw new RuntimeException('Missing Shelly IP address'); } try { $payload = worker_http_get_json(sprintf('http://%s/rpc/Input.GetStatus?id=%d', $localIp, $channel), 2); return [ 'online' => true, 'state' => (bool)($payload['state'] ?? false), 'raw' => $payload, ]; } catch (Throwable) { return null; } } 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 { $runRpcSwitch(); } catch (Throwable) { $runLegacySwitch(); } return worker_fetch_shelly_state($localIp, $channel); } function worker_normalize_relay_commands(array $body): array { $commands = $body['commands'] ?? []; return is_array($commands) ? array_values($commands) : []; } function worker_relay_command_result(array $command, callable $handler): array { $target = trim((string)($command['target'] ?? $command['relay'] ?? '')); $relayId = trim((string)($command['relayId'] ?? $command['relay_id'] ?? '')); try { $localIp = trim((string)($command['local_ip'] ?? $command['localIp'] ?? '')); $channel = (int)($command['channel'] ?? 0); return [ 'target' => $target, 'relayId' => $relayId, 'relay_id' => $relayId, 'ok' => true, 'payload' => $handler($localIp, $channel, $command), ]; } catch (Throwable $throwable) { return [ 'target' => $target, 'relayId' => $relayId, 'relay_id' => $relayId, 'ok' => false, 'error' => $throwable->getMessage(), ]; } } $method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')); $path = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?? '/'); $body = worker_read_json_body(); $hostname = gethostname() ?: 'truckwash-edge'; try { if ($method === 'GET' && $path === '/health') { worker_json_response(200, [ 'status' => 'healthy', 'service' => 'lan-worker', 'timestamp' => date(DateTimeInterface::ATOM), ]); return; } if ($method === 'POST' && $path === '/discover') { if (!worker_require_authorization()) { return; } worker_json_response(200, [ 'inventory' => [[ 'device_id' => 'gateway-runtime-' . substr(sha1($hostname), 0, 10), 'local_ip' => gethostbyname($hostname), 'model' => 'TruckWash Edge Gateway', 'channel_count' => 1, 'online' => true, 'capabilities' => [ 'local_discovery' => true, 'relay_commands' => true, 'gateway_management_v2' => true, ], 'metadata' => [ 'hostname' => $hostname, 'runtime' => 'compose-lan-worker', 'php_version' => PHP_VERSION, ], ]], ]); return; } if ($method === 'POST' && $path === '/relay/status') { if (!worker_require_authorization()) { return; } $localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? '')); $channel = (int)($body['channel'] ?? 0); $includeInput = (bool)($body['include_input'] ?? $body['includeInput'] ?? false); worker_json_response(200, worker_fetch_shelly_state($localIp, $channel, $includeInput)); return; } if ($method === 'POST' && $path === '/relay/input-status') { if (!worker_require_authorization()) { return; } $localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? '')); $channel = (int)($body['channel'] ?? 0); $input = worker_fetch_shelly_input_state($localIp, $channel); if ($input === null) { throw new RuntimeException('Shelly input status is not available'); } worker_json_response(200, $input); return; } if ($method === 'POST' && $path === '/relay/switch') { if (!worker_require_authorization()) { return; } $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, $body)); return; } if ($method === 'POST' && $path === '/relay/batch-status') { if (!worker_require_authorization()) { return; } $results = array_map( fn(array $command): array => worker_relay_command_result( $command, fn(string $localIp, int $channel): array => worker_fetch_shelly_state($localIp, $channel) ), worker_normalize_relay_commands($body) ); worker_json_response(200, [ 'batch_id' => $body['batch_id'] ?? $body['batchId'] ?? null, 'results' => $results, ]); return; } if ($method === 'POST' && $path === '/relay/batch-switch') { if (!worker_require_authorization()) { return; } $results = array_map( fn(array $command): array => worker_relay_command_result( $command, fn(string $localIp, int $channel, array $entry): array => worker_switch_shelly_state( $localIp, $channel, (bool)($entry['on'] ?? false), $entry ) ), worker_normalize_relay_commands($body) ); worker_json_response(200, [ 'batch_id' => $body['batch_id'] ?? $body['batchId'] ?? null, 'results' => $results, ]); return; } worker_json_response(404, ['message' => 'Not found']); } catch (Throwable $throwable) { worker_json_response(422, [ 'message' => $throwable->getMessage(), 'error_code' => 'EDGE_GATEWAY_WORKER_FAILED', ]); }