requireDepartment($departmentId); $token = bin2hex(random_bytes(24)); $claimToken = new edge_gateway_claim_tokens_o(); $claimTokenId = $claimToken->add_object([ 'department_id' => $departmentId, 'label' => $label, 'token_hash' => $this->hashToken($token), 'created_by' => $createdBy, 'expires_at' => $this->formatDateTime(time() + self::INSTALL_TOKEN_TTL_SECONDS), 'metadata_json' => [], ]); $claimToken->select($claimTokenId); $this->writeAudit( null, $departmentId, 'INSTALL_TOKEN_CREATED', $createdBy, ['claim_token_id' => $claimTokenId, 'label' => $label] ); return [ 'claim_token_id' => $claimTokenId, 'token' => $token, 'expires_at' => (string)$claimToken->expires_at->value(), 'install_command' => $this->buildInstallCommand($token), 'install_url' => $this->buildInstallScriptUrl($token), ]; } /** * @throws Exception */ public function claimGateway(string $token, string $hostname, ?string $installedVersion = null, array $metadata = []): array { $claimToken = $this->requireClaimToken($token); if ($claimToken->used_at->value() !== null) { throw new Exception('Install token has already been used'); } $departmentId = (int)$claimToken->department_id->value(); $label = trim((string)($claimToken->label->value() ?? $hostname)); $label = $label !== '' ? $label : 'Department gateway'; $agentToken = bin2hex(random_bytes(32)); $gateway = new edge_gateways_o(); $gatewayId = $gateway->add_object([ 'department_id' => $departmentId, 'label' => $label, 'hostname' => trim($hostname) !== '' ? trim($hostname) : null, 'agent_token_hash' => $this->hashToken($agentToken), 'status' => self::STATUS_ONLINE, 'transport_mode' => self::TRANSPORT_MODE_GATEWAY, 'release_channel' => self::DEFAULT_RELEASE_CHANNEL, 'installed_version' => $installedVersion, 'target_version' => $installedVersion, 'last_heartbeat_at' => $this->now(), 'last_seen_ip' => $this->remoteIp(), 'discovery_status' => 'PENDING', 'is_primary' => 1, 'metadata_json' => array_merge($metadata, [ 'credentials_rotated_at' => $this->now(), 'agent_runtime' => 'php-cli', ]), ]); $claimToken->used_at->set($this->now()); $gateway->select($gatewayId); $this->setGatewayPrimaryState($gateway, true); $this->writeAudit( $gatewayId, $departmentId, 'GATEWAY_CLAIMED', null, ['hostname' => $hostname, 'installed_version' => $installedVersion] ); return [ 'gateway' => $this->getGateway($gatewayId), 'agent_token' => $agentToken, 'heartbeat_url' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/heartbeat', 'commands_poll_url' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/commands/poll', 'operations_poll_url' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/operations/next', 'operation_events_url_template' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/operations/{operationId}/events', 'operation_complete_url_template' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/operations/{operationId}/complete', 'release_channel' => (string)$gateway->release_channel->value(), ]; } /** * @throws Exception */ public function authenticateGateway(int $gatewayId, string $plainToken): edge_gateways_o { $gateway = $this->requireGateway($gatewayId); if (!hash_equals((string)$gateway->agent_token_hash->value(), $this->hashToken($plainToken))) { throw new Exception('Invalid edge gateway token'); } return $gateway; } /** * @throws Exception */ public function recordHeartbeat(int $gatewayId, string $plainToken, array $payload): array { $gateway = $this->authenticateGateway($gatewayId, $plainToken); $existingMetadata = (array)($gateway->metadata_json->value() ?? []); $gateway->status->set((string)($payload['status'] ?? self::STATUS_ONLINE)); $gateway->hostname->set($payload['hostname'] ?? $gateway->hostname->value()); $gateway->installed_version->set($payload['installed_version'] ?? $gateway->installed_version->value()); $gateway->target_version->set($payload['target_version'] ?? $gateway->target_version->value()); $gateway->last_heartbeat_at->set($this->now()); $gateway->last_seen_ip->set($this->remoteIp()); $gateway->metadata_json->set(array_merge($existingMetadata, (array)($payload['metadata'] ?? []))); if (isset($payload['inventory']) && is_array($payload['inventory'])) { $this->syncDeviceInventory($gatewayId, $payload['inventory']); } return $this->getGateway($gatewayId); } /** * @return array> * @throws Exception */ public function listGateways(?int $departmentId = null, bool $includeDetail = true): array { $gatewayObject = new edge_gateways_o(); $rows = $departmentId === null ? $gatewayObject->getFieldsWhere(['deleted_at' => null], ['id']) : $gatewayObject->getFieldsWhere(['department_id' => $departmentId, 'deleted_at' => null], ['id']); $gateways = []; foreach ($rows as $row) { $gateway = $this->requireGateway((int)$row['id']); $gateways[] = $this->buildGatewayPayload($gateway, $includeDetail); } usort($gateways, static fn(array $a, array $b): int => ($a['department_id'] <=> $b['department_id']) ?: ($a['id'] <=> $b['id'])); return $gateways; } /** * @param array> $gateways * @return array * @throws Exception */ public function buildFleetUsageStatistics(?int $departmentId = null, array $gateways = []): array { $fleet = $gateways !== [] ? $gateways : $this->listGateways($departmentId, false); $gatewayIds = array_values(array_filter(array_map( static fn(array $gateway): int => (int)($gateway['id'] ?? 0), $fleet ))); return self::summarizeFleetUsage( $fleet, $this->aggregateInventoryUsage($gatewayIds), $this->aggregateBindingUsage($gatewayIds) ); } /** * @param array> $gateways * @return array */ public static function summarizeFleetUsage(array $gateways, array $inventoryUsage = [], array $bindingUsage = []): array { $inventory = array_merge(self::emptyInventoryUsage(), $inventoryUsage); $bindings = array_merge(self::emptyBindingUsage(), $bindingUsage); $totalGateways = count($gateways); $departmentIds = []; $gatewayOnline = 0; $gatewayOffline = 0; $gatewayDegraded = 0; $gatewayDrifted = 0; $brokerConnected = 0; $activeOperations = 0; $pendingOperations = 0; $inProgressOperations = 0; $operationBacklog = 0; $commandBacklog = 0; $latencyValues = []; $cpuValues = []; $memoryValues = []; $diskValues = []; foreach ($gateways as $gateway) { $departmentId = (int)($gateway['department_id'] ?? 0); if ($departmentId > 0) { $departmentIds[$departmentId] = true; } $status = strtoupper((string)($gateway['status'] ?? self::STATUS_OFFLINE)); if ($status === self::STATUS_ONLINE) { $gatewayOnline += 1; } elseif ($status === self::STATUS_DEGRADED) { $gatewayDegraded += 1; } else { $gatewayOffline += 1; } if (!empty($gateway['version_drift']['is_drifted'])) { $gatewayDrifted += 1; } if (!empty($gateway['channel_status']['broker']['connected'])) { $brokerConnected += 1; } if (!empty($gateway['active_operation'])) { $activeOperations += 1; } $recentSummary = isset($gateway['recent_operations_summary']) && is_array($gateway['recent_operations_summary']) ? (array)$gateway['recent_operations_summary'] : []; $pendingOperations += (int)($recentSummary['pending'] ?? 0); $inProgressOperations += (int)($recentSummary['in_progress'] ?? 0); $backlog = isset($gateway['backlog_depth']) && is_array($gateway['backlog_depth']) ? (array)$gateway['backlog_depth'] : []; $operationBacklog += (int)($backlog['operations'] ?? 0); $commandBacklog += (int)($backlog['commands'] ?? 0); $metrics = isset($gateway['metadata']['system_metrics']) && is_array($gateway['metadata']['system_metrics']) ? (array)$gateway['metadata']['system_metrics'] : []; self::appendNumericMetric($latencyValues, $metrics['latency_ms'] ?? null); self::appendNumericMetric($cpuValues, $metrics['cpu_usage_pct'] ?? null); self::appendNumericMetric($memoryValues, $metrics['memory_usage_pct'] ?? null); self::appendNumericMetric($diskValues, $metrics['disk_usage_pct'] ?? null); } return [ 'gateways' => [ 'total' => $totalGateways, 'departments' => count($departmentIds), 'online' => $gatewayOnline, 'offline' => $gatewayOffline, 'degraded' => $gatewayDegraded, 'drifted' => $gatewayDrifted, 'broker_connected' => $brokerConnected, ], 'inventory' => $inventory, 'bindings' => $bindings, 'operations' => [ 'active' => $activeOperations, 'pending' => $pendingOperations, 'in_progress' => $inProgressOperations, 'backlog' => $operationBacklog, ], 'commands' => [ 'backlog' => $commandBacklog, ], 'system' => [ 'latency_ms_avg' => self::averageMetric($latencyValues), 'cpu_usage_pct_avg' => self::averageMetric($cpuValues), 'memory_usage_pct_avg' => self::averageMetric($memoryValues), 'disk_usage_pct_avg' => self::averageMetric($diskValues), ], ]; } /** * @throws Exception */ public function getGateway(int $gatewayId): array { $gateway = $this->requireGateway($gatewayId); return $this->buildGatewayPayload($gateway, true); } /** * @throws Exception */ private function buildGatewayPayload(edge_gateways_o $gateway, bool $includeDetail): array { $data = $gateway->asArray(); $operations = new edge_gateway_operation_service($this); $data['metadata']['broker_presence'] = $this->readBrokerPresence((int)$gateway->id); $gatewayId = (int)$gateway->id; if ($includeDetail) { $data['inventory'] = $this->listInventory($gatewayId); $data['bindings'] = $this->listBindings($gatewayId); $data['recent_commands'] = $this->listRecentObjects(new edge_gateway_command_jobs_o(), ['gateway_id' => $gatewayId, 'deleted_at' => null]); $data['audit_logs'] = $this->listRecentObjects(new edge_gateway_audit_logs_o(), ['gateway_id' => $gatewayId]); $data['operations'] = $operations->listOperations($gatewayId, 12, true); } else { $data['inventory'] = []; $data['bindings'] = []; $data['recent_commands'] = []; $data['audit_logs'] = []; $data['operations'] = $operations->listOperations($gatewayId, 5, false); } $data['active_operation'] = $operations->getActiveOperation($gatewayId, false); $data['recent_operations_summary'] = $operations->buildRecentOperationsSummary($gatewayId); $data['department_transport_mode'] = $this->getDepartmentTransportMode((int)$gateway->department_id->value()); $data['operational_snapshot'] = $this->buildGatewayOperationalSnapshot((int)$gateway->id); return self::deriveGatewayRuntimeState($data); } /** * @throws Exception */ public function updateGatewayMetadata(int $gatewayId, array $payload, ?int $userId = null): array { $gateway = $this->requireGateway($gatewayId); $previousLabel = (string)$gateway->label->value(); $previousIsPrimary = (bool)$gateway->is_primary->value(); $label = trim((string)($payload['label'] ?? $previousLabel)); if ($label === '') { throw new Exception('Gateway label is required'); } $isPrimary = (bool)($payload['is_primary'] ?? $previousIsPrimary); $gateway->label->set($label); if ($isPrimary) { $this->setGatewayPrimaryState($gateway, true); } elseif ($isPrimary !== $previousIsPrimary) { $this->setGatewayPrimaryState($gateway, false); } $this->writeAudit( $gatewayId, (int)$gateway->department_id->value(), 'GATEWAY_METADATA_UPDATED', $userId, [ 'label' => $label, 'previous_label' => $previousLabel, 'is_primary' => $isPrimary, 'previous_is_primary' => $previousIsPrimary, ] ); return $this->getGateway($gatewayId); } /** * @throws Exception */ public function setDepartmentTransportMode(int $departmentId, string $transportMode, ?int $userId = null): array { if (!in_array($transportMode, [self::TRANSPORT_MODE_CLOUD, self::TRANSPORT_MODE_GATEWAY], true)) { throw new Exception('Invalid transport mode'); } $department = $this->requireDepartment($departmentId); $department->variables->set(self::DEPARTMENT_VARIABLE_TRANSPORT_MODE, $transportMode); $this->writeAudit( null, $departmentId, 'DEPARTMENT_TRANSPORT_MODE_UPDATED', $userId, ['transport_mode' => $transportMode] ); return [ 'department_id' => $departmentId, 'transport_mode' => $transportMode, ]; } public function getDepartmentTransportMode(int $departmentId): string { $variables = (new department_variables_o())->selectDepartment($departmentId); $mode = $variables->getVariable(self::DEPARTMENT_VARIABLE_TRANSPORT_MODE); if ($mode === self::TRANSPORT_MODE_GATEWAY) { return self::TRANSPORT_MODE_GATEWAY; } return self::TRANSPORT_MODE_CLOUD; } /** * @return array> * @throws Exception */ public function setRelayBindings(int $gatewayId, array $bindings, ?int $userId = null): array { $gateway = $this->requireGateway($gatewayId); $departmentId = (int)$gateway->department_id->value(); $incomingRelayIds = []; foreach ($bindings as $binding) { $relayId = trim((string)($binding['relay_id'] ?? '')); $deviceId = trim((string)($binding['device_id'] ?? '')); if ($relayId === '' || $deviceId === '') { throw new Exception('Each relay binding must contain relay_id and device_id'); } $bindingMetadata = $this->normalizeRelayBindingMetadata((array)($binding['metadata'] ?? []), $binding); $incomingRelayIds[] = $relayId; $existing = (new edge_gateway_relay_bindings_o())->getFieldsWhere([ 'gateway_id' => $gatewayId, 'relay_id' => $relayId, 'deleted_at' => null, ], ['id']); if ($existing !== []) { $bindingObject = (new edge_gateway_relay_bindings_o())->select((int)$existing[0]['id']); $bindingObject->device_id->set($deviceId); $bindingObject->local_ip->set($binding['local_ip'] ?? null); $bindingObject->channel->set((int)($binding['channel'] ?? 0)); $bindingObject->binding_source->set((string)($binding['binding_source'] ?? 'MANUAL')); $bindingObject->approved_by->set($userId); $bindingObject->approved_at->set($this->now()); $bindingObject->metadata_json->set($bindingMetadata); continue; } (new edge_gateway_relay_bindings_o())->add_object([ 'gateway_id' => $gatewayId, 'department_id' => $departmentId, 'relay_id' => $relayId, 'device_id' => $deviceId, 'local_ip' => $binding['local_ip'] ?? null, 'channel' => (int)($binding['channel'] ?? 0), 'binding_source' => (string)($binding['binding_source'] ?? 'MANUAL'), 'approved_by' => $userId, 'approved_at' => $this->now(), 'metadata_json' => $bindingMetadata, ]); } foreach ($this->listBindings($gatewayId) as $existingBinding) { if (in_array((string)$existingBinding['relay_id'], $incomingRelayIds, true)) { continue; } $bindingObject = (new edge_gateway_relay_bindings_o())->select((int)$existingBinding['id']); $bindingObject->deleted_at->set($this->now()); } $this->writeAudit( $gatewayId, $departmentId, 'RELAY_BINDINGS_UPDATED', $userId, ['binding_count' => count($bindings)] ); return $this->listBindings($gatewayId); } /** * @throws Exception */ public function queueDiscovery(int $gatewayId, ?int $userId = null): array { $gateway = $this->requireGateway($gatewayId); $gateway->discovery_status->set('PENDING'); $this->createCommandJob($gatewayId, 'DISCOVER_SHELLY', [], $userId, [ 'preferred_channel' => $this->resolveGatewayPreferredCommandChannel($gateway), ]); return $this->getGateway($gatewayId); } /** * @throws Exception */ public function deleteGateway(int $gatewayId, ?int $userId = null): array { $gateway = $this->requireGateway($gatewayId); $departmentId = (int)$gateway->department_id->value(); $label = (string)$gateway->label->value(); if ((bool)$gateway->is_primary->value()) { $replacement = $this->findAlternateGatewayForDepartment($departmentId, $gatewayId); if ($replacement !== null) { $replacement->is_primary->set(true); } } $this->softDeleteGatewayRelations($gatewayId); $gateway->deleted_at->set($this->now()); $this->writeAudit( $gatewayId, $departmentId, 'GATEWAY_DELETED', $userId, ['label' => $label] ); return [ 'deleted' => true, 'gateway_id' => $gatewayId, 'department_id' => $departmentId, ]; } /** * @throws Exception */ public function pollCommand(int $gatewayId, string $plainToken, int $waitSeconds = self::COMMAND_POLL_TIMEOUT_SECONDS): ?array { $gateway = $this->authenticateGateway($gatewayId, $plainToken); $deadline = microtime(true) + max(0, $waitSeconds); do { $job = $this->claimNextCommandJob($gateway); if ($job !== null) { return $this->formatAgentCommandJob($job, $gateway); } if (microtime(true) >= $deadline) { break; } usleep(self::COMMAND_POLL_INTERVAL_MICROSECONDS); } while (true); return null; } /** * @throws Exception */ public function submitCommandResult( int $gatewayId, int $jobId, string $plainToken, bool $ok, array $payload = [], ?string $error = null ): array { $gateway = $this->authenticateGateway($gatewayId, $plainToken); $job = (new edge_gateway_command_jobs_o())->select($jobId); if (!$job->exists()) { throw new Exception('Edge gateway command job not found'); } if ((int)$job->gateway_id->value() !== (int)$gateway->id) { throw new Exception('Edge gateway command job does not belong to this gateway'); } $status = (string)$job->status->value(); if (in_array($status, ['COMPLETED', 'FAILED'], true)) { return [ 'acknowledged' => true, 'job' => $job->asArray(), ]; } $errorMessage = $ok ? null : trim((string)$error); if (!$ok && $errorMessage === '') { $errorMessage = 'Edge gateway command failed'; } $this->finalizeCommandJob($job, $ok, $payload, $errorMessage, $gateway); return [ 'acknowledged' => true, 'job' => $job->asArray(), ]; } /** * @throws Exception */ public function resolveRelayBinding(int $departmentId, string $logicalRelayId): array { $gateway = $this->getPrimaryGatewayForDepartment($departmentId, false); $rows = (new edge_gateway_relay_bindings_o())->getFieldsWhere([ 'department_id' => $departmentId, 'gateway_id' => (int)$gateway->id, 'relay_id' => $logicalRelayId, 'deleted_at' => null, ], ['id']); if ($rows === []) { throw new Exception('No edge gateway relay binding found for relay ' . $logicalRelayId); } return (new edge_gateway_relay_bindings_o())->select((int)$rows[0]['id'])->asArray(); } /** * @throws Exception */ public function dispatchRelayStatus(int $departmentId, string $logicalRelayId): array { $binding = $this->resolveRelayBinding($departmentId, $logicalRelayId); $gateway = $this->requireGateway((int)$binding['gateway_id']); $resolution = $this->resolveRelayExecutionPlan($gateway, $binding, $logicalRelayId); if (($resolution['execution_path'] ?? 'local') === 'cloud') { return $this->dispatchRelayThroughCloud($departmentId, $logicalRelayId, null, $binding, $resolution); } $job = $this->createCommandJob((int)$gateway->id, 'GET_RELAY_STATUS', [ 'relayId' => $logicalRelayId, 'deviceId' => $binding['device_id'], 'localIp' => $binding['local_ip'], 'channel' => (int)$binding['channel'], ], null, [ 'preferred_channel' => (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API), 'fallback_reason' => $resolution['reason'] ?? null, ]); try { $result = $this->dispatchGatewayCommand($gateway, $job); return $this->finalizeRelayDispatch($binding, $resolution, $result); } catch (Exception $exception) { return $this->handleRelayDispatchFailure( $departmentId, $logicalRelayId, $binding, $resolution, null, $exception ); } } /** * @throws Exception */ public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on): array { $binding = $this->resolveRelayBinding($departmentId, $logicalRelayId); $gateway = $this->requireGateway((int)$binding['gateway_id']); $resolution = $this->resolveRelayExecutionPlan($gateway, $binding, $logicalRelayId); if (($resolution['execution_path'] ?? 'local') === 'cloud') { return $this->dispatchRelayThroughCloud($departmentId, $logicalRelayId, $on, $binding, $resolution); } $job = $this->createCommandJob((int)$gateway->id, 'SET_RELAY_STATE', [ 'relayId' => $logicalRelayId, 'deviceId' => $binding['device_id'], 'localIp' => $binding['local_ip'], 'channel' => (int)$binding['channel'], 'on' => $on, ], null, [ 'preferred_channel' => (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API), 'fallback_reason' => $resolution['reason'] ?? null, ]); try { $result = $this->dispatchGatewayCommand($gateway, $job); return $this->finalizeRelayDispatch($binding, $resolution, $result); } catch (Exception $exception) { return $this->handleRelayDispatchFailure( $departmentId, $logicalRelayId, $binding, $resolution, $on, $exception ); } } /** * @return array> */ public function listBindings(int $gatewayId): array { return $this->listRecentObjects(new edge_gateway_relay_bindings_o(), [ 'gateway_id' => $gatewayId, 'deleted_at' => null, ], 100); } /** * @return array> */ public function listInventory(int $gatewayId): array { return $this->listRecentObjects(new edge_gateway_device_inventory_o(), [ 'gateway_id' => $gatewayId, 'deleted_at' => null, ], 100); } public function buildInstallCommand(string $plainToken): string { return 'curl -fsSL "' . $this->buildInstallScriptUrl($plainToken) . '" | sudo bash'; } public function buildInstallTokenVerifyUrl(string $plainToken): string { return rtrim($this->getApiBaseUrl(), '/') . '/edge-agent/install-token/verify?token=' . urlencode($plainToken); } public function buildInstallScriptUrl(string $plainToken): string { return rtrim($this->getApiBaseUrl(), '/') . '/edge-agent/install.sh?token=' . urlencode($plainToken); } public function buildInstallScript(string $plainToken): string { $configJson = json_encode([ 'apiUrl' => $this->getApiBaseUrl(), 'installToken' => $plainToken, 'gatewayId' => null, 'agentToken' => null, 'installDir' => '/opt/truckwash-edge-agent', 'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME, 'heartbeatIntervalSeconds' => 15, 'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS, ], JSON_UNESCAPED_SLASHES); $script = <<<'BASH' #!/usr/bin/env bash set -Eeuo pipefail CURRENT_STEP="Preparing installer" CURRENT_METHOD="" CURRENT_URL="" INSTALL_STARTED_AT="$(date +%s)" log_info() { printf '[truckwash-edge-agent] %s\n' "$1" } log_error() { printf '[truckwash-edge-agent] ERROR: %s\n' "$1" >&2 } on_error() { local exit_code=$? log_error "Installer failed during step: ${CURRENT_STEP:-unknown}" if [ -n "${CURRENT_METHOD:-}" ] && [ -n "${CURRENT_URL:-}" ]; then log_error "Last request: ${CURRENT_METHOD} ${CURRENT_URL}" fi exit "$exit_code" } trap on_error ERR run_step() { local description="$1" shift CURRENT_STEP="$description" CURRENT_METHOD="" CURRENT_URL="" log_info "$description" "$@" } fetch_http() { local description="$1" local url="$2" local output_path="${3:-}" local body_path="$output_path" local headers_path local status="" local curl_exit=0 local preview="" local cleanup_body=0 if [ -z "$body_path" ]; then body_path="$(mktemp)" cleanup_body=1 fi headers_path="$(mktemp)" CURRENT_STEP="$description" CURRENT_METHOD="GET" CURRENT_URL="$url" log_info "${description}: GET ${url}" set +e status="$(curl -sS -L -D "$headers_path" -o "$body_path" -w '%{http_code}' "$url")" curl_exit=$? set -e if [ "$curl_exit" -ne 0 ]; then log_error "${description} request failed before a successful HTTP response was received." log_error "Request: GET ${url}" log_error "curl exit code: ${curl_exit}" if [ -s "$headers_path" ]; then log_error "Response headers:" sed 's/^/[truckwash-edge-agent] /' "$headers_path" >&2 fi if [ -s "$body_path" ]; then preview="$(head -c 400 "$body_path" || true)" if [ -n "$preview" ]; then log_error "Response body preview (first 400 bytes):" printf '%s\n' "$preview" | sed 's/^/[truckwash-edge-agent] /' >&2 fi fi [ "$cleanup_body" -eq 1 ] && rm -f "$body_path" rm -f "$headers_path" return "$curl_exit" fi if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then log_error "${description} returned HTTP ${status}." log_error "Request: GET ${url}" if [ -s "$headers_path" ]; then log_error "Response headers:" sed 's/^/[truckwash-edge-agent] /' "$headers_path" >&2 fi if [ -s "$body_path" ]; then preview="$(head -c 400 "$body_path" || true)" if [ -n "$preview" ]; then log_error "Response body preview (first 400 bytes):" printf '%s\n' "$preview" | sed 's/^/[truckwash-edge-agent] /' >&2 fi fi [ "$cleanup_body" -eq 1 ] && rm -f "$body_path" rm -f "$headers_path" return 1 fi [ "$cleanup_body" -eq 1 ] && rm -f "$body_path" rm -f "$headers_path" } config_has_claimed_gateway() { local config_path="$1" php -r ' $path = $argv[1]; if (!is_file($path)) { exit(1); } $decoded = json_decode((string)file_get_contents($path), true); if (!is_array($decoded)) { exit(1); } $gatewayId = isset($decoded["gatewayId"]) ? (int)$decoded["gatewayId"] : 0; $agentToken = isset($decoded["agentToken"]) ? trim((string)$decoded["agentToken"]) : ""; exit($gatewayId > 0 && $agentToken !== "" ? 0 : 1); ' "$config_path" } read_config_value() { local config_path="$1" local key="$2" php -r ' $path = $argv[1]; $key = $argv[2]; if (!is_file($path)) { exit(0); } $decoded = json_decode((string)file_get_contents($path), true); if (!is_array($decoded) || !array_key_exists($key, $decoded) || $decoded[$key] === null) { exit(0); } $value = $decoded[$key]; if (is_array($value) || is_object($value)) { echo json_encode($value, JSON_UNESCAPED_SLASHES); exit(0); } echo (string)$value; ' "$config_path" "$key" } merge_agent_config() { local template_path="$1" local config_path="$2" php -r ' $templatePath = $argv[1]; $configPath = $argv[2]; $template = json_decode((string)file_get_contents($templatePath), true); if (!is_array($template)) { fwrite(STDERR, "Invalid edge agent config template.\n"); exit(1); } $existing = []; if (is_file($configPath)) { $decoded = json_decode((string)file_get_contents($configPath), true); if (is_array($decoded)) { $existing = $decoded; } } foreach (["gatewayId", "agentToken", "agentInstanceId", "installedVersion", "targetVersion", "lastStagedUpdate"] as $key) { if (array_key_exists($key, $existing) && $existing[$key] !== null && $existing[$key] !== "") { $template[$key] = $existing[$key]; } } file_put_contents($configPath, json_encode($template, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); ' "$template_path" "$config_path" } heartbeat_marker_is_fresh() { local heartbeat_path="$1" local minimum_epoch="$2" if [ ! -f "$heartbeat_path" ]; then return 1 fi local modified_epoch modified_epoch="$(stat -c %Y "$heartbeat_path" 2>/dev/null || echo 0)" [ "${modified_epoch:-0}" -ge "$minimum_epoch" ] } print_service_diagnostics() { log_error "truckwash-edge-agent.service did not complete installation verification." log_error "systemctl status --no-pager truckwash-edge-agent.service" systemctl status --no-pager truckwash-edge-agent.service || true log_error "journalctl -u truckwash-edge-agent.service -n 40 --no-pager" journalctl -u truckwash-edge-agent.service -n 40 --no-pager || true } wait_for_gateway_claim() { local config_path="$1" local heartbeat_path="$2" local minimum_epoch="$3" local timeout_seconds="${4:-30}" local elapsed=0 while [ "$elapsed" -lt "$timeout_seconds" ]; do if config_has_claimed_gateway "$config_path" && heartbeat_marker_is_fresh "$heartbeat_path" "$minimum_epoch"; then return 0 fi sleep 1 elapsed=$((elapsed + 1)) done log_error "Gateway claim did not complete within ${timeout_seconds}s." print_service_diagnostics return 1 } wait_for_post_restart_heartbeat() { local heartbeat_path="$1" local minimum_epoch="$2" local timeout_seconds="${3:-30}" local elapsed=0 while [ "$elapsed" -lt "$timeout_seconds" ]; do if heartbeat_marker_is_fresh "$heartbeat_path" "$minimum_epoch"; then return 0 fi sleep 1 elapsed=$((elapsed + 1)) done log_error "Gateway heartbeat was not observed within ${timeout_seconds}s after reinstall." print_service_diagnostics return 1 } fetch_http "Verify install token" "__VERIFY_URL__" INSTALL_DIR=/opt/truckwash-edge-agent CONFIG_PATH="$INSTALL_DIR/config.json" CONFIG_TEMPLATE_PATH="$INSTALL_DIR/config.template.json" HEARTBEAT_MARKER_PATH="$INSTALL_DIR/runtime/last-heartbeat-ok.txt" REUSE_EXISTING_CREDENTIALS=0 run_step "Creating install directory" mkdir -p "$INSTALL_DIR" export DEBIAN_FRONTEND=noninteractive run_step "Updating package lists" apt-get update run_step "Installing required packages" apt-get install -y curl ca-certificates php-cli php-curl php-mbstring fetch_http "Download PHP edge agent" "__AGENT_URL__" "$INSTALL_DIR/agent.php" fetch_http "Download systemd service unit" "__SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-agent.service" if config_has_claimed_gateway "$CONFIG_PATH"; then REUSE_EXISTING_CREDENTIALS=1 log_info "Existing claimed gateway detected; reinstall will reuse saved gateway credentials." fi cat > "$CONFIG_TEMPLATE_PATH" <<'EOF_JSON' __CONFIG_JSON__ EOF_JSON run_step "Writing agent config" merge_agent_config "$CONFIG_TEMPLATE_PATH" "$CONFIG_PATH" rm -f "$CONFIG_TEMPLATE_PATH" run_step "Installing systemd service definition" install -m 0644 "$INSTALL_DIR/truckwash-edge-agent.service" "/etc/systemd/system/truckwash-edge-agent.service" run_step "Setting PHP agent permissions" chmod 0755 "$INSTALL_DIR/agent.php" run_step "Reloading systemd" systemctl daemon-reload run_step "Enabling truckwash-edge-agent.service" systemctl enable truckwash-edge-agent.service run_step "Restarting truckwash-edge-agent.service" systemctl restart truckwash-edge-agent.service run_step "Verifying truckwash-edge-agent.service is active" systemctl is-active --quiet truckwash-edge-agent.service if [ "$REUSE_EXISTING_CREDENTIALS" -eq 1 ]; then run_step "Waiting for post-reinstall heartbeat" wait_for_post_restart_heartbeat "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 30 log_info "Reinstall reused gateway $(read_config_value "$CONFIG_PATH" gatewayId)." else run_step "Waiting for gateway claim" wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 30 log_info "Gateway claim completed for gateway $(read_config_value "$CONFIG_PATH" gatewayId)." fi echo 'TruckWash edge agent installed.' BASH; return strtr($script, [ '__VERIFY_URL__' => $this->buildInstallTokenVerifyUrl($plainToken), '__AGENT_URL__' => $this->buildAgentArtifactUrl('agent.php'), '__SERVICE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_AGENT_SERVICE_NAME), '__CONFIG_JSON__' => (string)$configJson, ]); } /** * @throws Exception */ public function buildUpdateOperationRequest(string $targetVersion, string $releaseChannel): array { return array_merge( $this->buildUpdateCommandPayload($targetVersion, $releaseChannel), [ 'target_version' => $targetVersion, 'release_channel' => $releaseChannel, ] ); } /** * @throws Exception */ public function rotateGatewayCredentials(int $gatewayId, ?int $userId = null): array { $gateway = $this->requireGateway($gatewayId); $newToken = bin2hex(random_bytes(32)); $gateway->agent_token_hash->set($this->hashToken($newToken)); $metadata = (array)($gateway->metadata_json->value() ?? []); $metadata['credentials_rotated_at'] = $this->now(); $metadata['credential_rotation_requested_by'] = $userId; $gateway->metadata_json->set($metadata); $payload = [ 'apiUrl' => $this->getApiBaseUrl(), 'gatewayId' => (int)$gateway->id, 'agentToken' => $newToken, 'installDir' => '/opt/truckwash-edge-agent', 'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME, 'heartbeatIntervalSeconds' => 15, 'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS, 'installedVersion' => $gateway->installed_version->value() === null ? null : (string)$gateway->installed_version->value(), ]; $this->writeAudit( (int)$gateway->id, (int)$gateway->department_id->value(), 'GATEWAY_CREDENTIALS_ROTATED', $userId, ['service_name' => self::DEFAULT_AGENT_SERVICE_NAME] ); return [ 'gateway_id' => (int)$gateway->id, 'rotated_at' => (string)$metadata['credentials_rotated_at'], 'agent_token' => $newToken, 'config' => $payload, 'config_json' => json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), 'restart_instructions' => [ 'sudo systemctl restart ' . self::DEFAULT_AGENT_SERVICE_NAME, 'sudo systemctl status ' . self::DEFAULT_AGENT_SERVICE_NAME . ' --no-pager', ], ]; } public function syncGatewayInventory(int $gatewayId, array $inventory): void { $this->syncDeviceInventory($gatewayId, $inventory); } public function logGatewayAudit(?int $gatewayId, ?int $departmentId, string $action, ?int $userId, array $context): void { $this->writeAudit($gatewayId, $departmentId, $action, $userId, $context); } private function buildUpdateCommandPayload(string $targetVersion, string $releaseChannel): array { return [ 'targetVersion' => $targetVersion, 'releaseChannel' => $releaseChannel, 'artifactUrl' => $this->buildAgentArtifactUrl('agent.php'), 'artifactSha256' => $this->buildAgentArtifactSha256('agent.php'), 'serviceUnitUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_AGENT_SERVICE_NAME), 'serviceUnitSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_AGENT_SERVICE_NAME), 'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME, ]; } private function buildAgentArtifactUrl(string $fileName): string { return rtrim($this->getApiBaseUrl(), '/') . '/edge-agent/artifacts/' . $fileName; } private function buildAgentArtifactPath(string $fileName): string { return edge_gateway_agent_artifact_locator::resolve($fileName); } /** * @throws Exception */ private function buildAgentArtifactSha256(string $fileName): string { $artifactPath = $this->buildAgentArtifactPath($fileName); if (!is_file($artifactPath)) { throw new Exception('Missing edge agent artifact: ' . $fileName); } $sha256 = hash_file('sha256', $artifactPath); if ($sha256 === false) { throw new Exception('Unable to checksum edge agent artifact: ' . $fileName); } return $sha256; } /** * @throws Exception */ public function verifyInstallToken(string $plainToken): array { $claimToken = $this->requireClaimToken($plainToken); if ($claimToken->used_at->value() !== null) { throw new Exception('Install token has already been used'); } return [ 'valid' => true, 'claim_token_id' => (int)$claimToken->id, 'department_id' => (int)$claimToken->department_id->value(), 'label' => $claimToken->label->value(), 'expires_at' => (string)$claimToken->expires_at->value(), ]; } public function getApiBaseUrl(): string { $configured = trim((string)(getenv('EDGE_PUBLIC_API_URL') ?: '')); if ($configured !== '') { return $configured; } $forwardedScheme = $this->detectForwardedScheme(); if ($forwardedScheme !== null) { $scheme = strtolower($forwardedScheme) === 'https' ? 'https' : 'http'; } else { $requestScheme = strtolower(trim((string)($_SERVER['REQUEST_SCHEME'] ?? ''))); $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || $requestScheme === 'https' ? 'https' : 'http'; } $host = trim((string)($this->firstForwardedHeaderValue($_SERVER['HTTP_X_FORWARDED_HOST'] ?? null) ?? ($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? 'localhost'))); if ($host === '') { $host = 'localhost'; } $forwardedPort = $this->firstForwardedHeaderValue($_SERVER['HTTP_X_FORWARDED_PORT'] ?? null); $port = $forwardedPort !== null ? (int)$forwardedPort : 0; if ($port <= 0) { $hostPort = parse_url($scheme . '://' . $host, PHP_URL_PORT); $port = is_int($hostPort) ? $hostPort : (int)($_SERVER['SERVER_PORT'] ?? 0); } if ($scheme === 'http' && in_array($port, [443, 4433], true)) { $scheme = 'https'; } if ($port > 0 && !str_contains($host, ':') && !(($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80))) { $host .= ':' . $port; } return $scheme . '://' . $host; } private function detectForwardedScheme(): ?string { $forwardedScheme = $this->firstForwardedHeaderValue($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? null); if ($forwardedScheme !== null) { return $forwardedScheme; } $forwardedScheme = $this->firstForwardedHeaderValue($_SERVER['HTTP_X_FORWARDED_PROTOCOL'] ?? null); if ($forwardedScheme !== null) { return $forwardedScheme; } $forwardedHeader = trim((string)($_SERVER['HTTP_FORWARDED'] ?? '')); if ($forwardedHeader !== '' && preg_match('/proto=([^;,\s]+)/i', $forwardedHeader, $matches) === 1) { return trim($matches[1], "\"'"); } return null; } private function firstForwardedHeaderValue(mixed $value): ?string { if (!is_string($value)) { return null; } foreach (explode(',', $value) as $segment) { $normalized = trim($segment); if ($normalized !== '') { return $normalized; } } return null; } /** * @throws Exception */ private function requireDispatchableGateway(int $gatewayId): edge_gateways_o { $gateway = $this->requireGateway($gatewayId); $effectiveStatus = self::resolveGatewayStatus( $gateway->status->value() === null ? null : (string)$gateway->status->value(), $gateway->last_heartbeat_at->value() === null ? null : (string)$gateway->last_heartbeat_at->value() ); if (!in_array($effectiveStatus, [self::STATUS_ONLINE, self::STATUS_DEGRADED], true)) { throw new Exception('Gateway agent is offline'); } return $gateway; } /** * @throws Exception */ private function requireGateway(int $gatewayId): edge_gateways_o { $gateway = (new edge_gateways_o())->select($gatewayId); if (!$gateway->exists() || $gateway->deleted_at->value() !== null) { throw new Exception('Edge gateway not found'); } return $gateway; } private function softDeleteGatewayRelations(int $gatewayId): void { $tables = [ 'edge_gateway_device_inventory', 'edge_gateway_relay_bindings', 'edge_gateway_command_jobs', 'edge_gateway_operations', ]; $pdo = db::getPDO(); $deletedAt = $this->now(); foreach ($tables as $table) { $statement = $pdo->prepare( "UPDATE {$table} SET deleted_at = :deleted_at WHERE gateway_id = :gateway_id AND deleted_at IS NULL" ); $statement->execute([ ':deleted_at' => $deletedAt, ':gateway_id' => $gatewayId, ]); } } /** * @throws Exception */ private function requireDepartment(int $departmentId): departments_o { $department = (new departments_o())->select($departmentId); if (!$department->exists()) { throw new Exception('Department not found'); } return $department; } /** * @throws Exception */ private function requireClaimToken(string $plainToken): edge_gateway_claim_tokens_o { $rows = (new edge_gateway_claim_tokens_o())->getFieldsWhere([ 'token_hash' => $this->hashToken($plainToken), 'deleted_at' => null, ], ['id']); if ($rows === []) { throw new Exception('Invalid install token'); } $claimToken = (new edge_gateway_claim_tokens_o())->select((int)$rows[0]['id']); if (!$claimToken->exists()) { throw new Exception('Invalid install token'); } if (strtotime((string)$claimToken->expires_at->value()) < time()) { throw new Exception('Install token has expired'); } return $claimToken; } /** * @throws Exception */ private function getPrimaryGatewayForDepartment(int $departmentId, bool $requireDispatchable = true): edge_gateways_o { $rows = (new edge_gateways_o())->getFieldsWhere([ 'department_id' => $departmentId, 'deleted_at' => null, ], ['id']); if ($rows === []) { throw new Exception('No edge gateway found for department'); } $gatewayIds = array_map(static fn(array $row): int => (int)$row['id'], $rows); $gateways = array_map(static fn(int $id): edge_gateways_o => (new edge_gateways_o())->select($id), $gatewayIds); usort($gateways, static function (edge_gateways_o $a, edge_gateways_o $b): int { $aStatus = self::resolveGatewayStatus( $a->status->value() === null ? null : (string)$a->status->value(), $a->last_heartbeat_at->value() === null ? null : (string)$a->last_heartbeat_at->value() ); $bStatus = self::resolveGatewayStatus( $b->status->value() === null ? null : (string)$b->status->value(), $b->last_heartbeat_at->value() === null ? null : (string)$b->last_heartbeat_at->value() ); return ((int)$b->is_primary->value() <=> (int)$a->is_primary->value()) ?: (self::statusPriority($bStatus) <=> self::statusPriority($aStatus)) ?: (self::heartbeatTimestamp($b->last_heartbeat_at->value() === null ? null : (string)$b->last_heartbeat_at->value()) <=> self::heartbeatTimestamp($a->last_heartbeat_at->value() === null ? null : (string)$a->last_heartbeat_at->value())); }); $gateway = $gateways[0]; $effectiveStatus = self::resolveGatewayStatus( $gateway->status->value() === null ? null : (string)$gateway->status->value(), $gateway->last_heartbeat_at->value() === null ? null : (string)$gateway->last_heartbeat_at->value() ); if ($requireDispatchable && !in_array($effectiveStatus, [self::STATUS_ONLINE, self::STATUS_DEGRADED], true)) { throw new Exception('Department edge gateway is offline'); } return $gateway; } /** * @throws Exception */ private function setGatewayPrimaryState(edge_gateways_o $gateway, bool $isPrimary): void { if ($isPrimary) { $statement = db::getPDO()->prepare( "UPDATE edge_gateways SET is_primary = CASE WHEN id = :gateway_id THEN 1 ELSE 0 END WHERE department_id = :department_id AND deleted_at IS NULL" ); $statement->execute([ ':gateway_id' => (int)$gateway->id, ':department_id' => (int)$gateway->department_id->value(), ]); $gateway->is_primary->set(true); return; } $replacement = $this->findAlternateGatewayForDepartment( (int)$gateway->department_id->value(), (int)$gateway->id ); if ($replacement === null) { throw new Exception('Department must retain a primary gateway'); } $replacement->is_primary->set(true); $gateway->is_primary->set(false); } private function findAlternateGatewayForDepartment(int $departmentId, int $excludedGatewayId): ?edge_gateways_o { $rows = (new edge_gateways_o())->getFieldsWhere([ 'department_id' => $departmentId, 'deleted_at' => null, ], ['id']); $gatewayIds = array_values(array_filter( array_map(static fn(array $row): int => (int)$row['id'], $rows), static fn(int $gatewayId): bool => $gatewayId !== $excludedGatewayId )); if ($gatewayIds === []) { return null; } $gateways = array_map(static fn(int $id): edge_gateways_o => (new edge_gateways_o())->select($id), $gatewayIds); usort($gateways, static function (edge_gateways_o $a, edge_gateways_o $b): int { $aStatus = self::resolveGatewayStatus( $a->status->value() === null ? null : (string)$a->status->value(), $a->last_heartbeat_at->value() === null ? null : (string)$a->last_heartbeat_at->value() ); $bStatus = self::resolveGatewayStatus( $b->status->value() === null ? null : (string)$b->status->value(), $b->last_heartbeat_at->value() === null ? null : (string)$b->last_heartbeat_at->value() ); return ((int)$b->is_primary->value() <=> (int)$a->is_primary->value()) ?: (self::statusPriority($bStatus) <=> self::statusPriority($aStatus)) ?: (self::heartbeatTimestamp($b->last_heartbeat_at->value() === null ? null : (string)$b->last_heartbeat_at->value()) <=> self::heartbeatTimestamp($a->last_heartbeat_at->value() === null ? null : (string)$a->last_heartbeat_at->value())); }); return $gateways[0] ?? null; } private function createCommandJob( int $gatewayId, string $commandType, array $request, ?int $userId, array $delivery = [] ): edge_gateway_command_jobs_o { $deliveryMetadata = $this->buildDeliveryMetadata($delivery, self::COMMAND_EXPIRES_AFTER_SECONDS); $jobObject = new edge_gateway_command_jobs_o(); $jobId = $jobObject->add_object([ 'gateway_id' => $gatewayId, 'command_type' => $commandType, 'status' => 'PENDING', 'request_json' => $request, 'response_json' => [], 'delivery_json' => $deliveryMetadata, 'correlation_id' => bin2hex(random_bytes(16)), 'requested_by' => $userId, 'requested_at' => $this->now(), ]); return $jobObject->select($jobId); } /** * @throws Exception */ private function waitForCommandResult(int $jobId, int $timeoutSeconds = self::COMMAND_WAIT_TIMEOUT_SECONDS): array { $deadline = microtime(true) + max(0, $timeoutSeconds); do { $job = (new edge_gateway_command_jobs_o())->select($jobId); if (!$job->exists()) { throw new Exception('Edge gateway command job not found'); } $status = (string)$job->status->value(); if ($status === 'COMPLETED') { $response = (array)($job->response_json->value() ?? []); return (array)($response['payload'] ?? []); } if ($status === 'FAILED') { $errorMessage = trim((string)($job->error_message->value() ?? '')); throw new Exception($errorMessage !== '' ? $errorMessage : 'Edge gateway command failed'); } if (microtime(true) >= $deadline) { break; } usleep(self::COMMAND_POLL_INTERVAL_MICROSECONDS); } while (true); $job = (new edge_gateway_command_jobs_o())->select($jobId); if ($job->exists()) { $job->delivery_json->set($this->buildDeliveryMetadata( array_merge( (array)($job->delivery_json->value() ?? []), ['last_dispatch_error' => 'Edge gateway command timed out'] ), self::COMMAND_EXPIRES_AFTER_SECONDS )); } throw new Exception('Edge gateway command timed out'); } private function claimNextCommandJob(edge_gateways_o $gateway): ?edge_gateway_command_jobs_o { $pdo = db::getPDO(); $pdo->beginTransaction(); try { $statement = $pdo->prepare( 'SELECT id FROM edge_gateway_command_jobs WHERE gateway_id = :gateway_id AND deleted_at IS NULL AND ( status = :pending_status_match OR ( status = :dispatching_status_match AND COALESCE(updated_at, created_at, requested_at) <= :stale_before ) ) ORDER BY CASE WHEN status = :pending_status_order THEN 0 ELSE 1 END, requested_at ASC, id ASC LIMIT 1 FOR UPDATE' ); $statement->execute([ ':gateway_id' => (int)$gateway->id, ':pending_status_match' => 'PENDING', ':dispatching_status_match' => 'DISPATCHING', ':pending_status_order' => 'PENDING', ':stale_before' => $this->formatDateTime(time() - self::COMMAND_DISPATCH_STALE_AFTER_SECONDS), ]); $row = $statement->fetch(); if (!is_array($row) || !isset($row['id'])) { $pdo->commit(); return null; } $update = $pdo->prepare( 'UPDATE edge_gateway_command_jobs SET status = :status, response_json = :response_json, delivery_json = JSON_SET( COALESCE(delivery_json, JSON_OBJECT()), \'$.delivery_channel\', :delivery_channel, \'$.attempt_count\', COALESCE(JSON_EXTRACT(COALESCE(delivery_json, JSON_OBJECT()), \'$.attempt_count\'), 0) + 1, \'$.last_dispatch_error\', CAST(NULL AS JSON) ), error_message = NULL, completed_at = NULL WHERE id = :id' ); $update->execute([ ':status' => 'DISPATCHING', ':response_json' => json_encode([], JSON_UNESCAPED_UNICODE), ':delivery_channel' => json_encode(self::DELIVERY_CHANNEL_API, JSON_UNESCAPED_UNICODE), ':id' => (int)$row['id'], ]); $pdo->commit(); } catch (\Throwable $throwable) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $throwable; } return (new edge_gateway_command_jobs_o())->select((int)$row['id']); } private function formatAgentCommandJob(edge_gateway_command_jobs_o $job, edge_gateways_o $gateway): array { return [ 'id' => (int)$job->id, 'gateway_id' => (int)$gateway->id, 'department_id' => (int)$gateway->department_id->value(), 'command_type' => (string)$job->command_type->value(), 'commandType' => (string)$job->command_type->value(), 'payload' => $this->buildCommandExecutionPayload($job, $gateway), 'requested_at' => (string)$job->requested_at->value(), ]; } private function buildCommandExecutionPayload(edge_gateway_command_jobs_o $job, edge_gateways_o $gateway): array { return array_merge([ 'jobId' => (int)$job->id, 'gatewayId' => (int)$gateway->id, 'departmentId' => (int)$gateway->department_id->value(), 'correlationId' => (string)$job->correlation_id->value(), ], (array)($job->request_json->value() ?? [])); } private function finalizeCommandJob( edge_gateway_command_jobs_o $job, bool $ok, array $payload = [], ?string $errorMessage = null, ?edge_gateways_o $gateway = null ): void { $gatewayObject = $gateway ?? $this->requireGateway((int)$job->gateway_id->value()); $response = [ 'ok' => $ok, 'payload' => $payload, ]; if (!$ok && $errorMessage !== null && trim($errorMessage) !== '') { $response['error'] = $errorMessage; } $job->response_json->set($response); $job->delivery_json->set($this->buildDeliveryMetadata( array_merge( (array)($job->delivery_json->value() ?? []), [ 'delivery_channel' => ((array)($job->delivery_json->value() ?? []))['delivery_channel'] ?? self::DELIVERY_CHANNEL_API, 'last_dispatch_error' => $ok ? null : $errorMessage, ] ), self::COMMAND_EXPIRES_AFTER_SECONDS )); $job->completed_at->set($this->now()); $job->error_message->set($ok ? null : $errorMessage); $job->status->set($ok ? 'COMPLETED' : 'FAILED'); $this->applyCommandResult($gatewayObject, $job, $ok, $payload, $errorMessage); } private function applyCommandResult( edge_gateways_o $gateway, edge_gateway_command_jobs_o $job, bool $ok, array $payload, ?string $errorMessage ): void { unset($errorMessage); if ((string)$job->command_type->value() === 'DISCOVER_SHELLY') { if ($ok) { $inventory = isset($payload['inventory']) && is_array($payload['inventory']) ? $payload['inventory'] : []; $this->syncDeviceInventory((int)$gateway->id, $inventory); $gateway->discovery_status->set('READY'); } else { $gateway->discovery_status->set('FAILED'); } } } /** * @param array> $inventory */ private function syncDeviceInventory(int $gatewayId, array $inventory): void { foreach ($inventory as $device) { $deviceId = trim((string)($device['device_id'] ?? $device['id'] ?? '')); if ($deviceId === '') { continue; } $rows = (new edge_gateway_device_inventory_o())->getFieldsWhere([ 'gateway_id' => $gatewayId, 'device_id' => $deviceId, 'deleted_at' => null, ], ['id']); if ($rows === []) { (new edge_gateway_device_inventory_o())->add_object([ 'gateway_id' => $gatewayId, 'device_id' => $deviceId, 'local_ip' => $device['local_ip'] ?? $device['ip'] ?? null, 'model' => $device['model'] ?? null, 'channel_count' => (int)($device['channel_count'] ?? $device['channels'] ?? 1), 'capabilities_json' => (array)($device['capabilities'] ?? []), 'online' => (bool)($device['online'] ?? true), 'last_seen_at' => $this->now(), 'metadata_json' => (array)($device['metadata'] ?? []), ]); continue; } $inventoryObject = (new edge_gateway_device_inventory_o())->select((int)$rows[0]['id']); $inventoryObject->local_ip->set($device['local_ip'] ?? $device['ip'] ?? null); $inventoryObject->model->set($device['model'] ?? null); $inventoryObject->channel_count->set((int)($device['channel_count'] ?? $device['channels'] ?? 1)); $inventoryObject->capabilities_json->set((array)($device['capabilities'] ?? [])); $inventoryObject->online->set((bool)($device['online'] ?? true)); $inventoryObject->last_seen_at->set($this->now()); $inventoryObject->metadata_json->set((array)($device['metadata'] ?? [])); } } /** * @return array> */ private function listRecentObjects(object $object, array $conditions, int $limit = 20): array { if (!method_exists($object, 'getFieldsWhere') || !method_exists($object, 'select')) { return []; } $rows = $object->getFieldsWhere($conditions, ['id']); $ids = array_map(static fn(array $row): int => (int)$row['id'], $rows); rsort($ids); $ids = array_slice($ids, 0, $limit); $result = []; foreach ($ids as $id) { $tmp = $object::class; $selected = (new $tmp())->select($id); if (method_exists($selected, 'asArray')) { $result[] = $selected->asArray(); } } return $result; } /** * @throws Exception */ public function validateBrokerAgentConnection(int $gatewayId, string $plainToken): array { $gateway = $this->authenticateGateway($gatewayId, $plainToken); return [ 'id' => (int)$gateway->id, 'gateway_id' => (int)$gateway->id, 'department_id' => (int)$gateway->department_id->value(), 'label' => (string)$gateway->label->value(), 'hostname' => $gateway->hostname->value() === null ? null : (string)$gateway->hostname->value(), ]; } /** * @throws Exception */ public function recordBrokerPresence( int $gatewayId, string $status, ?string $connectionId = null, ?string $reason = null, array $metadata = [] ): array { $gateway = $this->requireGateway($gatewayId); $normalizedStatus = trim(strtolower($status)); $connected = $normalizedStatus === 'connected'; $presence = array_filter([ 'gateway_id' => $gatewayId, 'connected' => $connected, 'connection_id' => $connectionId, 'last_seen_at' => $this->now(), 'disconnect_reason' => $connected ? null : $reason, 'last_error' => !$connected && $reason !== null && trim($reason) !== '' ? trim($reason) : null, 'metadata' => $metadata, ], static fn(mixed $value): bool => $value !== null); $this->writeBrokerPresence($gatewayId, $presence); $gatewayMetadata = (array)($gateway->metadata_json->value() ?? []); $gatewayMetadata['broker_presence'] = array_merge( (array)($gatewayMetadata['broker_presence'] ?? []), $presence ); $gatewayMetadata['broker_connected'] = $connected; if ($connected) { $gatewayMetadata['broker_connected_at'] = $this->now(); $gatewayMetadata['broker_last_error'] = null; } else { $gatewayMetadata['broker_disconnected_at'] = $this->now(); $gatewayMetadata['broker_last_error'] = $reason; } $gateway->metadata_json->set($gatewayMetadata); return $presence; } private function readBrokerPresence(int $gatewayId): array { $redisPresence = $this->withRedis( static fn(redis $redis): ?string => $redis->get(edge_gateway_manager::brokerPresenceKey($gatewayId)), null ); if (is_string($redisPresence) && trim($redisPresence) !== '') { $decoded = json_decode($redisPresence, true); if (is_array($decoded)) { return $decoded; } } $gateway = (new edge_gateways_o())->select($gatewayId); if ($gateway->exists()) { $metadata = (array)($gateway->metadata_json->value() ?? []); if (isset($metadata['broker_presence']) && is_array($metadata['broker_presence'])) { return (array)$metadata['broker_presence']; } } return []; } private function writeBrokerPresence(int $gatewayId, array $presence): void { $encoded = json_encode($presence, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($encoded === false) { return; } $this->withRedis( static function (redis $redis) use ($gatewayId, $encoded): void { $redis->setEx(edge_gateway_manager::brokerPresenceKey($gatewayId), $encoded, edge_gateway_manager::BROKER_PRESENCE_TTL_SECONDS); } ); } private static function brokerPresenceKey(int $gatewayId): string { return 'edge_gateway_broker_presence_' . $gatewayId; } private function withRedis(callable $callback, mixed $fallback = null): mixed { try { return $callback(new redis()); } catch (\Throwable) { return $fallback; } } private function buildGatewayOperationalSnapshot(int $gatewayId): array { $pdo = db::getPDO(); $counts = [ 'command_backlog' => 0, 'operation_backlog' => 0, 'last_successful_command_at' => null, 'last_successful_discovery_at' => null, 'last_successful_operation_at' => null, 'last_successful_update_at' => null, ]; $countQueries = [ 'command_backlog' => "SELECT COUNT(*) AS c FROM edge_gateway_command_jobs WHERE gateway_id = :gateway_id AND deleted_at IS NULL AND status IN ('PENDING', 'DISPATCHING')", 'operation_backlog' => "SELECT COUNT(*) AS c FROM edge_gateway_operations WHERE gateway_id = :gateway_id AND deleted_at IS NULL AND status IN ('PENDING', 'IN_PROGRESS')", ]; foreach ($countQueries as $key => $sql) { $statement = $pdo->prepare($sql); $statement->execute([':gateway_id' => $gatewayId]); $row = $statement->fetch(); $counts[$key] = isset($row['c']) ? (int)$row['c'] : 0; } $timestampQueries = [ 'last_successful_command_at' => "SELECT completed_at AS ts FROM edge_gateway_command_jobs WHERE gateway_id = :gateway_id AND deleted_at IS NULL AND status = 'COMPLETED' ORDER BY completed_at DESC, id DESC LIMIT 1", 'last_successful_discovery_at' => "SELECT completed_at AS ts FROM edge_gateway_command_jobs WHERE gateway_id = :gateway_id AND deleted_at IS NULL AND status = 'COMPLETED' AND command_type = 'DISCOVER_SHELLY' ORDER BY completed_at DESC, id DESC LIMIT 1", 'last_successful_operation_at' => "SELECT completed_at AS ts FROM edge_gateway_operations WHERE gateway_id = :gateway_id AND deleted_at IS NULL AND status = 'COMPLETED' ORDER BY completed_at DESC, id DESC LIMIT 1", 'last_successful_update_at' => "SELECT completed_at AS ts FROM edge_gateway_operations WHERE gateway_id = :gateway_id AND deleted_at IS NULL AND status = 'COMPLETED' AND type = 'UPDATE' ORDER BY completed_at DESC, id DESC LIMIT 1", ]; foreach ($timestampQueries as $key => $sql) { $statement = $pdo->prepare($sql); $statement->execute([':gateway_id' => $gatewayId]); $row = $statement->fetch(); $counts[$key] = isset($row['ts']) ? (string)$row['ts'] : null; } return $counts; } private function buildBrokerPublicUrl(): ?string { $configured = trim((string)(getenv('EDGE_PUBLIC_BROKER_URL') ?: '')); if ($configured !== '') { return rtrim($configured, '/'); } $apiBaseUrl = $this->getApiBaseUrl(); $parsed = parse_url($apiBaseUrl); $host = $parsed['host'] ?? null; if (!is_string($host) || trim($host) === '') { return null; } $scheme = strtolower((string)($parsed['scheme'] ?? 'http')) === 'https' ? 'https' : 'http'; $port = (int)(getenv('EDGE_PUBLIC_BROKER_PORT') ?: 4300); if ($port <= 0) { $port = 4300; } $hostWithPort = $host; if (!(($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80))) { $hostWithPort .= ':' . $port; } return $scheme . '://' . $hostWithPort; } private function buildBrokerInternalUrl(): ?string { $configured = trim((string)(getenv('EDGE_BROKER_URL') ?: '')); return $configured !== '' ? rtrim($configured, '/') : null; } private function resolveGatewayPreferredCommandChannel(edge_gateways_o|array $gateway): string { $gatewayId = is_array($gateway) ? (int)($gateway['id'] ?? 0) : (int)$gateway->id; if ($gatewayId <= 0 || $this->buildBrokerInternalUrl() === null) { return self::DELIVERY_CHANNEL_API; } $presence = $this->readBrokerPresence($gatewayId); return !empty($presence['connected']) ? self::DELIVERY_CHANNEL_BROKER : self::DELIVERY_CHANNEL_API; } private function normalizeRelayBindingMetadata(array $metadata = [], array $binding = []): array { $fallbackMode = strtoupper(trim((string)($binding['fallback_mode'] ?? $metadata['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL))); if (!in_array($fallbackMode, [ self::RELAY_FALLBACK_PREFER_LOCAL, self::RELAY_FALLBACK_LOCAL_ONLY, self::RELAY_FALLBACK_CLOUD_ONLY, ], true)) { $fallbackMode = self::RELAY_FALLBACK_PREFER_LOCAL; } $metadata['fallback_mode'] = $fallbackMode; if (isset($binding['last_resolution']) && is_array($binding['last_resolution'])) { $metadata['last_resolution'] = (array)$binding['last_resolution']; } if (array_key_exists('last_success_at', $binding)) { $metadata['last_success_at'] = $binding['last_success_at']; } if (array_key_exists('last_error', $binding)) { $metadata['last_error'] = $binding['last_error']; } return $metadata; } private function buildDeliveryMetadata(array $overrides = [], int $ttlSeconds = self::COMMAND_EXPIRES_AFTER_SECONDS): array { $preferredChannel = strtoupper(trim((string)($overrides['preferred_channel'] ?? self::DELIVERY_CHANNEL_API))); if (!in_array($preferredChannel, [ self::DELIVERY_CHANNEL_BROKER, self::DELIVERY_CHANNEL_API, self::DELIVERY_CHANNEL_CLOUD, ], true)) { $preferredChannel = self::DELIVERY_CHANNEL_API; } return array_merge([ 'preferred_channel' => $preferredChannel, 'delivery_channel' => $overrides['delivery_channel'] ?? null, 'attempt_count' => isset($overrides['attempt_count']) ? (int)$overrides['attempt_count'] : 0, 'expires_at' => $overrides['expires_at'] ?? $this->formatDateTime(time() + max(30, $ttlSeconds)), 'fallback_reason' => $overrides['fallback_reason'] ?? null, 'last_dispatch_error' => $overrides['last_dispatch_error'] ?? null, ], $overrides); } private function resolveRelayExecutionPlan(edge_gateways_o $gateway, array $binding, string $logicalRelayId): array { $gatewayData = $gateway->asArray(); $gatewayData['metadata']['broker_presence'] = $this->readBrokerPresence((int)$gateway->id); $gatewayData['department_transport_mode'] = $this->getDepartmentTransportMode((int)$gateway->department_id->value()); $gatewayData['inventory'] = $this->listInventory((int)$gateway->id); $gatewayData['bindings'] = [$binding]; $runtime = self::deriveGatewayRuntimeState($gatewayData); $relayHealth = (array)($runtime['relay_health'][0] ?? []); $executionPath = (string)($relayHealth['execution_path'] ?? 'local'); return array_merge($relayHealth, [ 'relay_id' => $logicalRelayId, 'execution_path' => $executionPath, 'preferred_channel' => $executionPath === 'local' ? $this->resolveGatewayPreferredCommandChannel($gateway) : self::DELIVERY_CHANNEL_CLOUD, ]); } /** * @throws Exception */ private function dispatchGatewayCommand(edge_gateways_o $gateway, edge_gateway_command_jobs_o $job): array { $effectiveStatus = self::resolveGatewayStatus( $gateway->status->value() === null ? null : (string)$gateway->status->value(), $gateway->last_heartbeat_at->value() === null ? null : (string)$gateway->last_heartbeat_at->value() ); if (!in_array($effectiveStatus, [self::STATUS_ONLINE, self::STATUS_DEGRADED], true)) { throw new Exception('Gateway agent is offline'); } $delivery = (array)($job->delivery_json->value() ?? []); $preferredChannel = (string)($delivery['preferred_channel'] ?? self::DELIVERY_CHANNEL_API); if ($preferredChannel === self::DELIVERY_CHANNEL_BROKER && $this->buildBrokerInternalUrl() !== null) { try { $this->markCommandJobDispatching($job, self::DELIVERY_CHANNEL_BROKER); $payload = $this->dispatchBrokerCommand($gateway, $job); $this->finalizeCommandJob($job, true, $payload, null, $gateway); return $payload; } catch (Exception $exception) { $this->markCommandDeliveryFailure($job, self::DELIVERY_CHANNEL_BROKER, $exception->getMessage(), 'broker_dispatch_failed'); } } return $this->waitForCommandResult((int)$job->id); } private function markCommandJobDispatching( edge_gateway_command_jobs_o $job, string $channel, ?string $fallbackReason = null ): void { $delivery = $this->buildDeliveryMetadata( array_merge( (array)($job->delivery_json->value() ?? []), [ 'delivery_channel' => $channel, 'attempt_count' => (int)((array)($job->delivery_json->value() ?? [])['attempt_count'] ?? 0) + 1, 'fallback_reason' => $fallbackReason, 'last_dispatch_error' => null, ] ), self::COMMAND_EXPIRES_AFTER_SECONDS ); $job->status->set('DISPATCHING'); $job->response_json->set([]); $job->completed_at->set(null); $job->error_message->set(null); $job->delivery_json->set($delivery); } private function markCommandDeliveryFailure( edge_gateway_command_jobs_o $job, string $channel, string $errorMessage, ?string $fallbackReason = null ): void { $delivery = $this->buildDeliveryMetadata( array_merge( (array)($job->delivery_json->value() ?? []), [ 'delivery_channel' => $channel, 'fallback_reason' => $fallbackReason, 'last_dispatch_error' => $errorMessage, ] ), self::COMMAND_EXPIRES_AFTER_SECONDS ); $job->delivery_json->set($delivery); } /** * @throws Exception */ private function dispatchBrokerCommand(edge_gateways_o $gateway, edge_gateway_command_jobs_o $job): array { $brokerUrl = $this->buildBrokerInternalUrl(); if ($brokerUrl === null) { throw new Exception('Edge broker is not configured'); } $presence = $this->readBrokerPresence((int)$gateway->id); if (empty($presence['connected'])) { throw new Exception('Edge broker fast path is unavailable'); } $result = $this->httpJsonRequest( $brokerUrl . '/api/gateways/' . (int)$gateway->id . '/commands', [ 'commandType' => (string)$job->command_type->value(), 'payload' => $this->buildCommandExecutionPayload($job, $gateway), ], [ 'x-edge-broker-secret: ' . trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: '')), ], self::BROKER_HTTP_TIMEOUT_SECONDS ); if (!is_array($result) || empty($result['ok'])) { throw new Exception(trim((string)($result['error'] ?? 'Edge broker dispatch failed')) ?: 'Edge broker dispatch failed'); } return isset($result['payload']) && is_array($result['payload']) ? (array)$result['payload'] : []; } /** * @throws Exception */ private function dispatchRelayThroughCloud( int $departmentId, string $logicalRelayId, ?bool $on, array $binding, array $resolution ): array { $transport = new cloud_shelly_transport(); $response = $on === null ? $transport->sendPostRequest('/v2/devices/api/get', ['ids' => [$logicalRelayId]], $departmentId) : $transport->sendPostRequest('/v2/devices/api/set/switch', ['id' => $logicalRelayId, 'on' => $on], $departmentId); $normalized = is_array($response) ? (array)($response[0] ?? []) : (array)$response; $result = [ 'online' => (bool)($normalized['online'] ?? true), 'on' => (bool)($normalized['on'] ?? $normalized['output'] ?? $normalized['status']['switch:0']['output'] ?? $on ?? false), 'raw' => (array)($normalized['raw'] ?? $normalized), ]; return $this->finalizeRelayDispatch( $binding, array_merge($resolution, [ 'execution_path' => 'cloud', 'delivery_channel' => self::DELIVERY_CHANNEL_CLOUD, ]), $result ); } /** * @throws Exception */ private function handleRelayDispatchFailure( int $departmentId, string $logicalRelayId, array $binding, array $resolution, ?bool $on, Exception $exception ): array { $recommendedAction = $this->mapRelayFailureToRecommendedAction($exception->getMessage()); $this->recordRelayBindingResolution( $binding, array_merge($resolution, [ 'execution_path' => 'local', 'delivery_channel' => (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API), 'reason' => 'local_dispatch_failed', 'recommended_action' => $recommendedAction, 'recovery_actions' => [$recommendedAction], ]), false, $exception->getMessage() ); if ((string)($resolution['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL) !== self::RELAY_FALLBACK_PREFER_LOCAL) { throw $exception; } try { return $this->dispatchRelayThroughCloud( $departmentId, $logicalRelayId, $on, $binding, array_merge($resolution, [ 'execution_path' => 'cloud', 'reason' => 'local_dispatch_failed', 'fallback_reason' => $exception->getMessage(), 'recommended_action' => $recommendedAction, 'recovery_actions' => [$recommendedAction, 'force_cloud'], ]) ); } catch (Exception $cloudException) { $this->recordRelayBindingResolution( $binding, array_merge($resolution, [ 'execution_path' => 'cloud', 'delivery_channel' => self::DELIVERY_CHANNEL_CLOUD, 'reason' => 'cloud_fallback_failed', 'recommended_action' => $recommendedAction, ]), false, $cloudException->getMessage() ); throw $cloudException; } } private function finalizeRelayDispatch(array $binding, array $resolution, array $result): array { $executionPath = (string)($resolution['execution_path'] ?? 'local'); $deliveryChannel = $executionPath === 'cloud' ? self::DELIVERY_CHANNEL_CLOUD : (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API); $resolutionPayload = array_merge($resolution, [ 'delivery_channel' => $deliveryChannel, 'execution_path' => $executionPath, ]); $this->recordRelayBindingResolution($binding, $resolutionPayload, true, null); return array_merge($result, [ 'binding' => $this->reloadRelayBinding((int)$binding['id']), 'execution' => [ 'path' => $executionPath, 'channel' => $deliveryChannel, 'reason' => $resolutionPayload['reason'] ?? null, 'fallback_mode' => $resolutionPayload['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL, 'recommended_action' => $resolutionPayload['recommended_action'] ?? null, ], 'raw' => (array)($result['raw'] ?? []), ]); } private function reloadRelayBinding(int $bindingId): array { $binding = (new edge_gateway_relay_bindings_o())->select($bindingId); return $binding->exists() ? $binding->asArray() : []; } private function recordRelayBindingResolution( array $binding, array $resolution, bool $success, ?string $errorMessage ): void { $bindingObject = (new edge_gateway_relay_bindings_o())->select((int)$binding['id']); if (!$bindingObject->exists()) { return; } $metadata = $this->normalizeRelayBindingMetadata((array)($bindingObject->metadata_json->value() ?? []), $binding); $metadata['last_resolution'] = [ 'at' => $this->now(), 'execution_path' => $resolution['execution_path'] ?? 'local', 'delivery_channel' => $resolution['delivery_channel'] ?? ($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API), 'reason' => $resolution['reason'] ?? null, 'fallback_mode' => $metadata['fallback_mode'], 'recommended_action' => $resolution['recommended_action'] ?? null, 'recovery_actions' => array_values(array_filter((array)($resolution['recovery_actions'] ?? []))), 'gateway_status' => $resolution['gateway_status'] ?? null, 'device_online' => $resolution['device_online'] ?? null, 'device_freshness_seconds' => $resolution['device_freshness_seconds'] ?? null, 'device_freshness_state' => $resolution['device_freshness_state'] ?? null, ]; if ($success) { $metadata['last_success_at'] = $this->now(); $metadata['last_error'] = null; } else { $metadata['last_error'] = $errorMessage; } $bindingObject->metadata_json->set($metadata); } private function mapRelayFailureToRecommendedAction(string $errorMessage): string { $normalized = strtolower(trim($errorMessage)); if ($normalized === '') { return 'retry_local_command'; } if (str_contains($normalized, 'credential') || str_contains($normalized, 'token')) { return 'rotate_credentials'; } if (str_contains($normalized, 'discovery') || str_contains($normalized, 'device')) { return 'retry_discovery'; } if (str_contains($normalized, 'update')) { return 'retry_update'; } if (str_contains($normalized, 'offline') || str_contains($normalized, 'timeout') || str_contains($normalized, 'broker')) { return 'restart_agent'; } return 'retry_local_command'; } /** * @throws Exception */ private function httpJsonRequest(string $url, array $payload, array $headers = [], int $timeoutSeconds = 5): array { $defaultHeaders = [ 'Content-Type: application/json', 'Accept: application/json', ]; $context = stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => implode("\r\n", array_filter(array_merge($defaultHeaders, $headers))), 'content' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'timeout' => max(1, $timeoutSeconds), 'ignore_errors' => true, ], ]); $response = @file_get_contents($url, false, $context); if ($response === false) { throw new Exception('Unable to reach edge broker'); } $decoded = json_decode($response, true); if (!is_array($decoded)) { throw new Exception('Edge broker returned an invalid response'); } $statusLine = is_array($http_response_header ?? null) ? (string)($http_response_header[0] ?? '') : ''; if ($statusLine !== '' && preg_match('/\s(\d{3})\s/', $statusLine, $matches) === 1) { $statusCode = (int)$matches[1]; if ($statusCode >= 400) { throw new Exception(trim((string)($decoded['error'] ?? $decoded['message'] ?? 'Edge broker request failed')) ?: 'Edge broker request failed'); } } return $decoded; } /** * @param array $gatewayIds * @return array */ private function aggregateInventoryUsage(array $gatewayIds): array { if ($gatewayIds === []) { return self::emptyInventoryUsage(); } $placeholders = implode(', ', array_fill(0, count($gatewayIds), '?')); $statement = db::getPDO()->prepare( "SELECT COUNT(*) AS total, SUM(CASE WHEN online = 1 THEN 1 ELSE 0 END) AS online_total, SUM(CASE WHEN online = 0 THEN 1 ELSE 0 END) AS offline_total FROM edge_gateway_device_inventory WHERE deleted_at IS NULL AND gateway_id IN ($placeholders)" ); $statement->execute($gatewayIds); $row = $statement->fetch(); return [ 'total' => (int)($row['total'] ?? 0), 'online' => (int)($row['online_total'] ?? 0), 'offline' => (int)($row['offline_total'] ?? 0), ]; } /** * @param array $gatewayIds * @return array */ private function aggregateBindingUsage(array $gatewayIds): array { if ($gatewayIds === []) { return self::emptyBindingUsage(); } $placeholders = implode(', ', array_fill(0, count($gatewayIds), '?')); $statement = db::getPDO()->prepare( "SELECT COUNT(*) AS total, SUM(CASE WHEN fallback_mode <> ? THEN 1 ELSE 0 END) AS fallback_overrides, SUM(CASE WHEN fallback_mode = ? THEN 1 ELSE 0 END) AS cloud_only_total, SUM(CASE WHEN fallback_mode = ? THEN 1 ELSE 0 END) AS local_only_total FROM edge_gateway_relay_bindings WHERE deleted_at IS NULL AND gateway_id IN ($placeholders)" ); $statement->execute(array_merge([ self::RELAY_FALLBACK_PREFER_LOCAL, self::RELAY_FALLBACK_CLOUD_ONLY, self::RELAY_FALLBACK_LOCAL_ONLY, ], $gatewayIds)); $row = $statement->fetch(); return [ 'total' => (int)($row['total'] ?? 0), 'fallback_overrides' => (int)($row['fallback_overrides'] ?? 0), 'cloud_only' => (int)($row['cloud_only_total'] ?? 0), 'local_only' => (int)($row['local_only_total'] ?? 0), ]; } /** * @return array */ private static function emptyInventoryUsage(): array { return [ 'total' => 0, 'online' => 0, 'offline' => 0, ]; } /** * @return array */ private static function emptyBindingUsage(): array { return [ 'total' => 0, 'fallback_overrides' => 0, 'cloud_only' => 0, 'local_only' => 0, ]; } /** * @param array $values */ private static function appendNumericMetric(array &$values, mixed $value): void { if (!is_int($value) && !is_float($value) && !(is_string($value) && is_numeric($value))) { return; } $values[] = (float)$value; } /** * @param array $values */ private static function averageMetric(array $values): ?int { if ($values === []) { return null; } return (int)round(array_sum($values) / count($values)); } public static function deriveGatewayRuntimeState(array $gateway, ?int $now = null): array { $effectiveStatus = self::resolveGatewayStatus( isset($gateway['status']) ? (string)$gateway['status'] : null, isset($gateway['last_heartbeat_at']) && $gateway['last_heartbeat_at'] !== null ? (string)$gateway['last_heartbeat_at'] : null, $now ); $gateway['status'] = $effectiveStatus; $gateway['discovery_status'] = self::resolveDiscoveryStatus( isset($gateway['discovery_status']) ? (string)$gateway['discovery_status'] : null, $effectiveStatus ); $gateway['metadata'] = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : []; $gateway['operational_snapshot'] = isset($gateway['operational_snapshot']) && is_array($gateway['operational_snapshot']) ? (array)$gateway['operational_snapshot'] : []; $channelStatus = self::deriveChannelStatus($gateway, $effectiveStatus, $now); $relayHealth = self::buildRelayHealth($gateway, $effectiveStatus, $now); $fallbackSummary = self::buildFallbackSummary($relayHealth); $gateway['channel_status'] = $channelStatus; $gateway['relay_health'] = $relayHealth; $gateway['fallback_summary'] = $fallbackSummary; $gateway['transport_health'] = self::deriveTransportHealth($effectiveStatus, $channelStatus, $fallbackSummary); $gateway['last_successful_command_at'] = $gateway['operational_snapshot']['last_successful_command_at'] ?? self::findLatestCompletionTimestamp((array)($gateway['recent_commands'] ?? []), null); $gateway['last_successful_discovery_at'] = $gateway['operational_snapshot']['last_successful_discovery_at'] ?? self::findLatestCompletionTimestamp((array)($gateway['recent_commands'] ?? []), 'DISCOVER_SHELLY'); $gateway['last_successful_operation_at'] = $gateway['operational_snapshot']['last_successful_operation_at'] ?? null; $gateway['backlog_depth'] = [ 'commands' => (int)($gateway['operational_snapshot']['command_backlog'] ?? 0), 'operations' => (int)($gateway['operational_snapshot']['operation_backlog'] ?? 0), ]; $gateway['version_drift'] = self::buildVersionDriftSummary($gateway); $gateway['credential_freshness'] = self::buildCredentialFreshnessSummary($gateway, $now); $gateway['diagnostics'] = self::buildGatewayDiagnostics($gateway, $effectiveStatus, $now); $gateway['error_state'] = self::primaryGatewayErrorState($gateway['diagnostics'], $gateway); return $gateway; } private static function deriveChannelStatus(array $gateway, string $effectiveStatus, ?int $now = null): array { $metadata = (array)($gateway['metadata'] ?? []); $operational = (array)($gateway['operational_snapshot'] ?? []); $brokerPresence = isset($metadata['broker_presence']) && is_array($metadata['broker_presence']) ? (array)$metadata['broker_presence'] : []; $brokerConnected = !empty($brokerPresence['connected']); $brokerLastSeenAt = isset($brokerPresence['last_seen_at']) ? (string)$brokerPresence['last_seen_at'] : null; $brokerAgeSeconds = self::heartbeatAgeSeconds($brokerLastSeenAt, $now); $brokerHealthy = $brokerConnected && $brokerAgeSeconds !== null && $brokerAgeSeconds < self::BROKER_CONNECTIVITY_DEGRADED_AFTER_SECONDS; $commandPreferred = $brokerConnected ? self::DELIVERY_CHANNEL_BROKER : self::DELIVERY_CHANNEL_API; return [ 'command' => [ 'preferred' => $commandPreferred, 'active' => $brokerHealthy ? self::DELIVERY_CHANNEL_BROKER : self::DELIVERY_CHANNEL_API, 'state' => $effectiveStatus === self::STATUS_OFFLINE ? self::STATUS_OFFLINE : ($brokerConnected ? ($brokerHealthy ? self::STATUS_ONLINE : self::STATUS_DEGRADED) : self::STATUS_DEGRADED), 'backlog_depth' => (int)($operational['command_backlog'] ?? 0), 'last_success_at' => $operational['last_successful_command_at'] ?? self::findLatestCompletionTimestamp((array)($gateway['recent_commands'] ?? []), null), ], 'broker' => [ 'connected' => $brokerConnected, 'state' => !$brokerConnected ? self::STATUS_OFFLINE : ($brokerHealthy ? self::STATUS_ONLINE : self::STATUS_DEGRADED), 'last_seen_at' => $brokerLastSeenAt, 'disconnect_reason' => isset($brokerPresence['disconnect_reason']) ? (string)$brokerPresence['disconnect_reason'] : null, 'last_error' => isset($brokerPresence['last_error']) ? (string)$brokerPresence['last_error'] : null, ], ]; } private static function buildRelayHealth(array $gateway, string $effectiveStatus, ?int $now = null): array { $bindings = is_array($gateway['bindings'] ?? null) ? (array)$gateway['bindings'] : []; $inventory = is_array($gateway['inventory'] ?? null) ? (array)$gateway['inventory'] : []; $inventoryByDeviceId = []; foreach ($inventory as $device) { if (!is_array($device)) { continue; } $deviceId = trim((string)($device['device_id'] ?? '')); if ($deviceId !== '') { $inventoryByDeviceId[$deviceId] = $device; } } $departmentTransportMode = (string)($gateway['department_transport_mode'] ?? self::TRANSPORT_MODE_CLOUD); $relayHealth = []; foreach ($bindings as $binding) { if (!is_array($binding)) { continue; } $bindingMetadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : []; $fallbackMode = self::normalizeFallbackMode((string)($binding['fallback_mode'] ?? $bindingMetadata['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL)); $device = isset($inventoryByDeviceId[(string)($binding['device_id'] ?? '')]) ? (array)$inventoryByDeviceId[(string)$binding['device_id']] : null; $deviceLastSeenAt = is_array($device) && isset($device['last_seen_at']) ? (string)$device['last_seen_at'] : null; $deviceFreshnessSeconds = self::heartbeatAgeSeconds($deviceLastSeenAt, $now); $deviceOnline = is_array($device) && array_key_exists('online', $device) ? (bool)$device['online'] : null; $deviceFresh = $device !== null && $deviceOnline !== false && $deviceFreshnessSeconds !== null && $deviceFreshnessSeconds < self::DEVICE_FRESHNESS_DEGRADED_AFTER_SECONDS; $executionPath = 'local'; $reason = null; if ($departmentTransportMode === self::TRANSPORT_MODE_CLOUD) { $executionPath = 'cloud'; $reason = 'department_cutover'; } elseif ($fallbackMode === self::RELAY_FALLBACK_CLOUD_ONLY) { $executionPath = 'cloud'; $reason = 'binding_cloud_only'; } elseif ($effectiveStatus === self::STATUS_OFFLINE) { $executionPath = $fallbackMode === self::RELAY_FALLBACK_LOCAL_ONLY ? 'local' : 'cloud'; $reason = 'gateway_offline'; } elseif ($device === null) { $executionPath = $fallbackMode === self::RELAY_FALLBACK_LOCAL_ONLY ? 'local' : 'cloud'; $reason = 'device_missing'; } elseif (!$deviceFresh) { $executionPath = $fallbackMode === self::RELAY_FALLBACK_LOCAL_ONLY ? 'local' : 'cloud'; $reason = $deviceOnline === false ? 'device_offline' : 'device_stale'; } $recommendedAction = match ($reason) { 'department_cutover' => 'review_department_cutover', 'binding_cloud_only' => 'review_binding_override', 'gateway_offline' => 'restart_agent', 'device_missing', 'device_stale', 'device_offline' => 'retry_discovery', default => null, }; $relayHealth[] = [ 'binding_id' => isset($binding['id']) ? (int)$binding['id'] : null, 'relay_id' => isset($binding['relay_id']) ? (string)$binding['relay_id'] : null, 'device_id' => isset($binding['device_id']) ? (string)$binding['device_id'] : null, 'fallback_mode' => $fallbackMode, 'execution_path' => $executionPath, 'reason' => $reason, 'recommended_action' => $recommendedAction, 'recovery_actions' => array_values(array_filter([$recommendedAction])), 'device_online' => $deviceOnline, 'device_freshness_seconds' => $deviceFreshnessSeconds, 'device_freshness_state' => self::resolveDeviceFreshnessState($device, $deviceFreshnessSeconds), 'gateway_status' => $effectiveStatus, 'last_resolution' => isset($binding['last_resolution']) && is_array($binding['last_resolution']) ? (array)$binding['last_resolution'] : (isset($bindingMetadata['last_resolution']) && is_array($bindingMetadata['last_resolution']) ? (array)$bindingMetadata['last_resolution'] : null), 'last_success_at' => $binding['last_success_at'] ?? $bindingMetadata['last_success_at'] ?? null, 'last_error' => $binding['last_error'] ?? $bindingMetadata['last_error'] ?? null, ]; } return $relayHealth; } private static function buildFallbackSummary(array $relayHealth): array { $summary = [ 'local_relays' => 0, 'cloud_relays' => 0, 'local_only_relays' => 0, 'cloud_only_relays' => 0, 'affected_relays' => [], 'recommended_action' => null, ]; foreach ($relayHealth as $relay) { $executionPath = (string)($relay['execution_path'] ?? 'local'); if ($executionPath === 'cloud') { $summary['cloud_relays'] += 1; if (!empty($relay['relay_id'])) { $summary['affected_relays'][] = (string)$relay['relay_id']; } if ($summary['recommended_action'] === null && !empty($relay['recommended_action'])) { $summary['recommended_action'] = (string)$relay['recommended_action']; } } else { $summary['local_relays'] += 1; } $fallbackMode = (string)($relay['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL); if ($fallbackMode === self::RELAY_FALLBACK_LOCAL_ONLY) { $summary['local_only_relays'] += 1; } if ($fallbackMode === self::RELAY_FALLBACK_CLOUD_ONLY) { $summary['cloud_only_relays'] += 1; } } return $summary; } private static function deriveTransportHealth(string $effectiveStatus, array $channelStatus, array $fallbackSummary): array { $brokerState = (string)($channelStatus['broker']['state'] ?? self::STATUS_OFFLINE); $affectedRelayCount = count((array)($fallbackSummary['affected_relays'] ?? [])); $transportState = $effectiveStatus; if ($effectiveStatus !== self::STATUS_OFFLINE && ($brokerState === self::STATUS_DEGRADED || $affectedRelayCount > 0)) { $transportState = self::STATUS_DEGRADED; } return [ 'status' => $transportState, 'broker_connected' => !empty($channelStatus['broker']['connected']), 'affected_relay_count' => $affectedRelayCount, 'summary' => $affectedRelayCount > 0 ? $affectedRelayCount . ' relæ(er) kører via cloud fallback' : (!empty($channelStatus['broker']['connected']) ? 'Broker fast path er aktiv med API polling som fallback' : 'API polling er aktiv som primær kontrolkanal'), 'recommended_action' => $fallbackSummary['recommended_action'] ?? ($channelStatus['broker']['last_error'] ?? null), ]; } private static function buildVersionDriftSummary(array $gateway): array { $installed = isset($gateway['installed_version']) ? trim((string)$gateway['installed_version']) : ''; $target = isset($gateway['target_version']) ? trim((string)$gateway['target_version']) : ''; $isDrifted = $installed !== '' && $target !== '' && $installed !== $target; return [ 'installed_version' => $installed !== '' ? $installed : null, 'target_version' => $target !== '' ? $target : null, 'release_channel' => isset($gateway['release_channel']) ? (string)$gateway['release_channel'] : self::DEFAULT_RELEASE_CHANNEL, 'is_drifted' => $isDrifted, 'status' => $isDrifted ? 'UPDATE_AVAILABLE' : (($installed === '' || $target === '') ? 'UNKNOWN' : 'IN_SYNC'), 'last_successful_update_at' => $gateway['operational_snapshot']['last_successful_update_at'] ?? null, ]; } private static function buildCredentialFreshnessSummary(array $gateway, ?int $now = null): array { $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : []; $rotatedAt = isset($metadata['credentials_rotated_at']) ? (string)$metadata['credentials_rotated_at'] : null; $ageSeconds = self::heartbeatAgeSeconds($rotatedAt, $now); return [ 'rotated_at' => $rotatedAt, 'age_days' => $ageSeconds === null ? null : (int)floor($ageSeconds / 86400), 'state' => $rotatedAt === null ? 'UNKNOWN' : ($ageSeconds !== null && $ageSeconds <= self::CREDENTIAL_FRESH_AFTER_SECONDS ? 'FRESH' : 'STALE'), ]; } private static function buildGatewayDiagnostics(array $gateway, string $effectiveStatus, ?int $now = null): array { $diagnostics = []; $heartbeatAge = self::heartbeatAgeSeconds( isset($gateway['last_heartbeat_at']) ? (string)$gateway['last_heartbeat_at'] : null, $now ); if ($effectiveStatus === self::STATUS_OFFLINE) { $diagnostics[] = [ 'code' => edge_gateway_operation_service::ERROR_OFFLINE, 'severity' => 'danger', 'message' => 'Gateway heartbeat has expired and the gateway is offline.', 'recommended_action' => 'restart_agent', ]; } elseif ($effectiveStatus === self::STATUS_DEGRADED || ($heartbeatAge !== null && $heartbeatAge >= self::HEARTBEAT_DEGRADED_AFTER_SECONDS)) { $diagnostics[] = [ 'code' => edge_gateway_operation_service::ERROR_STALE_HEARTBEAT, 'severity' => 'warning', 'message' => 'Gateway heartbeat is stale and control traffic may degrade.', 'recommended_action' => 'inspect_connectivity', ]; } $activeOperation = isset($gateway['active_operation']) && is_array($gateway['active_operation']) ? (array)$gateway['active_operation'] : null; if ($activeOperation !== null && !empty($activeOperation['started_at'])) { $startedAt = strtotime((string)$activeOperation['started_at']); if ($startedAt !== false && (($now ?? time()) - $startedAt) >= edge_gateway_operation_service::OPERATION_TIMEOUT_SECONDS) { $diagnostics[] = [ 'code' => edge_gateway_operation_service::ERROR_OPERATION_TIMEOUT, 'severity' => 'warning', 'message' => 'The active gateway operation has exceeded the expected timeout.', 'recommended_action' => 'retry_operation', ]; } } if (!empty($gateway['version_drift']['is_drifted'])) { $diagnostics[] = [ 'code' => 'EDGE_GATEWAY_VERSION_DRIFT', 'severity' => 'info', 'message' => 'Installed gateway version differs from the target version.', 'recommended_action' => 'queue_update', ]; } if (($gateway['credential_freshness']['state'] ?? 'UNKNOWN') === 'STALE') { $diagnostics[] = [ 'code' => 'EDGE_GATEWAY_CREDENTIALS_STALE', 'severity' => 'warning', 'message' => 'Gateway credentials have not been rotated recently.', 'recommended_action' => 'rotate_credentials', ]; } return $diagnostics; } private static function primaryGatewayErrorState(array $diagnostics, array $gateway): ?array { if ($diagnostics !== []) { return [ 'code' => (string)$diagnostics[0]['code'], 'message' => (string)$diagnostics[0]['message'], 'recommended_action' => $diagnostics[0]['recommended_action'] ?? null, ]; } if (!empty($gateway['active_operation']['error_code']) || !empty($gateway['active_operation']['error_message'])) { return [ 'code' => $gateway['active_operation']['error_code'] ?? 'EDGE_GATEWAY_OPERATION_FAILED', 'message' => $gateway['active_operation']['error_message'] ?? 'Gateway operation failed', 'recommended_action' => 'retry_operation', ]; } return null; } private static function findLatestCompletionTimestamp(array $jobs, ?string $commandType = null): ?string { foreach ($jobs as $job) { if (!is_array($job)) { continue; } if (($job['status'] ?? null) !== 'COMPLETED') { continue; } if ($commandType !== null && ($job['command_type'] ?? null) !== $commandType) { continue; } return isset($job['completed_at']) ? (string)$job['completed_at'] : null; } return null; } private static function findLatestShellTimestamp(array $sessions): ?string { foreach ($sessions as $session) { if (!is_array($session)) { continue; } foreach (['opened_at', 'approved_at', 'created_at'] as $field) { if (!empty($session[$field])) { return (string)$session[$field]; } } } return null; } private static function normalizeFallbackMode(?string $fallbackMode): string { $normalized = strtoupper(trim((string)$fallbackMode)); if (in_array($normalized, [ self::RELAY_FALLBACK_PREFER_LOCAL, self::RELAY_FALLBACK_LOCAL_ONLY, self::RELAY_FALLBACK_CLOUD_ONLY, ], true)) { return $normalized; } return self::RELAY_FALLBACK_PREFER_LOCAL; } private static function resolveDeviceFreshnessState(?array $device, ?int $ageSeconds): string { if ($device === null) { return 'MISSING'; } if (isset($device['online']) && $device['online'] === false) { return 'OFFLINE'; } if ($ageSeconds === null) { return 'UNKNOWN'; } if ($ageSeconds >= self::DEVICE_FRESHNESS_DEGRADED_AFTER_SECONDS) { return 'STALE'; } return 'READY'; } public static function resolveGatewayStatus(?string $reportedStatus, ?string $lastHeartbeatAt, ?int $now = null): string { $normalizedStatus = self::normalizeGatewayStatus($reportedStatus); $heartbeatAgeSeconds = self::heartbeatAgeSeconds($lastHeartbeatAt, $now); if ($normalizedStatus === self::STATUS_OFFLINE) { return self::STATUS_OFFLINE; } if ($heartbeatAgeSeconds === null || $heartbeatAgeSeconds >= self::HEARTBEAT_OFFLINE_AFTER_SECONDS) { return self::STATUS_OFFLINE; } if ($normalizedStatus === self::STATUS_DEGRADED) { return self::STATUS_DEGRADED; } if ($normalizedStatus === self::STATUS_ONLINE && $heartbeatAgeSeconds >= self::HEARTBEAT_DEGRADED_AFTER_SECONDS) { return self::STATUS_DEGRADED; } return $normalizedStatus; } public static function resolveDiscoveryStatus(?string $discoveryStatus, string $effectiveStatus): string { $normalizedStatus = trim(strtoupper((string)$discoveryStatus)); if ($effectiveStatus === self::STATUS_OFFLINE && $normalizedStatus === 'READY') { return 'STALE'; } return $normalizedStatus !== '' ? $normalizedStatus : 'UNKNOWN'; } private static function normalizeGatewayStatus(?string $reportedStatus): string { $normalizedStatus = trim(strtoupper((string)$reportedStatus)); if (in_array($normalizedStatus, [self::STATUS_ONLINE, self::STATUS_DEGRADED, self::STATUS_OFFLINE], true)) { return $normalizedStatus; } return $normalizedStatus !== '' ? $normalizedStatus : 'UNKNOWN'; } private static function heartbeatAgeSeconds(?string $lastHeartbeatAt, ?int $now = null): ?int { if ($lastHeartbeatAt === null || trim($lastHeartbeatAt) === '') { return null; } $heartbeatTimestamp = strtotime($lastHeartbeatAt); if ($heartbeatTimestamp === false) { return null; } return max(0, ($now ?? time()) - $heartbeatTimestamp); } private static function heartbeatTimestamp(?string $lastHeartbeatAt): int { if ($lastHeartbeatAt === null || trim($lastHeartbeatAt) === '') { return 0; } $heartbeatTimestamp = strtotime($lastHeartbeatAt); return $heartbeatTimestamp === false ? 0 : $heartbeatTimestamp; } private static function statusPriority(string $status): int { return match ($status) { self::STATUS_ONLINE => 3, self::STATUS_DEGRADED => 2, self::STATUS_OFFLINE => 1, default => 0, }; } private function writeAudit(?int $gatewayId, ?int $departmentId, string $action, ?int $userId, array $context): void { (new edge_gateway_audit_logs_o())->add_object([ 'gateway_id' => $gatewayId, 'department_id' => $departmentId, 'action' => $action, 'actor_user_id' => $userId, 'actor_type' => $userId === null ? 'SYSTEM' : 'USER', 'severity' => 'INFO', 'context_json' => $context, ]); } private function hashToken(string $plainToken): string { return hash('sha256', $plainToken); } private function now(): string { return date('Y-m-d H:i:s'); } private function formatDateTime(int $timestamp): string { return date('Y-m-d H:i:s', $timestamp); } private function remoteIp(): ?string { $ip = trim((string)($_SERVER['REMOTE_ADDR'] ?? '')); return $ip !== '' ? $ip : null; } }