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.
This commit is contained in:
Jeppe Bundgaard
2026-04-29 09:52:51 +02:00
parent 690e114d38
commit eeccacb2a7
33 changed files with 4880 additions and 140 deletions
@@ -1016,6 +1016,8 @@ final class TruckwashEdgeAgent
private string $controlPlaneStatusPath;
private string $stagedUpdatePath;
private int $lastHeartbeatAt = 0;
private int $lastMachineSignalPollAt = 0;
private int $lastMachineSignalMonitorRefreshAt = 0;
private string $agentInstanceId;
public function __construct(string $configPath)
@@ -1058,6 +1060,7 @@ final class TruckwashEdgeAgent
$this->flushOutbox();
$this->pumpBrokerTransport();
$this->heartbeat();
$this->pollMachineStartSignals();
if ($this->resumePendingOperationCompletion()) {
$this->pumpBrokerTransport();
continue;
@@ -1291,6 +1294,171 @@ final class TruckwashEdgeAgent
], JSON_UNESCAPED_SLASHES) . PHP_EOL);
}
private function pollMachineStartSignals(): void
{
$interval = max(1, (int)$this->config->get('machineSignalPollIntervalSeconds', 2));
if ((time() - $this->lastMachineSignalPollAt) < $interval) {
return;
}
$this->lastMachineSignalPollAt = time();
$gatewayId = (int)$this->config->get('gatewayId');
$agentToken = (string)$this->config->get('agentToken');
if ($gatewayId <= 0 || trim($agentToken) === '') {
return;
}
foreach ($this->machineSignalMonitors($gatewayId, $agentToken) as $monitor) {
$localIp = trim((string)($monitor['local_ip'] ?? ''));
if ($localIp === '') {
continue;
}
try {
$component = strtolower(trim((string)($monitor['component'] ?? 'input'))) === 'switch' ? 'switch' : 'input';
$channel = (int)($monitor['channel'] ?? 0);
$status = $this->machineSignalMonitorStatus($localIp, $channel, $component);
$on = $this->machineSignalOnState($status, $component);
if ($on === null) {
continue;
}
$stateKey = sprintf(
'selfserve_machine_signal:%s:%s:%d',
preg_replace('/[^A-Za-z0-9_\-:.]+/', '_', (string)($monitor['relay_id'] ?? 'relay')),
$component,
$channel
);
$previous = $this->stateStore->getJson($stateKey, null);
$previousOn = is_array($previous) && array_key_exists('on', $previous) ? (bool)$previous['on'] : false;
if ($on && !$previousOn) {
$event = $component === 'switch' ? 'switch.on' : 'input.toggle_on';
$payload = [
'agent_token' => $agentToken,
'agent_instance_id' => $this->agentInstanceId,
'lane_id' => (int)($monitor['lane_id'] ?? 0),
'relay_id' => (string)($monitor['relay_id'] ?? ''),
'device_id' => (string)($monitor['device_id'] ?? ''),
'component' => $component,
'channel' => $channel,
'event' => $event,
'source' => 'edge_gateway_poll',
'status' => $status,
];
$payload[$component === 'switch' ? 'output' : 'state'] = true;
$this->sendControlPlaneEvent(
'/edge-agent/gateways/' . $gatewayId . '/selfserve/machine-signal',
$payload,
'machine_signal'
);
}
$this->stateStore->setJson($stateKey, [
'on' => $on,
'component' => $component,
'channel' => $channel,
'relay_id' => (string)($monitor['relay_id'] ?? ''),
'updated_at' => date('c'),
]);
} catch (Throwable $throwable) {
$this->logger->warning('Machine start signal poll failed: ' . $throwable->getMessage());
}
}
}
/**
* @return array<int,array<string,mixed>>
*/
private function machineSignalMonitors(int $gatewayId, string $agentToken): array
{
$cache = $this->stateStore->getJson('selfserve_machine_signal_monitors', []);
if (
is_array($cache)
&& isset($cache['monitors'], $cache['refreshed_at'])
&& is_array($cache['monitors'])
&& (time() - (int)$cache['refreshed_at']) < 60
) {
return array_values(array_filter($cache['monitors'], 'is_array'));
}
try {
$response = $this->http->post('/edge-agent/gateways/' . $gatewayId . '/selfserve/machine-signal-bindings', [
'agent_token' => $agentToken,
'agent_instance_id' => $this->agentInstanceId,
], 10);
$payload = is_array($response['data'] ?? null) ? (array)$response['data'] : (is_array($response) ? $response : []);
$monitors = is_array($payload['monitors'] ?? null) ? array_values(array_filter((array)$payload['monitors'], 'is_array')) : [];
$this->lastMachineSignalMonitorRefreshAt = time();
$this->stateStore->setJson('selfserve_machine_signal_monitors', [
'refreshed_at' => $this->lastMachineSignalMonitorRefreshAt,
'monitors' => $monitors,
]);
return $monitors;
} catch (Throwable $throwable) {
if (is_array($cache) && isset($cache['monitors']) && is_array($cache['monitors'])) {
return array_values(array_filter($cache['monitors'], 'is_array'));
}
$this->logger->warning('Machine start signal monitor refresh failed: ' . $throwable->getMessage());
return [];
}
}
private function machineSignalMonitorStatus(string $localIp, int $channel, string $component): array
{
if ($component === 'input') {
try {
$input = $this->workerHttp->post('/relay/input-status', [
'local_ip' => $localIp,
'channel' => $channel,
], 5) ?? [];
return [
'input_state' => $input['state'] ?? null,
'input' => $input,
];
} catch (Throwable) {
// Fall back to the combined status endpoint below.
}
}
return $this->workerHttp->post('/relay/status', [
'local_ip' => $localIp,
'channel' => $channel,
'include_input' => $component === 'input',
], 8) ?? [];
}
private function machineSignalOnState(array $status, string $component): ?bool
{
if ($component === 'switch') {
if (array_key_exists('output', $status)) {
return (bool)$status['output'];
}
if (array_key_exists('on', $status)) {
return (bool)$status['on'];
}
if (isset($status['raw']) && is_array($status['raw']) && array_key_exists('output', $status['raw'])) {
return (bool)$status['raw']['output'];
}
return null;
}
if (array_key_exists('input_state', $status)) {
return $status['input_state'] === null ? null : (bool)$status['input_state'];
}
if (isset($status['input']) && is_array($status['input']) && array_key_exists('state', $status['input'])) {
return (bool)$status['input']['state'];
}
if (array_key_exists('state', $status)) {
return (bool)$status['state'];
}
return null;
}
private function processCommandQueue(): void
{
$gatewayId = (int)$this->config->get('gatewayId');