459 lines
14 KiB
PHP
459 lines
14 KiB
PHP
<?php
|
|
|
|
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);
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
echo json_encode($payload, JSON_UNESCAPED_SLASHES);
|
|
}
|
|
|
|
function worker_read_json_body(): array
|
|
{
|
|
$raw = file_get_contents('php://input');
|
|
if (!is_string($raw) || trim($raw) === '') {
|
|
return [];
|
|
}
|
|
|
|
$decoded = json_decode($raw, true);
|
|
return is_array($decoded) ? $decoded : [];
|
|
}
|
|
|
|
function worker_config_path(): string
|
|
{
|
|
$configuredPath = trim((string)getenv('TRUCKWASH_WORKER_CONFIG_PATH'));
|
|
return $configuredPath !== '' ? $configuredPath : '/config/config.json';
|
|
}
|
|
|
|
function worker_expected_token(): string
|
|
{
|
|
$environmentToken = trim((string)getenv('TRUCKWASH_WORKER_TOKEN'));
|
|
if ($environmentToken !== '') {
|
|
return $environmentToken;
|
|
}
|
|
|
|
$configPath = worker_config_path();
|
|
if (!is_file($configPath)) {
|
|
return '';
|
|
}
|
|
|
|
$decoded = json_decode((string)file_get_contents($configPath), true);
|
|
return is_array($decoded) ? trim((string)($decoded['agentToken'] ?? '')) : '';
|
|
}
|
|
|
|
function worker_request_token(): string
|
|
{
|
|
$headerToken = trim((string)($_SERVER['HTTP_X_TRUCKWASH_WORKER_TOKEN'] ?? ''));
|
|
if ($headerToken !== '') {
|
|
return $headerToken;
|
|
}
|
|
|
|
$authorization = trim((string)($_SERVER['HTTP_AUTHORIZATION'] ?? ''));
|
|
if (str_starts_with(strtolower($authorization), 'bearer ')) {
|
|
return trim(substr($authorization, 7));
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function worker_require_authorization(): bool
|
|
{
|
|
$expectedToken = worker_expected_token();
|
|
if ($expectedToken === '') {
|
|
worker_json_response(503, [
|
|
'message' => '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',
|
|
]);
|
|
}
|