Update relay-handling logic and test assertions for device binding and local IP resolution
- Correct test cases to ensure proper relay IDs are switched. - Add robust local IP resolution for relay-device bindings, including caching and inventory backfill. - Introduce fast-path options for relay status and switch dispatch. - Validate PHP extensions (`curl`, `sqlite3`) in edge agent images.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -9,7 +9,10 @@ use interfaces\shelly_transport_i;
|
||||
|
||||
class gateway_shelly_transport implements shelly_transport_i
|
||||
{
|
||||
public function __construct(private readonly ?edge_gateway_manager $manager = null)
|
||||
public function __construct(
|
||||
private readonly ?edge_gateway_manager $manager = null,
|
||||
private readonly bool $localOnly = false
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -52,7 +55,9 @@ class gateway_shelly_transport implements shelly_transport_i
|
||||
|
||||
$result = [];
|
||||
foreach ($ids as $logicalRelayId) {
|
||||
$status = $this->manager()->dispatchRelayStatus($departmentId, $logicalRelayId);
|
||||
$status = $this->localOnly
|
||||
? $this->manager()->dispatchRelayStatusLocalOnly($departmentId, $logicalRelayId)
|
||||
: $this->manager()->dispatchRelayStatus($departmentId, $logicalRelayId);
|
||||
$result[] = $this->normalizeRelayPayload($logicalRelayId, $status);
|
||||
}
|
||||
|
||||
@@ -69,11 +74,17 @@ class gateway_shelly_transport implements shelly_transport_i
|
||||
throw new Exception('Shelly gateway switch requests require an id');
|
||||
}
|
||||
|
||||
$status = $this->manager()->dispatchRelaySwitch(
|
||||
$departmentId,
|
||||
$logicalRelayId,
|
||||
(bool)($data['on'] ?? false)
|
||||
);
|
||||
$status = $this->localOnly
|
||||
? $this->manager()->dispatchRelaySwitchLocalOnly(
|
||||
$departmentId,
|
||||
$logicalRelayId,
|
||||
(bool)($data['on'] ?? false)
|
||||
)
|
||||
: $this->manager()->dispatchRelaySwitch(
|
||||
$departmentId,
|
||||
$logicalRelayId,
|
||||
(bool)($data['on'] ?? false)
|
||||
);
|
||||
|
||||
return [$this->normalizeRelayPayload($logicalRelayId, $status)];
|
||||
}
|
||||
|
||||
@@ -198,6 +198,7 @@ class shelly_relay_inventory
|
||||
$online = $this->normalizeBoolean($catalog_entry['cloud_online'] ?? null);
|
||||
}
|
||||
$status_color = $this->extractStatusColor($online);
|
||||
$local_ip = $this->extractLocalIp($device, $catalog_entry);
|
||||
|
||||
return [
|
||||
'id' => $device_id,
|
||||
@@ -215,6 +216,7 @@ class shelly_relay_inventory
|
||||
'code' => $device_code !== '' ? $device_code : null,
|
||||
'control_type' => $control_type,
|
||||
'control_name' => $control_name !== '' ? $control_name : null,
|
||||
'local_ip' => $local_ip,
|
||||
'status_color' => $status_color,
|
||||
'online' => $online,
|
||||
];
|
||||
@@ -352,6 +354,44 @@ class shelly_relay_inventory
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $device
|
||||
* @param array<string,mixed>|null $catalog_entry
|
||||
*/
|
||||
private function extractLocalIp(array $device, ?array $catalog_entry = null): ?string
|
||||
{
|
||||
$candidates = [
|
||||
$device['local_ip'] ?? null,
|
||||
$device['localIp'] ?? null,
|
||||
$device['ip'] ?? null,
|
||||
$device['_dev_info']['local_ip'] ?? null,
|
||||
$device['_dev_info']['localIp'] ?? null,
|
||||
$device['_dev_info']['ip'] ?? null,
|
||||
$device['wifi_sta']['ip'] ?? null,
|
||||
$device['wifi']['ip'] ?? null,
|
||||
$device['eth']['ip'] ?? null,
|
||||
$device['status']['wifi_sta']['ip'] ?? null,
|
||||
$device['status']['wifi']['ip'] ?? null,
|
||||
$device['status']['eth']['ip'] ?? null,
|
||||
$device['status']['sta_ip'] ?? null,
|
||||
$device['settings']['wifi_sta']['ip'] ?? null,
|
||||
$device['settings']['wifi']['ip'] ?? null,
|
||||
$device['settings']['eth']['ip'] ?? null,
|
||||
$catalog_entry['local_ip'] ?? null,
|
||||
$catalog_entry['localIp'] ?? null,
|
||||
$catalog_entry['ip'] ?? null,
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$normalized = trim((string)($candidate ?? ''));
|
||||
if ($normalized !== '' && filter_var($normalized, FILTER_VALIDATE_IP) !== false) {
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function buildRelayLabel(
|
||||
?string $device_type,
|
||||
string $cloud_name,
|
||||
|
||||
@@ -17,8 +17,22 @@ class shelly_transport_resolver
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolveForDepartment(int $departmentId): shelly_transport_i
|
||||
public function resolveForDepartment(int $departmentId, ?string $transportOverride = null): shelly_transport_i
|
||||
{
|
||||
$override = strtolower(trim((string)$transportOverride));
|
||||
$explicitLocalOverride = $override === 'local';
|
||||
if ($override === 'local') {
|
||||
$override = edge_gateway_manager::TRANSPORT_MODE_GATEWAY;
|
||||
}
|
||||
|
||||
if ($override === edge_gateway_manager::TRANSPORT_MODE_GATEWAY) {
|
||||
return $this->gatewayTransport ?? new gateway_shelly_transport($this->manager(), $explicitLocalOverride);
|
||||
}
|
||||
|
||||
if ($override === edge_gateway_manager::TRANSPORT_MODE_CLOUD) {
|
||||
return $this->cloudTransport ?? new cloud_shelly_transport();
|
||||
}
|
||||
|
||||
$mode = $this->manager()->getDepartmentTransportMode($departmentId);
|
||||
if ($mode === edge_gateway_manager::TRANSPORT_MODE_GATEWAY) {
|
||||
return $this->gatewayTransport ?? new gateway_shelly_transport($this->manager());
|
||||
|
||||
@@ -74,6 +74,8 @@ class edge_gateway_manager
|
||||
public const SHELL_SESSION_TTL_SECONDS = 900;
|
||||
public const DEVICE_FRESHNESS_DEGRADED_AFTER_SECONDS = 180;
|
||||
public const CREDENTIAL_FRESH_AFTER_SECONDS = 2592000;
|
||||
/** @var array<int,array<string,mixed>>|null */
|
||||
private ?array $shellyRelayOptionsCache = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -621,8 +623,15 @@ class edge_gateway_manager
|
||||
|
||||
if ($existing !== []) {
|
||||
$bindingObject = (new edge_gateway_relay_bindings_o())->select((int)$existing[0]['id']);
|
||||
$existingDeviceId = trim((string)$bindingObject->device_id->value());
|
||||
$localIp = $this->resolveRelayBindingLocalIp(
|
||||
$gatewayId,
|
||||
$binding,
|
||||
$deviceId,
|
||||
$existingDeviceId === $deviceId ? $bindingObject->local_ip->value() : null
|
||||
);
|
||||
$bindingObject->device_id->set($deviceId);
|
||||
$bindingObject->local_ip->set($binding['local_ip'] ?? null);
|
||||
$bindingObject->local_ip->set($localIp);
|
||||
$bindingObject->channel->set((int)($binding['channel'] ?? 0));
|
||||
$bindingObject->binding_source->set((string)($binding['binding_source'] ?? 'MANUAL'));
|
||||
$bindingObject->approved_by->set($userId);
|
||||
@@ -631,12 +640,13 @@ class edge_gateway_manager
|
||||
continue;
|
||||
}
|
||||
|
||||
$localIp = $this->resolveRelayBindingLocalIp($gatewayId, $binding, $deviceId);
|
||||
(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,
|
||||
'local_ip' => $localIp,
|
||||
'channel' => (int)($binding['channel'] ?? 0),
|
||||
'binding_source' => (string)($binding['binding_source'] ?? 'MANUAL'),
|
||||
'approved_by' => $userId,
|
||||
@@ -837,10 +847,33 @@ class edge_gateway_manager
|
||||
* @throws Exception
|
||||
*/
|
||||
public function dispatchRelayStatus(int $departmentId, string $logicalRelayId): array
|
||||
{
|
||||
return $this->dispatchRelayStatusWithOptions($departmentId, $logicalRelayId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function dispatchRelayStatusLocalOnly(int $departmentId, string $logicalRelayId): array
|
||||
{
|
||||
return $this->dispatchRelayStatusWithOptions($departmentId, $logicalRelayId, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function dispatchRelayStatusWithOptions(
|
||||
int $departmentId,
|
||||
string $logicalRelayId,
|
||||
bool $requireFastLocalPath = false
|
||||
): array
|
||||
{
|
||||
$binding = $this->resolveRelayBinding($departmentId, $logicalRelayId);
|
||||
$gateway = $this->requireGateway((int)$binding['gateway_id']);
|
||||
$resolution = $this->resolveRelayExecutionPlan($gateway, $binding, $logicalRelayId);
|
||||
if ($requireFastLocalPath) {
|
||||
$resolution = $this->forceLocalRelayExecutionPlan($resolution);
|
||||
}
|
||||
|
||||
if (($resolution['execution_path'] ?? 'local') === 'cloud') {
|
||||
return $this->dispatchRelayThroughCloud($departmentId, $logicalRelayId, null, $binding, $resolution);
|
||||
@@ -854,6 +887,7 @@ class edge_gateway_manager
|
||||
], null, [
|
||||
'preferred_channel' => (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API),
|
||||
'fallback_reason' => $resolution['reason'] ?? null,
|
||||
'require_fast_path' => $requireFastLocalPath,
|
||||
]);
|
||||
|
||||
try {
|
||||
@@ -875,10 +909,34 @@ class edge_gateway_manager
|
||||
* @throws Exception
|
||||
*/
|
||||
public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on): array
|
||||
{
|
||||
return $this->dispatchRelaySwitchWithOptions($departmentId, $logicalRelayId, $on);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function dispatchRelaySwitchLocalOnly(int $departmentId, string $logicalRelayId, bool $on): array
|
||||
{
|
||||
return $this->dispatchRelaySwitchWithOptions($departmentId, $logicalRelayId, $on, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function dispatchRelaySwitchWithOptions(
|
||||
int $departmentId,
|
||||
string $logicalRelayId,
|
||||
bool $on,
|
||||
bool $requireFastLocalPath = false
|
||||
): array
|
||||
{
|
||||
$binding = $this->resolveRelayBinding($departmentId, $logicalRelayId);
|
||||
$gateway = $this->requireGateway((int)$binding['gateway_id']);
|
||||
$resolution = $this->resolveRelayExecutionPlan($gateway, $binding, $logicalRelayId);
|
||||
if ($requireFastLocalPath) {
|
||||
$resolution = $this->forceLocalRelayExecutionPlan($resolution);
|
||||
}
|
||||
|
||||
if (($resolution['execution_path'] ?? 'local') === 'cloud') {
|
||||
return $this->dispatchRelayThroughCloud($departmentId, $logicalRelayId, $on, $binding, $resolution);
|
||||
@@ -893,6 +951,7 @@ class edge_gateway_manager
|
||||
], null, [
|
||||
'preferred_channel' => (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API),
|
||||
'fallback_reason' => $resolution['reason'] ?? null,
|
||||
'require_fast_path' => $requireFastLocalPath,
|
||||
]);
|
||||
|
||||
try {
|
||||
@@ -1870,16 +1929,43 @@ BASH;
|
||||
public function recordTelemetryFromBroker(int $gatewayId, array $payload): array
|
||||
{
|
||||
$gateway = $this->requireGateway($gatewayId);
|
||||
$now = $this->now();
|
||||
$metadata = array_merge(
|
||||
(array)($gateway->metadata_json->value() ?? []),
|
||||
isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
|
||||
);
|
||||
$existingPresence = isset($metadata['broker_presence']) && is_array($metadata['broker_presence'])
|
||||
? (array)$metadata['broker_presence']
|
||||
: [];
|
||||
$presenceMetadata = isset($existingPresence['metadata']) && is_array($existingPresence['metadata'])
|
||||
? (array)$existingPresence['metadata']
|
||||
: [];
|
||||
$payloadMetadata = isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : [];
|
||||
$connectionId = trim((string)($payload['broker_connection_id'] ?? $existingPresence['connection_id'] ?? ''));
|
||||
$presence = [
|
||||
'gateway_id' => $gatewayId,
|
||||
'connected' => true,
|
||||
'connection_id' => $connectionId !== '' ? $connectionId : null,
|
||||
'last_seen_at' => $now,
|
||||
'disconnect_reason' => null,
|
||||
'last_error' => null,
|
||||
'metadata' => array_merge($presenceMetadata, array_filter([
|
||||
'agent_instance_id' => $payload['broker_agent_instance_id']
|
||||
?? $payloadMetadata['agent_instance_id']
|
||||
?? null,
|
||||
], static fn(mixed $value): bool => $value !== null && $value !== '')),
|
||||
];
|
||||
$metadata['broker_presence'] = $presence;
|
||||
$metadata['broker_connected'] = true;
|
||||
$metadata['broker_connected_at'] = $now;
|
||||
$metadata['broker_last_error'] = null;
|
||||
$this->writeBrokerPresence($gatewayId, $presence);
|
||||
|
||||
$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_heartbeat_at->set($now);
|
||||
$gateway->metadata_json->set($metadata);
|
||||
|
||||
if (isset($payload['inventory']) && is_array($payload['inventory'])) {
|
||||
@@ -2834,6 +2920,145 @@ BASH;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveRelayBindingLocalIp(
|
||||
int $gatewayId,
|
||||
array $binding,
|
||||
string $deviceId,
|
||||
mixed $existingLocalIp = null
|
||||
): ?string {
|
||||
$incomingLocalIp = $this->normalizeLocalIp(
|
||||
$binding['local_ip']
|
||||
?? $binding['localIp']
|
||||
?? $binding['ip']
|
||||
?? $binding['metadata']['local_ip']
|
||||
?? null
|
||||
);
|
||||
if ($incomingLocalIp !== null) {
|
||||
return $incomingLocalIp;
|
||||
}
|
||||
|
||||
$preservedLocalIp = $this->normalizeLocalIp($existingLocalIp);
|
||||
if ($preservedLocalIp !== null) {
|
||||
return $preservedLocalIp;
|
||||
}
|
||||
|
||||
$inventoryLocalIp = $this->findInventoryLocalIp($gatewayId, $deviceId);
|
||||
if ($inventoryLocalIp !== null) {
|
||||
return $inventoryLocalIp;
|
||||
}
|
||||
|
||||
return $this->findShellyCloudRelayLocalIp(
|
||||
$deviceId,
|
||||
trim((string)($binding['relay_id'] ?? ''))
|
||||
);
|
||||
}
|
||||
|
||||
private function findInventoryLocalIp(int $gatewayId, string $deviceId): ?string
|
||||
{
|
||||
$rows = (new edge_gateway_device_inventory_o())->getFieldsWhere([
|
||||
'gateway_id' => $gatewayId,
|
||||
'device_id' => $deviceId,
|
||||
'deleted_at' => null,
|
||||
], ['id']);
|
||||
|
||||
if ($rows === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$inventoryObject = (new edge_gateway_device_inventory_o())->select((int)$rows[0]['id']);
|
||||
return $this->normalizeLocalIp($inventoryObject->local_ip->value());
|
||||
}
|
||||
|
||||
private function findShellyCloudRelayLocalIp(string $deviceId, string $relayId): ?string
|
||||
{
|
||||
foreach ($this->listShellyRelayOptionsForLocalIpLookup() as $option) {
|
||||
$optionDeviceId = trim((string)($option['device_id'] ?? ''));
|
||||
$optionRelayId = trim((string)($option['id'] ?? ''));
|
||||
if ($optionDeviceId !== $deviceId && $optionRelayId !== $relayId && $optionRelayId !== $deviceId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$localIp = $this->normalizeLocalIp(
|
||||
$option['local_ip']
|
||||
?? $option['localIp']
|
||||
?? $option['ip']
|
||||
?? null
|
||||
);
|
||||
if ($localIp !== null) {
|
||||
return $localIp;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private function listShellyRelayOptionsForLocalIpLookup(): array
|
||||
{
|
||||
if (is_array($this->shellyRelayOptionsCache)) {
|
||||
return $this->shellyRelayOptionsCache;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->shellyRelayOptionsCache = (new shelly_relay_inventory())->listRelayOptions();
|
||||
} catch (\Throwable) {
|
||||
$this->shellyRelayOptionsCache = [];
|
||||
}
|
||||
|
||||
return $this->shellyRelayOptionsCache;
|
||||
}
|
||||
|
||||
private function normalizeLocalIp(mixed $value): ?string
|
||||
{
|
||||
$localIp = trim((string)($value ?? ''));
|
||||
if ($localIp === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return filter_var($localIp, FILTER_VALIDATE_IP) !== false ? $localIp : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $device
|
||||
*/
|
||||
private function extractDeviceLocalIp(array $device): ?string
|
||||
{
|
||||
return $this->normalizeLocalIp(
|
||||
$device['local_ip']
|
||||
?? $device['localIp']
|
||||
?? $device['ip']
|
||||
?? $device['metadata']['local_ip']
|
||||
?? $device['metadata']['localIp']
|
||||
?? $device['metadata']['ip']
|
||||
?? null
|
||||
);
|
||||
}
|
||||
|
||||
private function backfillRelayBindingLocalIpFromInventory(int $gatewayId, string $deviceId, ?string $localIp): void
|
||||
{
|
||||
$localIp = $this->normalizeLocalIp($localIp);
|
||||
if ($localIp === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = (new edge_gateway_relay_bindings_o())->getFieldsWhere([
|
||||
'gateway_id' => $gatewayId,
|
||||
'device_id' => $deviceId,
|
||||
'deleted_at' => null,
|
||||
], ['id']);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$bindingObject = (new edge_gateway_relay_bindings_o())->select((int)$row['id']);
|
||||
if ($this->normalizeLocalIp($bindingObject->local_ip->value()) !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$bindingObject->local_ip->set($localIp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $inventory
|
||||
*/
|
||||
@@ -2845,6 +3070,7 @@ BASH;
|
||||
continue;
|
||||
}
|
||||
|
||||
$localIp = $this->extractDeviceLocalIp($device);
|
||||
$rows = (new edge_gateway_device_inventory_o())->getFieldsWhere([
|
||||
'gateway_id' => $gatewayId,
|
||||
'device_id' => $deviceId,
|
||||
@@ -2855,7 +3081,7 @@ BASH;
|
||||
(new edge_gateway_device_inventory_o())->add_object([
|
||||
'gateway_id' => $gatewayId,
|
||||
'device_id' => $deviceId,
|
||||
'local_ip' => $device['local_ip'] ?? $device['ip'] ?? null,
|
||||
'local_ip' => $localIp,
|
||||
'model' => $device['model'] ?? null,
|
||||
'channel_count' => (int)($device['channel_count'] ?? $device['channels'] ?? 1),
|
||||
'capabilities_json' => (array)($device['capabilities'] ?? []),
|
||||
@@ -2863,17 +3089,21 @@ BASH;
|
||||
'last_seen_at' => $this->now(),
|
||||
'metadata_json' => (array)($device['metadata'] ?? []),
|
||||
]);
|
||||
$this->backfillRelayBindingLocalIpFromInventory($gatewayId, $deviceId, $localIp);
|
||||
continue;
|
||||
}
|
||||
|
||||
$inventoryObject = (new edge_gateway_device_inventory_o())->select((int)$rows[0]['id']);
|
||||
$inventoryObject->local_ip->set($device['local_ip'] ?? $device['ip'] ?? null);
|
||||
if ($localIp !== null) {
|
||||
$inventoryObject->local_ip->set($localIp);
|
||||
}
|
||||
$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'] ?? []));
|
||||
$this->backfillRelayBindingLocalIpFromInventory($gatewayId, $deviceId, $localIp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2931,7 +3161,7 @@ BASH;
|
||||
$gateway = $this->requireGateway($gatewayId);
|
||||
$normalizedStatus = trim(strtolower($status));
|
||||
$connected = $normalizedStatus === 'connected';
|
||||
$presence = array_filter([
|
||||
$presence = [
|
||||
'gateway_id' => $gatewayId,
|
||||
'connected' => $connected,
|
||||
'connection_id' => $connectionId,
|
||||
@@ -2939,7 +3169,7 @@ BASH;
|
||||
'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);
|
||||
|
||||
@@ -3142,7 +3372,7 @@ BASH;
|
||||
}
|
||||
|
||||
$presence = $this->readBrokerPresence($gatewayId);
|
||||
return !empty($presence['connected']) ? self::DELIVERY_CHANNEL_BROKER : self::DELIVERY_CHANNEL_API;
|
||||
return self::isBrokerPresenceConnected($presence) ? self::DELIVERY_CHANNEL_BROKER : self::DELIVERY_CHANNEL_API;
|
||||
}
|
||||
|
||||
public function validateBrokerSharedSecret(?string $secret): bool
|
||||
@@ -3301,33 +3531,66 @@ BASH;
|
||||
]);
|
||||
}
|
||||
|
||||
private function forceLocalRelayExecutionPlan(array $resolution): array
|
||||
{
|
||||
$wasCloud = (string)($resolution['execution_path'] ?? 'local') === 'cloud';
|
||||
$recoveryActions = array_values(array_filter(array_unique(array_merge(
|
||||
(array)($resolution['recovery_actions'] ?? []),
|
||||
['retry_local_command']
|
||||
))));
|
||||
|
||||
return array_merge($resolution, [
|
||||
'execution_path' => 'local',
|
||||
'preferred_channel' => self::DELIVERY_CHANNEL_BROKER,
|
||||
'fallback_mode' => self::RELAY_FALLBACK_LOCAL_ONLY,
|
||||
'reason' => $wasCloud ? 'local_transport_override' : ($resolution['reason'] ?? null),
|
||||
'recommended_action' => $resolution['recommended_action'] ?? 'retry_local_command',
|
||||
'recovery_actions' => $recoveryActions,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function dispatchGatewayCommand(edge_gateways_o $gateway, edge_gateway_command_jobs_o $job): array
|
||||
{
|
||||
$delivery = (array)($job->delivery_json->value() ?? []);
|
||||
$preferredChannel = (string)($delivery['preferred_channel'] ?? self::DELIVERY_CHANNEL_API);
|
||||
$requireFastPath = !empty($delivery['require_fast_path']);
|
||||
|
||||
$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)) {
|
||||
if (!$requireFastPath && !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');
|
||||
if ($preferredChannel === self::DELIVERY_CHANNEL_BROKER) {
|
||||
if ($this->buildBrokerInternalUrl() === null) {
|
||||
$this->markCommandDeliveryFailure($job, self::DELIVERY_CHANNEL_BROKER, 'Edge broker is not configured', 'broker_not_configured');
|
||||
if ($requireFastPath) {
|
||||
throw new Exception('Edge broker is not configured');
|
||||
}
|
||||
} else {
|
||||
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');
|
||||
if ($requireFastPath) {
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($requireFastPath) {
|
||||
throw new Exception('Edge broker fast path is unavailable');
|
||||
}
|
||||
|
||||
return $this->waitForCommandResult((int)$job->id);
|
||||
}
|
||||
|
||||
@@ -3386,11 +3649,6 @@ BASH;
|
||||
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',
|
||||
[
|
||||
@@ -4101,9 +4359,9 @@ BASH;
|
||||
$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);
|
||||
$brokerConnected = self::isBrokerPresenceConnected($brokerPresence, $now);
|
||||
$brokerHealthy = $brokerConnected
|
||||
&& $brokerAgeSeconds !== null
|
||||
&& $brokerAgeSeconds < self::BROKER_CONNECTIVITY_DEGRADED_AFTER_SECONDS;
|
||||
@@ -4132,6 +4390,18 @@ BASH;
|
||||
];
|
||||
}
|
||||
|
||||
private static function isBrokerPresenceConnected(array $presence, ?int $now = null): bool
|
||||
{
|
||||
if (empty($presence['connected'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lastSeenAt = isset($presence['last_seen_at']) ? (string)$presence['last_seen_at'] : null;
|
||||
$ageSeconds = self::heartbeatAgeSeconds($lastSeenAt, $now);
|
||||
|
||||
return $ageSeconds !== null && $ageSeconds < self::BROKER_PRESENCE_TTL_SECONDS;
|
||||
}
|
||||
|
||||
private static function buildRelayHealth(array $gateway, string $effectiveStatus, ?int $now = null): array
|
||||
{
|
||||
$bindings = is_array($gateway['bindings'] ?? null) ? (array)$gateway['bindings'] : [];
|
||||
|
||||
@@ -32,8 +32,8 @@ trait selfserve_lane_port_controller_t
|
||||
if ($this->getLaneStatus()->equals(selfserve_lane_status::FAULT)) throw new \Exception("Cannot open port on FAULT lane");
|
||||
// Get the relay ID based on the port
|
||||
$relay_id = match ($port) {
|
||||
selfserve_lane_port::ENTRANCE => $this->department_lane->relay_out_id->value(),
|
||||
selfserve_lane_port::EXIT => $this->department_lane->relay_in_id->value(),
|
||||
selfserve_lane_port::ENTRANCE => $this->department_lane->relay_in_id->value(),
|
||||
selfserve_lane_port::EXIT => $this->department_lane->relay_out_id->value(),
|
||||
default => throw new \Exception("Invalid port specified, must be ENTRANCE or EXIT"),
|
||||
};
|
||||
// Make sure relay ID is valid
|
||||
@@ -63,8 +63,8 @@ trait selfserve_lane_port_controller_t
|
||||
{
|
||||
// Queue the relay switch command to be executed asynchronously
|
||||
$relay_id = match ($port) {
|
||||
selfserve_lane_port::ENTRANCE => $this->department_lane->relay_out_id->value(),
|
||||
selfserve_lane_port::EXIT => $this->department_lane->relay_in_id->value(),
|
||||
selfserve_lane_port::ENTRANCE => $this->department_lane->relay_in_id->value(),
|
||||
selfserve_lane_port::EXIT => $this->department_lane->relay_out_id->value(),
|
||||
default => throw new \Exception("Invalid port specified, must be ENTRANCE or EXIT"),
|
||||
};
|
||||
// If the relay ID indicates a demo port, skip the Shelly switch call but keep the logging and state changes
|
||||
|
||||
@@ -21,6 +21,27 @@ trait selfserve_lane_relay_controller_t
|
||||
private const SHELLY_DEFAULT_CHANNEL = 0;
|
||||
private const DEMO_RELAY_ID_PREFIX = 'demo-';
|
||||
|
||||
protected ?string $shelly_transport_override = null;
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function setShellyTransportOverride(?string $transport): self
|
||||
{
|
||||
$normalized = strtolower(trim((string)$transport));
|
||||
if ($normalized === '') {
|
||||
$this->shelly_transport_override = null;
|
||||
return $this;
|
||||
}
|
||||
|
||||
if (!in_array($normalized, ['cloud', 'gateway', 'local'], true)) {
|
||||
throw new \Exception('Invalid Shelly transport override. Expected local or cloud.');
|
||||
}
|
||||
|
||||
$this->shelly_transport_override = $normalized;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current MACHINE relay status from Shelly.
|
||||
* @return array{relay_id: string, online: bool, on: bool}
|
||||
@@ -622,7 +643,10 @@ trait selfserve_lane_relay_controller_t
|
||||
|
||||
private function getLaneShellyStatusSnapshotCacheKey(): string
|
||||
{
|
||||
return self::SHELLY_STATUS_SNAPSHOT_KEY_PREFIX . (int)$this->id;
|
||||
$transport = strtolower(trim((string)$this->shelly_transport_override));
|
||||
$safeTransport = $transport !== '' ? (preg_replace('/[^a-z0-9_-]+/', '_', $transport) ?: $transport) : '';
|
||||
$transportSuffix = $safeTransport !== '' ? '_' . $safeTransport : '';
|
||||
return self::SHELLY_STATUS_SNAPSHOT_KEY_PREFIX . (int)$this->id . $transportSuffix;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -695,7 +719,10 @@ trait selfserve_lane_relay_controller_t
|
||||
|
||||
protected function createShellyTransport(): shelly_transport_i
|
||||
{
|
||||
return (new shelly_transport_resolver())->resolveForDepartment($this->resolveShellyTransportDepartmentId());
|
||||
return (new shelly_transport_resolver())->resolveForDepartment(
|
||||
$this->resolveShellyTransportDepartmentId(),
|
||||
$this->shelly_transport_override
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,8 @@ FROM ${BASE_IMAGE}
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'
|
||||
|
||||
COPY auto-updater.php /usr/local/bin/auto-updater.php
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
ARG BASE_IMAGE=php:8.2-cli-bookworm
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN set -eux; \
|
||||
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'
|
||||
|
||||
WORKDIR /opt/truckwash-edge-agent
|
||||
|
||||
COPY agent.php /opt/truckwash-edge-agent/agent.php
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
ARG BASE_IMAGE=php:8.2-cli-bookworm
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN set -eux; \
|
||||
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'
|
||||
|
||||
WORKDIR /opt/truckwash-edge-agent
|
||||
|
||||
COPY lan-worker.php /opt/truckwash-edge-agent/lan-worker.php
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
[Unit]
|
||||
Description=TruckWash Edge Agent
|
||||
After=network.target
|
||||
Description=TruckWash Edge Agent Compatibility Unit
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=/opt/truckwash-edge-agent
|
||||
ExecStart=/usr/bin/php /opt/truckwash-edge-agent/agent.php --config /opt/truckwash-edge-agent/config.json
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
User=root
|
||||
ExecStart=/opt/truckwash-edge-agent/gateway-launcher.sh up
|
||||
ExecReload=/opt/truckwash-edge-agent/gateway-launcher.sh reconcile
|
||||
ExecStop=/opt/truckwash-edge-agent/gateway-launcher.sh down
|
||||
TimeoutStartSec=900
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -441,12 +441,14 @@ class moduleSelfServeRoute
|
||||
|
||||
$lane = $selfserve->lane($lane_id);
|
||||
try {
|
||||
$this->applyShellyTransportOverride($lane);
|
||||
$lane->open($gate);
|
||||
$response->success([
|
||||
'lane_id' => $lane_id,
|
||||
'gate' => $gate->name,
|
||||
'opened' => true,
|
||||
'state' => $lane->getLaneState()->name,
|
||||
'transport' => $this->requestedShellyTransportOverride(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
error_log('Failed to open self-serve lane gate ' . $gate->name . ' for lane ' . $lane_id . ': ' . $e->getMessage());
|
||||
@@ -468,6 +470,7 @@ class moduleSelfServeRoute
|
||||
$lane = $selfserve->lane($lane_id);
|
||||
|
||||
try {
|
||||
$this->applyShellyTransportOverride($lane);
|
||||
$status = $lane->getMachineProgramPickerRelayStatus();
|
||||
$response->success([
|
||||
'lane_id' => $lane_id,
|
||||
@@ -475,6 +478,7 @@ class moduleSelfServeRoute
|
||||
'relay_id' => (string)$status['relay_id'],
|
||||
'online' => (bool)$status['online'],
|
||||
'on' => (bool)$status['on'],
|
||||
'transport' => $this->requestedShellyTransportOverride(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$response->error('Failed to get MACHINE_PROGRAM_PICKER relay status: ' . $e->getMessage(), 400);
|
||||
@@ -501,6 +505,7 @@ class moduleSelfServeRoute
|
||||
|
||||
$lane = $selfserve->lane($lane_id);
|
||||
try {
|
||||
$this->applyShellyTransportOverride($lane);
|
||||
$lane->setMachineProgramPickerRelayStatus((bool)$on);
|
||||
$status = $lane->getMachineProgramPickerRelayStatus();
|
||||
$response->success([
|
||||
@@ -510,6 +515,7 @@ class moduleSelfServeRoute
|
||||
'relay_id' => (string)$status['relay_id'],
|
||||
'online' => (bool)$status['online'],
|
||||
'on' => (bool)$status['on'],
|
||||
'transport' => $this->requestedShellyTransportOverride(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$response->error('Failed to set MACHINE_PROGRAM_PICKER relay status: ' . $e->getMessage(), 400);
|
||||
@@ -530,6 +536,7 @@ class moduleSelfServeRoute
|
||||
$lane = $selfserve->lane($lane_id);
|
||||
|
||||
try {
|
||||
$this->applyShellyTransportOverride($lane);
|
||||
$status = $lane->getMachineCleanerRelayStatus();
|
||||
$response->success([
|
||||
'lane_id' => $lane_id,
|
||||
@@ -537,6 +544,7 @@ class moduleSelfServeRoute
|
||||
'relay_id' => (string)$status['relay_id'],
|
||||
'online' => (bool)$status['online'],
|
||||
'on' => (bool)$status['on'],
|
||||
'transport' => $this->requestedShellyTransportOverride(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$response->error('Failed to get MACHINE_CLEANER relay status: ' . $e->getMessage(), 400);
|
||||
@@ -563,6 +571,7 @@ class moduleSelfServeRoute
|
||||
|
||||
$lane = $selfserve->lane($lane_id);
|
||||
try {
|
||||
$this->applyShellyTransportOverride($lane);
|
||||
$lane->setMachineCleanerRelayStatus((bool)$on);
|
||||
$status = $lane->getMachineCleanerRelayStatus();
|
||||
$response->success([
|
||||
@@ -572,6 +581,7 @@ class moduleSelfServeRoute
|
||||
'relay_id' => (string)$status['relay_id'],
|
||||
'online' => (bool)$status['online'],
|
||||
'on' => (bool)$status['on'],
|
||||
'transport' => $this->requestedShellyTransportOverride(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$response->error('Failed to set MACHINE_CLEANER relay status: ' . $e->getMessage(), 400);
|
||||
@@ -592,6 +602,7 @@ class moduleSelfServeRoute
|
||||
$lane = $selfserve->lane($lane_id);
|
||||
|
||||
try {
|
||||
$this->applyShellyTransportOverride($lane);
|
||||
$status = $lane->getMachineRelayStatus();
|
||||
$response->success([
|
||||
'lane_id' => $lane_id,
|
||||
@@ -599,6 +610,7 @@ class moduleSelfServeRoute
|
||||
'relay_id' => (string)$status['relay_id'],
|
||||
'online' => (bool)$status['online'],
|
||||
'on' => (bool)$status['on'],
|
||||
'transport' => $this->requestedShellyTransportOverride(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$response->error('Failed to get MACHINE relay status: ' . $e->getMessage(), 400);
|
||||
@@ -625,6 +637,7 @@ class moduleSelfServeRoute
|
||||
|
||||
$lane = $selfserve->lane($lane_id);
|
||||
try {
|
||||
$this->applyShellyTransportOverride($lane);
|
||||
$lane->setMachineRelayStatus((bool)$on);
|
||||
// Keep lane cache state aligned with the latest explicit relay action
|
||||
try {
|
||||
@@ -651,6 +664,7 @@ class moduleSelfServeRoute
|
||||
'relay_id' => (string)$status['relay_id'],
|
||||
'online' => (bool)$status['online'],
|
||||
'on' => (bool)$status['on'],
|
||||
'transport' => $this->requestedShellyTransportOverride(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$response->error('Failed to set MACHINE relay status: ' . $e->getMessage(), 400);
|
||||
@@ -984,4 +998,47 @@ class moduleSelfServeRoute
|
||||
'modules_selfserve_lane_force_machine_disable' => 'Force disable MACHINE relay but keep lane as in-wash (superusers only)'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function applyShellyTransportOverride(object $lane): void
|
||||
{
|
||||
$transport = $this->requestedShellyTransportOverride();
|
||||
if ($transport === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!method_exists($lane, 'setShellyTransportOverride')) {
|
||||
throw new \Exception('This lane does not support Shelly transport overrides.');
|
||||
}
|
||||
|
||||
$lane->setShellyTransportOverride($transport);
|
||||
}
|
||||
|
||||
private function requestedShellyTransportOverride(): ?string
|
||||
{
|
||||
$transport = null;
|
||||
foreach (['transport', 'test_transport', 'transport_mode'] as $parameter) {
|
||||
if (self::isParametersSet([$parameter])) {
|
||||
$transport = self::getParameter($parameter);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$normalized = strtolower(trim((string)$transport));
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($normalized === 'gateway') {
|
||||
return 'local';
|
||||
}
|
||||
|
||||
if (!in_array($normalized, ['local', 'cloud'], true)) {
|
||||
throw new \InvalidArgumentException('Invalid transport. Expected local or cloud.');
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,18 @@ it('keeps relay dispatch and discovery queueing on the edge gateway manager', fu
|
||||
expect($managerSource)->not->toBeFalse();
|
||||
expect($managerSource)->toContain('public function queueDiscovery');
|
||||
expect($managerSource)->toContain('public function dispatchRelayStatus');
|
||||
expect($managerSource)->toContain('public function dispatchRelayStatusLocalOnly');
|
||||
expect($managerSource)->toContain('public function dispatchRelaySwitch');
|
||||
expect($managerSource)->toContain('public function dispatchRelaySwitchLocalOnly');
|
||||
expect($managerSource)->toContain('private function createCommandJob');
|
||||
expect($managerSource)->toContain("'require_fast_path' => \$requireFastLocalPath");
|
||||
expect($managerSource)->toContain('local_transport_override');
|
||||
expect($managerSource)->toContain('private function resolveRelayBindingLocalIp');
|
||||
expect($managerSource)->toContain('private function findShellyCloudRelayLocalIp');
|
||||
expect($managerSource)->toContain('private function backfillRelayBindingLocalIpFromInventory');
|
||||
expect($managerSource)->toContain('$bindingObject->local_ip->set($localIp);');
|
||||
expect($managerSource)->toContain("'local_ip' => \$localIp");
|
||||
expect($managerSource)->toContain('(new shelly_relay_inventory())->listRelayOptions()');
|
||||
expect($managerSource)->toContain('public function buildUpdateOperationRequest');
|
||||
expect($managerSource)->toContain('public function rotateGatewayCredentials');
|
||||
expect($operationServiceSource)->toContain('public function queueOperation');
|
||||
@@ -38,11 +48,17 @@ it('loads relay command helpers on the manager and gateway operations on the ded
|
||||
expect($reflection->hasMethod('pollCommand'))->toBeTrue();
|
||||
expect($reflection->hasMethod('submitCommandResult'))->toBeTrue();
|
||||
expect($reflection->hasMethod('dispatchRelayStatus'))->toBeTrue();
|
||||
expect($reflection->hasMethod('dispatchRelayStatusLocalOnly'))->toBeTrue();
|
||||
expect($reflection->hasMethod('dispatchRelaySwitch'))->toBeTrue();
|
||||
expect($reflection->hasMethod('dispatchRelaySwitchLocalOnly'))->toBeTrue();
|
||||
expect($reflection->hasMethod('claimNextCommandJob'))->toBeTrue();
|
||||
expect($reflection->getMethod('claimNextCommandJob')->isPrivate())->toBeTrue();
|
||||
expect($reflection->hasMethod('syncDeviceInventory'))->toBeTrue();
|
||||
expect($reflection->getMethod('syncDeviceInventory')->isPrivate())->toBeTrue();
|
||||
expect($reflection->hasMethod('resolveRelayBindingLocalIp'))->toBeTrue();
|
||||
expect($reflection->getMethod('resolveRelayBindingLocalIp')->isPrivate())->toBeTrue();
|
||||
expect($reflection->hasMethod('backfillRelayBindingLocalIpFromInventory'))->toBeTrue();
|
||||
expect($reflection->getMethod('backfillRelayBindingLocalIpFromInventory')->isPrivate())->toBeTrue();
|
||||
expect($reflection->hasMethod('syncGatewayInventory'))->toBeTrue();
|
||||
expect($operationServiceReflection->hasMethod('queueOperation'))->toBeTrue();
|
||||
expect($operationServiceReflection->hasMethod('cancelOperation'))->toBeTrue();
|
||||
|
||||
@@ -72,6 +72,38 @@ it('marks ready discovery as stale when the gateway heartbeat has expired', func
|
||||
expect($gateway['discovery_status'])->toBe('STALE');
|
||||
});
|
||||
|
||||
it('only trusts broker presence while the broker heartbeat is fresh', function (): void {
|
||||
$recent = edge_gateway_manager::deriveGatewayRuntimeState([
|
||||
'status' => edge_gateway_manager::STATUS_ONLINE,
|
||||
'last_heartbeat_at' => '2026-04-08 10:04:50',
|
||||
'metadata' => [
|
||||
'broker_presence' => [
|
||||
'connected' => true,
|
||||
'last_seen_at' => '2026-04-08 10:04:30',
|
||||
],
|
||||
],
|
||||
], edge_gateway_heartbeat_test_now('2026-04-08 10:05:00'));
|
||||
|
||||
expect($recent['channel_status']['broker']['connected'])->toBeTrue();
|
||||
expect($recent['channel_status']['broker']['state'])->toBe(edge_gateway_manager::STATUS_ONLINE);
|
||||
expect($recent['channel_status']['command']['preferred'])->toBe(edge_gateway_manager::DELIVERY_CHANNEL_BROKER);
|
||||
|
||||
$stale = edge_gateway_manager::deriveGatewayRuntimeState([
|
||||
'status' => edge_gateway_manager::STATUS_ONLINE,
|
||||
'last_heartbeat_at' => '2026-04-08 10:04:50',
|
||||
'metadata' => [
|
||||
'broker_presence' => [
|
||||
'connected' => true,
|
||||
'last_seen_at' => '2026-04-08 10:03:00',
|
||||
],
|
||||
],
|
||||
], edge_gateway_heartbeat_test_now('2026-04-08 10:05:00'));
|
||||
|
||||
expect($stale['channel_status']['broker']['connected'])->toBeFalse();
|
||||
expect($stale['channel_status']['broker']['state'])->toBe(edge_gateway_manager::STATUS_OFFLINE);
|
||||
expect($stale['channel_status']['command']['preferred'])->toBe(edge_gateway_manager::DELIVERY_CHANNEL_API);
|
||||
});
|
||||
|
||||
it('merges incoming heartbeat metadata with existing gateway metadata', function (): void {
|
||||
$source = file_get_contents(app_path('classes/edge_gateway_manager.php'));
|
||||
|
||||
@@ -79,6 +111,15 @@ it('merges incoming heartbeat metadata with existing gateway metadata', function
|
||||
expect($source)->toContain("\$gateway->metadata_json->set(array_merge(\$existingMetadata, (array)(\$payload['metadata'] ?? [])));");
|
||||
});
|
||||
|
||||
it('refreshes broker presence from broker telemetry heartbeats', function (): void {
|
||||
$source = file_get_contents(app_path('classes/edge_gateway_manager.php'));
|
||||
|
||||
expect($source)->toContain('public function recordTelemetryFromBroker');
|
||||
expect($source)->toContain("'broker_connection_id'");
|
||||
expect($source)->toContain('$this->writeBrokerPresence($gatewayId, $presence);');
|
||||
expect($source)->toContain('private static function isBrokerPresenceConnected');
|
||||
});
|
||||
|
||||
it('derives relay fallback and transport health details without shell or update runtime state', function (): void {
|
||||
$gateway = edge_gateway_manager::deriveGatewayRuntimeState([
|
||||
'status' => edge_gateway_manager::STATUS_ONLINE,
|
||||
|
||||
@@ -50,7 +50,11 @@ it('builds the installer around the compose stack artifacts and management polli
|
||||
expect($managerSource)->toContain('journalctl -u truckwash-edge-gateway-stack.service -n 60 --no-pager || true');
|
||||
expect($managerSource)->not->toContain('agent.mjs');
|
||||
expect($managerSource)->toContain("'brokerUrl' => \$this->buildBrokerPublicUrl()");
|
||||
expect($serviceSource)->toContain('ExecStart=/usr/bin/php /opt/truckwash-edge-agent/agent.php --config /opt/truckwash-edge-agent/config.json');
|
||||
expect($serviceSource)->toContain('Description=TruckWash Edge Agent Compatibility Unit');
|
||||
expect($serviceSource)->toContain('ExecStart=/opt/truckwash-edge-agent/gateway-launcher.sh up');
|
||||
expect($serviceSource)->toContain('ExecReload=/opt/truckwash-edge-agent/gateway-launcher.sh reconcile');
|
||||
expect($serviceSource)->toContain('ExecStop=/opt/truckwash-edge-agent/gateway-launcher.sh down');
|
||||
expect($serviceSource)->not->toContain('/usr/bin/php /opt/truckwash-edge-agent/agent.php');
|
||||
expect($stackServiceSource)->toContain('ExecStart=/opt/truckwash-edge-agent/gateway-launcher.sh up');
|
||||
expect($stackServiceSource)->toContain('TimeoutStartSec=900');
|
||||
expect($launcherSource)->toContain('STACK_HEALTHCHECK_TIMEOUT_SECONDS="${STACK_HEALTHCHECK_TIMEOUT_SECONDS:-300}"');
|
||||
@@ -92,14 +96,17 @@ it('builds the installer around the compose stack artifacts and management polli
|
||||
expect($agentSource)->toContain("'last_transport_error'");
|
||||
expect($agentSource)->toContain('private function recordTransportFailure(string $context, Throwable $throwable): void');
|
||||
expect($edgeDockerfileSource)->toContain('FROM ${BASE_IMAGE}');
|
||||
expect($edgeDockerfileSource)->toContain('extension_loaded($extension)');
|
||||
expect($edgeDockerfileSource)->toContain('Missing PHP extension: {$extension}');
|
||||
expect($edgeDockerfileSource)->toContain('COPY agent.php /opt/truckwash-edge-agent/agent.php');
|
||||
expect($edgeDockerfileSource)->not->toContain('docker-php-ext-install');
|
||||
expect($workerDockerfileSource)->toContain('FROM ${BASE_IMAGE}');
|
||||
expect($workerDockerfileSource)->toContain('extension_loaded($extension)');
|
||||
expect($workerDockerfileSource)->toContain('Missing PHP extension: {$extension}');
|
||||
expect($workerDockerfileSource)->toContain('COPY lan-worker.php /opt/truckwash-edge-agent/lan-worker.php');
|
||||
expect($workerDockerfileSource)->not->toContain('docker-php-ext-install');
|
||||
expect($autoUpdaterSource)->toContain("'/bin/bash ' . escapeshellarg(\$launcherPath) . ' reconcile 2>&1'");
|
||||
expect($autoUpdaterDockerfileSource)->toContain('COPY auto-updater.php /usr/local/bin/auto-updater.php');
|
||||
expect($autoUpdaterDockerfileSource)->toContain('apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose;');
|
||||
expect($autoUpdaterDockerfileSource)->toContain('extension_loaded($extension)');
|
||||
expect($serviceSource)->not->toContain('node /opt/truckwash-edge-agent/agent.mjs');
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ class GatewayShellyTransportManagerFake extends edge_gateway_manager
|
||||
public array $statusCalls = [];
|
||||
/** @var array<int,array<string,mixed>> */
|
||||
public array $switchCalls = [];
|
||||
/** @var array<int,array<string,mixed>> */
|
||||
public array $localOnlyStatusCalls = [];
|
||||
/** @var array<int,array<string,mixed>> */
|
||||
public array $localOnlySwitchCalls = [];
|
||||
public ?Exception $statusException = null;
|
||||
public ?Exception $switchException = null;
|
||||
|
||||
@@ -55,6 +59,35 @@ class GatewayShellyTransportManagerFake extends edge_gateway_manager
|
||||
'raw' => ['source' => 'switch'],
|
||||
];
|
||||
}
|
||||
|
||||
public function dispatchRelayStatusLocalOnly(int $departmentId, string $logicalRelayId): array
|
||||
{
|
||||
$this->localOnlyStatusCalls[] = [
|
||||
'department_id' => $departmentId,
|
||||
'relay_id' => $logicalRelayId,
|
||||
];
|
||||
|
||||
return [
|
||||
'online' => true,
|
||||
'on' => true,
|
||||
'raw' => ['source' => 'local-status'],
|
||||
];
|
||||
}
|
||||
|
||||
public function dispatchRelaySwitchLocalOnly(int $departmentId, string $logicalRelayId, bool $on): array
|
||||
{
|
||||
$this->localOnlySwitchCalls[] = [
|
||||
'department_id' => $departmentId,
|
||||
'relay_id' => $logicalRelayId,
|
||||
'on' => $on,
|
||||
];
|
||||
|
||||
return [
|
||||
'online' => true,
|
||||
'on' => $on,
|
||||
'raw' => ['source' => 'local-switch'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
it('maps gateway relay status responses into the Shelly cloud payload shape', function (): void {
|
||||
@@ -92,6 +125,30 @@ it('maps gateway relay switch responses into the Shelly cloud payload shape', fu
|
||||
expect($result[0]['status']['switch:0']['output'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('uses local-only gateway dispatch for explicit local transport overrides', function (): void {
|
||||
$manager = new GatewayShellyTransportManagerFake();
|
||||
$transport = new gateway_shelly_transport($manager, true);
|
||||
|
||||
$status = $transport->sendPostRequest('/v2/devices/api/get', [
|
||||
'ids' => ['relay-entry'],
|
||||
], 17);
|
||||
$switch = $transport->sendPostRequest('/v2/devices/api/set/switch', [
|
||||
'id' => 'relay-entry',
|
||||
'on' => true,
|
||||
], 17);
|
||||
|
||||
expect($manager->localOnlyStatusCalls)->toBe([
|
||||
['department_id' => 17, 'relay_id' => 'relay-entry'],
|
||||
]);
|
||||
expect($manager->localOnlySwitchCalls)->toBe([
|
||||
['department_id' => 17, 'relay_id' => 'relay-entry', 'on' => true],
|
||||
]);
|
||||
expect($manager->statusCalls)->toBe([]);
|
||||
expect($manager->switchCalls)->toBe([]);
|
||||
expect($status[0]['raw']['source'])->toBe('local-status');
|
||||
expect($switch[0]['raw']['source'])->toBe('local-switch');
|
||||
});
|
||||
|
||||
it('requires a valid department id for gateway transport requests', function (): void {
|
||||
$transport = new gateway_shelly_transport(new GatewayShellyTransportManagerFake());
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ class SelfserveLanePortControllerHarness
|
||||
public function logLaneAction(...$args): void {}
|
||||
}
|
||||
|
||||
it('opens exit port by switching relay_in_id on', function (): void {
|
||||
it('opens exit port by switching relay_out_id on', function (): void {
|
||||
$lane = new SelfserveLanePortControllerHarness(
|
||||
relayInId: 'relay-in-123',
|
||||
relayOutId: 'relay-out-123'
|
||||
@@ -108,13 +108,13 @@ it('opens exit port by switching relay_in_id on', function (): void {
|
||||
expect($result)->toBeTrue();
|
||||
expect($lane->switchFake->switchCalls)->toBe([
|
||||
[
|
||||
'id' => 'relay-in-123',
|
||||
'id' => 'relay-out-123',
|
||||
'on' => true,
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('opens entrance port by switching relay_out_id on', function (): void {
|
||||
it('opens entrance port by switching relay_in_id on', function (): void {
|
||||
$lane = new SelfserveLanePortControllerHarness(
|
||||
relayInId: 'relay-in-123',
|
||||
relayOutId: 'relay-out-123'
|
||||
@@ -125,7 +125,7 @@ it('opens entrance port by switching relay_out_id on', function (): void {
|
||||
expect($result)->toBeTrue();
|
||||
expect($lane->switchFake->switchCalls)->toBe([
|
||||
[
|
||||
'id' => 'relay-out-123',
|
||||
'id' => 'relay-in-123',
|
||||
'on' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -84,6 +84,8 @@ it('wires machine relay status get and set endpoints', function (): void {
|
||||
expect($moduleSelfServeRoute)->toContain('getMachineCleanerRelayStatus');
|
||||
expect($moduleSelfServeRoute)->toContain('setMachineCleanerRelayStatus');
|
||||
expect($moduleSelfServeRoute)->toContain('setMachineCleanerRelayStatusHard(true)');
|
||||
expect($moduleSelfServeRoute)->toContain('applyShellyTransportOverride($lane)');
|
||||
expect($moduleSelfServeRoute)->toContain("'transport' => \$this->requestedShellyTransportOverride()");
|
||||
});
|
||||
|
||||
it('wires allowed services route through machine relay visibility sync', function (): void {
|
||||
|
||||
@@ -19,6 +19,9 @@ it('normalizes owned Shelly devices into relay select options', function (): voi
|
||||
'online' => 1,
|
||||
],
|
||||
'name' => 'Entry Gate',
|
||||
'wifi_sta' => [
|
||||
'ip' => '10.32.0.11',
|
||||
],
|
||||
'status' => [
|
||||
'switch:0' => [
|
||||
'output' => false,
|
||||
@@ -33,6 +36,11 @@ it('normalizes owned Shelly devices into relay select options', function (): voi
|
||||
'model' => 'Shelly Pro 2PM',
|
||||
],
|
||||
'name' => 'Machine Cabinet',
|
||||
'status' => [
|
||||
'eth' => [
|
||||
'ip' => '10.32.0.12',
|
||||
],
|
||||
],
|
||||
'relays' => [
|
||||
['ison' => false, 'name' => 'Program Picker'],
|
||||
],
|
||||
@@ -96,6 +104,7 @@ it('normalizes owned Shelly devices into relay select options', function (): voi
|
||||
'code' => 'SPSW-001PE16EU',
|
||||
'control_type' => 'Switch',
|
||||
'control_name' => 'Entrance Relay',
|
||||
'local_ip' => '10.32.0.11',
|
||||
'status_color' => 'Green',
|
||||
'online' => true,
|
||||
],
|
||||
@@ -109,6 +118,7 @@ it('normalizes owned Shelly devices into relay select options', function (): voi
|
||||
'code' => 'SPSW-201XE16EU',
|
||||
'control_type' => 'Relay',
|
||||
'control_name' => 'Program Picker',
|
||||
'local_ip' => '10.32.0.12',
|
||||
'status_color' => 'Red',
|
||||
'online' => false,
|
||||
],
|
||||
@@ -122,6 +132,7 @@ it('normalizes owned Shelly devices into relay select options', function (): voi
|
||||
'code' => 'SHSW-1',
|
||||
'control_type' => 'Relay',
|
||||
'control_name' => null,
|
||||
'local_ip' => null,
|
||||
'status_color' => 'Red',
|
||||
'online' => false,
|
||||
],
|
||||
@@ -142,6 +153,7 @@ it('falls back to local device and control names when Shelly cloud list metadata
|
||||
'model' => 'Shelly Plus 1PM',
|
||||
'online' => 1,
|
||||
],
|
||||
'ip' => '10.32.0.21',
|
||||
'name' => 'Entry Gate',
|
||||
'status' => [
|
||||
'switch:0' => [
|
||||
@@ -174,6 +186,7 @@ it('falls back to local device and control names when Shelly cloud list metadata
|
||||
'code' => 'SPSW-001PE16EU',
|
||||
'control_type' => 'Switch',
|
||||
'control_name' => 'Entrance Relay',
|
||||
'local_ip' => '10.32.0.21',
|
||||
'status_color' => 'Green',
|
||||
'online' => true,
|
||||
],
|
||||
|
||||
@@ -4,6 +4,7 @@ app_require('classes/edge_gateway_manager.php');
|
||||
app_require('classes/shelly_transport_resolver.php');
|
||||
|
||||
use classes\edge_gateway_manager;
|
||||
use classes\gateway_shelly_transport;
|
||||
use classes\shelly_transport_resolver;
|
||||
use interfaces\shelly_transport_i;
|
||||
|
||||
@@ -75,3 +76,31 @@ it('resolves the injected cloud transport when a department is in cloud mode', f
|
||||
expect($resolver->resolveForDepartment(22))->toBe($cloudTransport);
|
||||
expect($manager->calls)->toBe([22]);
|
||||
});
|
||||
|
||||
it('lets relay tests override the department transport without changing department mode', function (): void {
|
||||
$manager = new ShellyTransportResolverManagerFake();
|
||||
$manager->modesByDepartment[17] = edge_gateway_manager::TRANSPORT_MODE_CLOUD;
|
||||
|
||||
$cloudTransport = new ShellyTransportResolverTransportFake('cloud');
|
||||
$gatewayTransport = new ShellyTransportResolverTransportFake('gateway');
|
||||
|
||||
$resolver = new shelly_transport_resolver($manager, $cloudTransport, $gatewayTransport);
|
||||
|
||||
expect($resolver->resolveForDepartment(17, 'local'))->toBe($gatewayTransport);
|
||||
expect($resolver->resolveForDepartment(17, 'gateway'))->toBe($gatewayTransport);
|
||||
expect($resolver->resolveForDepartment(17, 'cloud'))->toBe($cloudTransport);
|
||||
expect($manager->calls)->toBe([]);
|
||||
});
|
||||
|
||||
it('marks non-injected local overrides as local-only gateway transport', function (): void {
|
||||
$manager = new ShellyTransportResolverManagerFake();
|
||||
$resolver = new shelly_transport_resolver($manager);
|
||||
|
||||
$transport = $resolver->resolveForDepartment(17, 'local');
|
||||
$localOnly = new ReflectionProperty($transport, 'localOnly');
|
||||
$localOnly->setAccessible(true);
|
||||
|
||||
expect($transport)->toBeInstanceOf(gateway_shelly_transport::class);
|
||||
expect($localOnly->getValue($transport))->toBeTrue();
|
||||
expect($manager->calls)->toBe([]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user