Files
api/services/nginx/app/modules/selfserve/classes/selfserve_virtual_hardware.php
T
Jeppe Bundgaard eeccacb2a7 Add unit tests for department lane dynamic image overrides and introduce classes for self-serve signal and virtual hardware management
- Add `DepartmentLaneDynamicImageRouteTest` to verify dynamic image preview handling for studio lanes.
- Introduce `selfserve_machine_signal` class to standardize signal normalization, recording, and gateway signal management workflows.
- Add `selfserve_virtual_hardware` class to handle virtual hardware configurations, including gateway and binding management.
- Enhance structure with auxiliary methods for payload normalization, workspace merging, and validation warnings.
2026-04-29 09:52:51 +02:00

785 lines
30 KiB
PHP

<?php
namespace modules\selfserve\classes;
require_once WD . '/classes/db.php';
require_once WD . '/classes/selfserve_schema_bootstrap.php';
use classes\db;
use classes\selfserve_schema_bootstrap;
class selfserve_virtual_hardware
{
public const SCHEMA_VERSION = 1;
public const DEFAULT_GATEWAY_KEY = 'virtual-main';
public function __construct()
{
selfserve_schema_bootstrap::ensureTables();
}
/**
* @return array<string,mixed>
*/
public function getConfig(int $departmentId): array
{
$pdo = db::getPDO();
$statement = $pdo->prepare(
'SELECT config_json
FROM department_selfserve_studio_virtual_hardware
WHERE department_id = :department_id AND deleted_at IS NULL
LIMIT 1'
);
$statement->execute([':department_id' => $departmentId]);
$row = $statement->fetch(\PDO::FETCH_ASSOC);
if (!is_array($row)) {
return $this->emptyConfig();
}
$decoded = json_decode((string)($row['config_json'] ?? '{}'), true);
return $this->normalizeConfig(is_array($decoded) ? $decoded : []);
}
/**
* @param array<string,mixed> $config
* @return array<string,mixed>
*/
public function saveConfig(int $departmentId, array $config, ?int $userId = null): array
{
$normalized = $this->normalizeConfig($config);
$json = json_encode($normalized, JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new \RuntimeException('Unable to encode virtual hardware config.');
}
$pdo = db::getPDO();
$statement = $pdo->prepare(
'INSERT INTO department_selfserve_studio_virtual_hardware
(department_id, config_json, created_by, updated_by, deleted_at)
VALUES
(:department_id, :config_json, :created_by, :updated_by, NULL)
ON DUPLICATE KEY UPDATE
config_json = VALUES(config_json),
updated_by = VALUES(updated_by),
deleted_at = NULL'
);
$statement->execute([
':department_id' => $departmentId,
':config_json' => $json,
':created_by' => $userId,
':updated_by' => $userId,
]);
return $normalized;
}
/**
* @param array<string,mixed> $payload
* @param array<string,mixed> $realWorkspace
* @return array<string,mixed>
*/
public function applyOperation(int $departmentId, string $operation, array $payload, ?int $userId, array $realWorkspace): array
{
$config = $this->getConfig($departmentId);
$operation = strtolower(trim($operation));
if ($operation === 'generate_from_lanes') {
$config = $this->generateFromLanes($realWorkspace, $config);
} elseif ($operation === 'upsert_gateway') {
$config = $this->upsertGateway($config, $payload);
} elseif ($operation === 'upsert_binding') {
$config = $this->upsertBinding($config, $payload, $realWorkspace);
} elseif ($operation === 'delete_binding') {
$config = $this->deleteBinding($config, $payload);
} elseif ($operation === 'reset') {
$config = $this->emptyConfig();
} else {
throw new \RuntimeException('Unsupported virtual hardware operation: ' . $operation);
}
return $this->saveConfig($departmentId, $config, $userId);
}
/**
* @param array<string,mixed> $workspace
* @return array<string,mixed>
*/
public function mergeWorkspace(array $workspace, int $departmentId): array
{
return $this->mergeWorkspaceWithConfig($workspace, $this->getConfig($departmentId));
}
/**
* Pure merge helper used by graph serialization and tests.
*
* @param array<string,mixed> $workspace
* @param array<string,mixed> $config
* @return array<string,mixed>
*/
public function mergeWorkspaceWithConfig(array $workspace, array $config): array
{
$config = $this->normalizeConfig($config);
$workspace += [
'gateways' => [],
'relays' => [],
'lanes' => [],
'issues' => [],
'actions' => [],
];
$virtualBindings = (array)($config['bindings'] ?? []);
$enabled = (bool)($config['enabled'] ?? true);
if (!$enabled || $virtualBindings === []) {
$workspace['virtual'] = $this->workspaceVirtualSummary($config, 0, 0);
return $workspace;
}
$realBindingRelayIds = $this->realBindingRelayIds((array)($workspace['gateways'] ?? []));
$virtualGateways = $this->virtualGatewaysForWorkspace($config);
$virtualRelays = $this->virtualRelaysForWorkspace($config);
$bindingsByRelayId = $this->indexVirtualBindingsByRelayId($virtualGateways);
$workspace['gateways'] = array_values(array_merge((array)($workspace['gateways'] ?? []), $virtualGateways));
$workspace['relays'] = $this->mergeRelays((array)($workspace['relays'] ?? []), $virtualRelays);
$workspace['lanes'] = $this->mergeLaneCoverage((array)($workspace['lanes'] ?? []), $bindingsByRelayId);
$coveredRelayIds = array_fill_keys(array_keys($bindingsByRelayId), true);
$workspace['issues'] = $this->mergeIssues((array)($workspace['issues'] ?? []), $coveredRelayIds, $virtualGateways);
$workspace['virtual'] = $this->workspaceVirtualSummary($config, count($virtualGateways), count($virtualBindings), $realBindingRelayIds);
$workspace['summary'] = $this->mergeSummary((array)($workspace['summary'] ?? []), $workspace);
return $workspace;
}
/**
* @param array<string,mixed> $workspace
* @return array<int,string>
*/
public function validationWarnings(array $workspace): array
{
$virtual = is_array($workspace['virtual'] ?? null) ? (array)$workspace['virtual'] : [];
if (($virtual['has_virtual_hardware'] ?? false) !== true) {
return [];
}
$warnings = [
'Studio uses virtual hardware coverage. Publishing is allowed, but live relay dispatch still requires a real edge gateway and real relay bindings.',
];
$virtualOnlyRelays = (array)($virtual['virtual_only_relay_ids'] ?? []);
if ($virtualOnlyRelays !== []) {
$warnings[] = 'Virtual coverage only for relay IDs: ' . implode(', ', $virtualOnlyRelays) . '.';
}
return $warnings;
}
/**
* @param array<string,mixed> $workspace
* @param array<string,mixed>|null $baseConfig
* @return array<string,mixed>
*/
public function generateFromLanes(array $workspace, ?array $baseConfig = null): array
{
$config = $this->normalizeConfig($baseConfig ?? $this->emptyConfig());
$gatewayKey = self::DEFAULT_GATEWAY_KEY;
$config = $this->upsertGateway($config, [
'key' => $gatewayKey,
'label' => 'Virtual Studio Gateway',
'status' => 'VIRTUAL',
]);
foreach ((array)($workspace['lanes'] ?? []) as $lane) {
if (!is_array($lane)) {
continue;
}
foreach ((array)($lane['relay_slots'] ?? []) as $slot) {
if (!is_array($slot)) {
continue;
}
$relayId = trim((string)($slot['relay_id'] ?? ''));
$role = $this->normalizeRole($slot['slot'] ?? $slot['role'] ?? '');
if ($relayId === '' || $role === '') {
continue;
}
$config = $this->upsertBinding($config, [
'gateway_key' => $gatewayKey,
'relay_id' => $relayId,
'role' => $role,
'services' => [$role],
'label' => trim((string)($lane['name'] ?? ('Lane ' . ($lane['id'] ?? '')))) . ' ' . $role,
'lane_id' => (int)($lane['id'] ?? 0),
'slot' => $role,
'generated' => true,
], $workspace);
}
}
return $config;
}
/**
* @return array<string,mixed>
*/
public function emptyConfig(): array
{
return [
'schema_version' => self::SCHEMA_VERSION,
'enabled' => true,
'gateways' => [],
'relays' => [],
'bindings' => [],
];
}
/**
* @param array<string,mixed> $config
* @return array<string,mixed>
*/
public function normalizeConfig(array $config): array
{
$normalized = $this->emptyConfig();
$normalized['schema_version'] = (int)($config['schema_version'] ?? self::SCHEMA_VERSION);
$normalized['enabled'] = filter_var($config['enabled'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
foreach ((array)($config['gateways'] ?? []) as $gateway) {
if (!is_array($gateway)) {
continue;
}
$key = $this->normalizeGatewayKey($gateway['key'] ?? $gateway['gateway_key'] ?? $gateway['id'] ?? '');
if ($key === '') {
continue;
}
$normalized['gateways'][$key] = [
'key' => $key,
'label' => trim((string)($gateway['label'] ?? ('Virtual Gateway ' . $key))),
'status' => strtoupper(trim((string)($gateway['status'] ?? 'VIRTUAL'))) ?: 'VIRTUAL',
'metadata' => is_array($gateway['metadata'] ?? null) ? (array)$gateway['metadata'] : [],
];
}
foreach ((array)($config['relays'] ?? []) as $relay) {
if (!is_array($relay)) {
continue;
}
$relayId = trim((string)($relay['relay_id'] ?? $relay['id'] ?? ''));
if ($relayId === '') {
continue;
}
$normalized['relays'][$relayId] = [
'relay_id' => $relayId,
'name' => trim((string)($relay['name'] ?? $relay['label'] ?? ('Virtual relay ' . $relayId))),
'status' => strtoupper(trim((string)($relay['status'] ?? 'VIRTUAL'))) ?: 'VIRTUAL',
'virtual' => true,
];
}
$bindings = [];
foreach ((array)($config['bindings'] ?? []) as $binding) {
if (!is_array($binding)) {
continue;
}
$gatewayKey = $this->normalizeGatewayKey($binding['gateway_key'] ?? $binding['gateway_id'] ?? self::DEFAULT_GATEWAY_KEY);
$relayId = trim((string)($binding['relay_id'] ?? ''));
$role = $this->normalizeRole($binding['role'] ?? $binding['slot'] ?? $binding['service'] ?? '');
if ($gatewayKey === '' || $relayId === '') {
continue;
}
if (!isset($normalized['gateways'][$gatewayKey])) {
$normalized['gateways'][$gatewayKey] = [
'key' => $gatewayKey,
'label' => 'Virtual Gateway ' . $gatewayKey,
'status' => 'VIRTUAL',
'metadata' => [],
];
}
if (!isset($normalized['relays'][$relayId])) {
$normalized['relays'][$relayId] = [
'relay_id' => $relayId,
'name' => trim((string)($binding['label'] ?? ('Virtual relay ' . $relayId))),
'status' => 'VIRTUAL',
'virtual' => true,
];
}
$services = $this->normalizeServiceList($binding['services'] ?? ($role !== '' ? [$role] : []));
if ($services === [] && $role !== '') {
$services = [$role];
}
$id = $this->bindingId($gatewayKey, $relayId, $role);
$bindings[$id] = [
'id' => $id,
'gateway_key' => $gatewayKey,
'relay_id' => $relayId,
'role' => $role,
'slot' => $role,
'services' => $services,
'label' => trim((string)($binding['label'] ?? ('Virtual ' . ($role ?: 'relay') . ' ' . $relayId))),
'channel' => array_key_exists('channel', $binding) ? (int)$binding['channel'] : 0,
'lane_id' => isset($binding['lane_id']) ? (int)$binding['lane_id'] : null,
'generated' => (bool)($binding['generated'] ?? false),
'virtual' => true,
];
}
$normalized['gateways'] = array_values($normalized['gateways']);
$normalized['relays'] = array_values($normalized['relays']);
$normalized['bindings'] = array_values($bindings);
return $normalized;
}
/**
* @param array<string,mixed> $config
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
private function upsertGateway(array $config, array $payload): array
{
$config = $this->normalizeConfig($config);
$key = $this->normalizeGatewayKey($payload['key'] ?? $payload['gateway_key'] ?? $payload['id'] ?? self::DEFAULT_GATEWAY_KEY);
if ($key === '') {
throw new \RuntimeException('Virtual gateway key is required.');
}
$gateways = [];
foreach ((array)$config['gateways'] as $gateway) {
$gateways[$this->normalizeGatewayKey($gateway['key'] ?? $gateway['id'] ?? '')] = $gateway;
}
$gateways[$key] = [
'key' => $key,
'label' => trim((string)($payload['label'] ?? $gateways[$key]['label'] ?? ('Virtual Gateway ' . $key))),
'status' => strtoupper(trim((string)($payload['status'] ?? $gateways[$key]['status'] ?? 'VIRTUAL'))) ?: 'VIRTUAL',
'metadata' => is_array($payload['metadata'] ?? null) ? (array)$payload['metadata'] : (array)($gateways[$key]['metadata'] ?? []),
];
$config['gateways'] = array_values($gateways);
return $this->normalizeConfig($config);
}
/**
* @param array<string,mixed> $config
* @param array<string,mixed> $payload
* @param array<string,mixed> $workspace
* @return array<string,mixed>
*/
private function upsertBinding(array $config, array $payload, array $workspace): array
{
$config = $this->normalizeConfig($config);
$gatewayKey = $this->normalizeGatewayKey($payload['gateway_key'] ?? $payload['gateway_id'] ?? self::DEFAULT_GATEWAY_KEY);
$relayId = trim((string)($payload['relay_id'] ?? ''));
$role = $this->normalizeRole($payload['role'] ?? $payload['slot'] ?? $payload['service'] ?? '');
if ($gatewayKey === '' || $relayId === '') {
throw new \RuntimeException('Virtual gateway key and relay id are required.');
}
$config = $this->upsertGateway($config, ['key' => $gatewayKey]);
$services = $this->normalizeServiceList($payload['services'] ?? ($role !== '' ? [$role] : []));
if ($services === [] && $role !== '') {
$services = [$role];
}
$binding = [
'id' => $this->bindingId($gatewayKey, $relayId, $role),
'gateway_key' => $gatewayKey,
'relay_id' => $relayId,
'role' => $role,
'slot' => $role,
'services' => $services,
'label' => trim((string)($payload['label'] ?? ('Virtual ' . ($role ?: 'relay') . ' ' . $relayId))),
'channel' => array_key_exists('channel', $payload) ? (int)$payload['channel'] : 0,
'lane_id' => isset($payload['lane_id']) ? (int)$payload['lane_id'] : $this->laneIdForRelay($workspace, $relayId),
'generated' => (bool)($payload['generated'] ?? false),
'virtual' => true,
];
$bindings = [];
foreach ((array)$config['bindings'] as $existing) {
if (!is_array($existing)) {
continue;
}
$bindings[(string)($existing['id'] ?? $this->bindingId((string)($existing['gateway_key'] ?? ''), (string)($existing['relay_id'] ?? ''), (string)($existing['role'] ?? '')))] = $existing;
}
$bindings[$binding['id']] = $binding;
$config['bindings'] = array_values($bindings);
$config['relays'][] = [
'relay_id' => $relayId,
'name' => trim((string)($payload['relay_label'] ?? $payload['label'] ?? ('Virtual relay ' . $relayId))),
'status' => 'VIRTUAL',
'virtual' => true,
];
return $this->normalizeConfig($config);
}
/**
* @param array<string,mixed> $config
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
private function deleteBinding(array $config, array $payload): array
{
$config = $this->normalizeConfig($config);
$id = trim((string)($payload['id'] ?? ''));
if ($id === '') {
$id = $this->bindingId(
$this->normalizeGatewayKey($payload['gateway_key'] ?? $payload['gateway_id'] ?? self::DEFAULT_GATEWAY_KEY),
trim((string)($payload['relay_id'] ?? '')),
$this->normalizeRole($payload['role'] ?? $payload['slot'] ?? '')
);
}
$config['bindings'] = array_values(array_filter((array)$config['bindings'], static function (array $binding) use ($id): bool {
return (string)($binding['id'] ?? '') !== $id;
}));
return $this->normalizeConfig($config);
}
/**
* @param array<string,mixed> $config
* @return array<int,array<string,mixed>>
*/
private function virtualGatewaysForWorkspace(array $config): array
{
$bindingsByGateway = [];
foreach ((array)($config['bindings'] ?? []) as $binding) {
if (!is_array($binding)) {
continue;
}
$gatewayKey = $this->normalizeGatewayKey($binding['gateway_key'] ?? self::DEFAULT_GATEWAY_KEY);
$bindingsByGateway[$gatewayKey][] = [
...$binding,
'gateway_id' => $gatewayKey,
'gateway_key' => $gatewayKey,
'node_id' => 'binding:' . $gatewayKey . ':' . (string)($binding['relay_id'] ?? '') . ':' . count($bindingsByGateway[$gatewayKey] ?? []),
'virtual' => true,
];
}
$gateways = [];
foreach ((array)($config['gateways'] ?? []) as $gateway) {
if (!is_array($gateway)) {
continue;
}
$key = $this->normalizeGatewayKey($gateway['key'] ?? self::DEFAULT_GATEWAY_KEY);
$bindings = array_values($bindingsByGateway[$key] ?? []);
if ($bindings === []) {
continue;
}
$gateways[] = [
'id' => $key,
'key' => $key,
'label' => (string)($gateway['label'] ?? ('Virtual Gateway ' . $key)),
'status' => (string)($gateway['status'] ?? 'VIRTUAL'),
'virtual' => true,
'studio_only' => true,
'bindings' => $bindings,
'metadata' => (array)($gateway['metadata'] ?? []),
];
}
return $gateways;
}
/**
* @param array<string,mixed> $config
* @return array<int,array<string,mixed>>
*/
private function virtualRelaysForWorkspace(array $config): array
{
return array_values(array_map(static function (array $relay): array {
return [
...$relay,
'virtual' => true,
'studio_only' => true,
];
}, (array)($config['relays'] ?? [])));
}
/**
* @param array<int,array<string,mixed>> $gateways
* @return array<string,array<int,array<string,mixed>>>
*/
private function indexVirtualBindingsByRelayId(array $gateways): array
{
$bindings = [];
foreach ($gateways as $gateway) {
foreach ((array)($gateway['bindings'] ?? []) as $binding) {
if (!is_array($binding)) {
continue;
}
$relayId = trim((string)($binding['relay_id'] ?? ''));
if ($relayId === '') {
continue;
}
$bindings[$relayId][] = [
...$binding,
'gateway_id' => (string)($gateway['id'] ?? ''),
'gateway_label' => (string)($gateway['label'] ?? 'Virtual Gateway'),
'gateway_status' => (string)($gateway['status'] ?? 'VIRTUAL'),
'virtual' => true,
'studio_only' => true,
];
}
}
return $bindings;
}
/**
* @param array<int,array<string,mixed>> $realRelays
* @param array<int,array<string,mixed>> $virtualRelays
* @return array<int,array<string,mixed>>
*/
private function mergeRelays(array $realRelays, array $virtualRelays): array
{
$rows = [];
foreach (array_merge($realRelays, $virtualRelays) as $relay) {
if (!is_array($relay)) {
continue;
}
$relayId = trim((string)($relay['relay_id'] ?? $relay['id'] ?? ''));
if ($relayId === '') {
continue;
}
$rows[$relayId . ':' . (!empty($relay['virtual']) ? 'virtual' : 'real')] = $relay;
}
return array_values($rows);
}
/**
* @param array<int,array<string,mixed>> $lanes
* @param array<string,array<int,array<string,mixed>>> $bindingsByRelayId
* @return array<int,array<string,mixed>>
*/
private function mergeLaneCoverage(array $lanes, array $bindingsByRelayId): array
{
foreach ($lanes as $laneIndex => $lane) {
if (!is_array($lane)) {
continue;
}
$required = 0;
$bound = 0;
foreach ((array)($lane['relay_slots'] ?? []) as $slotIndex => $slot) {
if (!is_array($slot)) {
continue;
}
$required += 1;
$relayId = trim((string)($slot['relay_id'] ?? ''));
$covered = (bool)($slot['coverage']['covered'] ?? false);
if (!$covered && $relayId !== '' && isset($bindingsByRelayId[$relayId])) {
$slot['coverage'] = [
'relay_id' => $relayId,
'covered' => true,
'status' => 'VIRTUAL',
'binding_count' => count($bindingsByRelayId[$relayId]),
'primary_binding' => $bindingsByRelayId[$relayId][0] ?? null,
'bindings' => $bindingsByRelayId[$relayId],
'virtual' => true,
'studio_only' => true,
];
$slot['virtual'] = true;
$covered = true;
}
if ($covered) {
$bound += 1;
}
$lane['relay_slots'][$slotIndex] = $slot;
}
$lane['binding_coverage'] = [
'required' => $required,
'bound' => $bound,
'missing' => max(0, $required - $bound),
'state' => $required === 0 ? 'NOT_REQUIRED' : ($bound === $required ? 'READY' : 'MISSING'),
'virtual' => $this->laneHasVirtualCoverage($lane),
];
$lanes[$laneIndex] = $lane;
}
return $lanes;
}
/**
* @param array<string,mixed> $lane
*/
private function laneHasVirtualCoverage(array $lane): bool
{
foreach ((array)($lane['relay_slots'] ?? []) as $slot) {
if (is_array($slot) && !empty($slot['coverage']['virtual'])) {
return true;
}
}
return false;
}
/**
* @param array<int,array<string,mixed>> $issues
* @param array<string,bool> $coveredRelayIds
* @param array<int,array<string,mixed>> $virtualGateways
* @return array<int,array<string,mixed>>
*/
private function mergeIssues(array $issues, array $coveredRelayIds, array $virtualGateways): array
{
$filtered = [];
foreach ($issues as $issue) {
if (!is_array($issue)) {
continue;
}
$code = strtoupper((string)($issue['code'] ?? ''));
if (in_array($code, ['LANE_BINDING_GAP', 'SCANNER_LANE_PARTIAL', 'SELFSERVE_PARTIAL_READY'], true)) {
$filtered[] = [
...$issue,
'severity' => 'info',
'virtual' => true,
'message' => (string)($issue['message'] ?? 'Relay coverage is incomplete.') . ' Studio virtual hardware covers this for dry runs only.',
];
continue;
}
if (in_array($code, ['NO_GATEWAY', 'NO_ONLINE_GATEWAY'], true) && $virtualGateways !== []) {
$filtered[] = [
...$issue,
'severity' => 'warning',
'virtual' => true,
'message' => (string)($issue['message'] ?? 'Real gateway is not ready.') . ' A virtual studio gateway is available for dry runs only.',
];
continue;
}
$filtered[] = $issue;
}
$filtered[] = [
'severity' => 'warning',
'code' => 'VIRTUAL_HARDWARE_ACTIVE',
'message' => 'Virtual studio hardware is active. It counts for studio validation and simulation only; live dispatch still requires real gateway bindings.',
'virtual' => true,
'relay_ids' => array_keys($coveredRelayIds),
];
return $filtered;
}
/**
* @param array<string,mixed> $summary
* @param array<string,mixed> $workspace
* @return array<string,mixed>
*/
private function mergeSummary(array $summary, array $workspace): array
{
$virtual = (array)($workspace['virtual'] ?? []);
$summary['virtual_gateway_count'] = (int)($virtual['gateway_count'] ?? 0);
$summary['virtual_binding_count'] = (int)($virtual['binding_count'] ?? 0);
$summary['has_virtual_hardware'] = (bool)($virtual['has_virtual_hardware'] ?? false);
return $summary;
}
/**
* @param array<string,mixed> $config
* @return array<string,mixed>
*/
/**
* @param array<string,bool> $realBindingRelayIds
*/
private function workspaceVirtualSummary(array $config, int $gatewayCount, int $bindingCount, array $realBindingRelayIds = []): array
{
$relayIds = [];
foreach ((array)($config['bindings'] ?? []) as $binding) {
if (is_array($binding) && trim((string)($binding['relay_id'] ?? '')) !== '') {
$relayId = trim((string)$binding['relay_id']);
if (!isset($realBindingRelayIds[$relayId])) {
$relayIds[] = $relayId;
}
}
}
return [
'schema_version' => (int)($config['schema_version'] ?? self::SCHEMA_VERSION),
'enabled' => (bool)($config['enabled'] ?? true),
'has_virtual_hardware' => $bindingCount > 0,
'gateway_count' => $gatewayCount,
'binding_count' => $bindingCount,
'virtual_only_relay_ids' => array_values(array_unique($relayIds)),
'config' => $config,
];
}
/**
* @param array<int,array<string,mixed>> $gateways
* @return array<string,bool>
*/
private function realBindingRelayIds(array $gateways): array
{
$relayIds = [];
foreach ($gateways as $gateway) {
if (!is_array($gateway) || !empty($gateway['virtual'])) {
continue;
}
foreach ((array)($gateway['bindings'] ?? []) as $binding) {
if (!is_array($binding)) {
continue;
}
$relayId = trim((string)($binding['relay_id'] ?? ''));
if ($relayId !== '') {
$relayIds[$relayId] = true;
}
}
}
return $relayIds;
}
/**
* @param array<string,mixed> $workspace
*/
private function laneIdForRelay(array $workspace, string $relayId): ?int
{
foreach ((array)($workspace['lanes'] ?? []) as $lane) {
if (!is_array($lane)) {
continue;
}
foreach ((array)($lane['relay_slots'] ?? []) as $slot) {
if (is_array($slot) && trim((string)($slot['relay_id'] ?? '')) === $relayId) {
return (int)($lane['id'] ?? 0) ?: null;
}
}
}
return null;
}
private function normalizeGatewayKey(mixed $value): string
{
return trim((string)$value);
}
private function normalizeRole(mixed $value): string
{
return strtoupper(trim((string)$value));
}
/**
* @return array<int,string>
*/
private function normalizeServiceList(mixed $value): array
{
if (is_string($value)) {
$decoded = json_decode($value, true);
$value = json_last_error() === JSON_ERROR_NONE && is_array($decoded) ? $decoded : explode(',', $value);
}
if (!is_array($value)) {
$value = [$value];
}
$services = [];
foreach ($value as $entry) {
if (is_array($entry)) {
continue;
}
$service = $this->normalizeRole($entry);
if ($service !== '') {
$services[$service] = true;
}
}
return array_keys($services);
}
private function bindingId(string $gatewayKey, string $relayId, string $role): string
{
return $gatewayKey . ':' . $relayId . ':' . ($role !== '' ? $role : 'relay');
}
}