Files
api/services/nginx/app/routes/moduleSelfServeRoute.php
T
Jeppe Bundgaard feb9aac4f7 Handle relay command job timeouts for edge gateways
- Introduce `expireTimedOutRelayStatusCommandJobs` to clean up long-pending relay status command jobs.
- Add `TIMED_OUT` status for relay command jobs and incorporate it into job status evaluations.
- Refactor command job finalization to support timeout-specific error messaging.
- Improve handling of fast-path failures in edge broker commands.
2026-04-27 16:47:09 +02:00

1099 lines
55 KiB
PHP

<?php
namespace routes;
use classes\authentication;
use classes\email;
use classes\response;
use classes\router;
use classes\selfserve;
use classes\stripe;
use modules\selfserve\helpers\selfserve_lane_command;
use modules\selfserve\helpers\selfserve_lane_port;
use modules\selfserve\helpers\selfserve_lane_relay;
use modules\selfserve\helpers\selfserve_wash_session_status;
use objects\departments_o;
use objects\logs_o;
use objects\orders_o;
use objects\customer_vehicles_o;
use objects\selfserve_wash_sessions_o;
use objects\stripe_module_customers_o;
use objects\users_o;
use traits\route_t;
class moduleSelfServeRoute
{
use route_t;
public function run(): void
{
global /** @var response $response */
/** @var router $router */
$router, $response;
/** Modules > Self Serve > Lane > Status */
$this->get('/modules/self-serve/lane/status', function () {
global $response;
self::requirePermission('modules_selfserve_lane_status_view');
$selfserve = new selfserve();
$lane_id = self::isParametersSet(['lane_id']) ? (int)self::getParameter('lane_id') : 1;
$lane = $selfserve->lane($lane_id);
$response->success([
'id' => $lane->id,
'status' => $lane->getLaneStatus()->name,
'mode' => $lane->getLaneMode()->name,
'state' => $lane->getLaneState()->name,
'wash_start_time' => $lane->getWashStartTime(),
'elapsed_wash_time' => $lane->getElapsedWashTime(),
'license_plate' => $lane->getLicensePlate(),
'customer_number' => $lane->getCustomerNumber(),
]);
},
[
'modules_selfserve_lane_status_view' => 'View self-serve lane status',
]
);
/** Modules > Self Serve > Lane > Wash > In-progress details */
$this->get('/modules/self-serve/lane/wash/in-progress', function () {
global $response;
self::requirePermission('modules_selfserve_lane_wash_in_progress_view');
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$build_customer = static function (?int $customer_number): ?array {
if ($customer_number === null || $customer_number <= 0) {
return null;
}
$customer_obj = (new users_o())->getUserByCustomerNumber($customer_number);
if ($customer_obj->exists()) {
return [
'id' => (int)$customer_obj->id,
'customer_number' => $customer_number,
'display_name' => $customer_obj->display_name->value() === null ? null : (string)$customer_obj->display_name->value(),
'email' => $customer_obj->email->value() === null ? null : (string)$customer_obj->email->value(),
'phone_country_code' => $customer_obj->phone_country_code->value() === null ? null : (int)$customer_obj->phone_country_code->value(),
'phone' => $customer_obj->phone->value() === null ? null : (string)$customer_obj->phone->value(),
];
}
return [
'id' => null,
'customer_number' => $customer_number,
'display_name' => null,
'email' => null,
'phone_country_code' => null,
'phone' => null,
];
};
$build_vehicle = static function (?int $vehicle_id, ?string $reg): ?array {
$vehicle_obj = null;
if ($vehicle_id !== null && $vehicle_id > 0) {
$tmp_vehicle = (new customer_vehicles_o())->select($vehicle_id);
if ($tmp_vehicle->exists()) {
$vehicle_obj = $tmp_vehicle;
}
}
if ($vehicle_obj === null && $reg !== null && trim($reg) !== '') {
$tmp_vehicle = (new customer_vehicles_o())->selectByPlate(trim($reg));
if ($tmp_vehicle->exists()) {
$vehicle_obj = $tmp_vehicle;
}
}
if ($vehicle_obj === null) {
return null;
}
return [
'id' => (int)$vehicle_obj->id,
'customer_id' => (int)$vehicle_obj->customer_id->value(),
'type' => (int)$vehicle_obj->type->value(),
'reg' => (string)$vehicle_obj->reg->value(),
'reference' => $vehicle_obj->reference->value() === null ? null : (string)$vehicle_obj->reference->value(),
];
};
$format_wash_started_at = static function (?int $wash_start_time): ?string {
if ($wash_start_time === null || $wash_start_time <= 0) {
return null;
}
return date('Y-m-d H:i:s', $wash_start_time);
};
$selfserve = new selfserve();
$included_minutes_when_machine_enabled = (int)$selfserve
->config
->machine_wash_minutes_included
->getVariableValue();
if ($included_minutes_when_machine_enabled < 0) {
$included_minutes_when_machine_enabled = 0;
}
$rows = (new selfserve_wash_sessions_o())->getFieldsWhere([
'lane_id' => $lane_id,
'completed_at' => null,
'deleted_at' => null,
], ['id']);
$session = null;
if ($rows !== []) {
usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']);
$tmp_session = (new selfserve_wash_sessions_o())->select((int)$rows[0]['id']);
if ($tmp_session->exists()) {
$session = $tmp_session;
}
}
if ($session === null) {
$lane = $selfserve->lane($lane_id);
$lane_status = $lane->getLaneStatus();
$lane_state = $lane->getLaneState();
$wash_started_at = $format_wash_started_at($lane->getWashStartTime());
$machine_start_triggered = $wash_started_at !== null;
$machine_relay_enabled = $machine_start_triggered
|| $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON_QUEUED)
|| $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON)
|| $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH)
|| $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::EXIT_PORT_OPEN_QUEUED)
|| $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::EXIT_PORT_OPEN);
$included_minutes = $machine_relay_enabled ? $included_minutes_when_machine_enabled : null;
$in_progress = $lane_status->equals(\modules\selfserve\helpers\selfserve_lane_status::OCCUPIED)
|| $lane_state->equals(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH);
if (!$in_progress) {
$response->success([
'lane_id' => $lane_id,
'in_progress' => false,
'session' => null,
'customer' => null,
'vehicle' => null,
]);
return;
}
$runtime_customer_number = (int)$lane->getCustomerNumber();
if ($runtime_customer_number <= 0) {
$runtime_customer_number = null;
}
$runtime_reg = trim((string)$lane->getLicensePlate());
if ($runtime_reg === '') {
$runtime_reg = null;
}
$customer = $build_customer($runtime_customer_number);
$vehicle = $build_vehicle(null, $runtime_reg);
$response->success([
'lane_id' => $lane_id,
'in_progress' => true,
'session' => [
'id' => null,
'status' => $lane_state->name,
'reg' => $runtime_reg,
'customer_number' => $runtime_customer_number,
'vehicle_id' => $vehicle['id'] ?? null,
'vehicle_type_id' => $vehicle['type'] ?? null,
'included_minutes' => $included_minutes,
'machine_type_id' => null,
'machine_relay_enabled' => $machine_relay_enabled,
'machine_relay_enabled_at' => $machine_relay_enabled ? $wash_started_at : null,
'machine_start_triggered' => $machine_start_triggered,
'machine_start_triggered_at' => $wash_started_at,
'wash_started_at' => $wash_started_at,
'created_at' => null,
'updated_at' => null,
],
'customer' => $customer,
'vehicle' => $vehicle,
]);
return;
}
$customer_number = $session->customer_number->value() === null ? null : (int)$session->customer_number->value();
$vehicle_id = $session->vehicle_id->value() === null ? null : (int)$session->vehicle_id->value();
$session_reg = trim((string)$session->reg->value());
if ($session_reg === '') {
$session_reg = null;
}
$customer = $build_customer($customer_number);
$vehicle = $build_vehicle($vehicle_id, $session_reg);
$machine_start_triggered_at = $session->machine_start_triggered_at->value() === null ? null : (string)$session->machine_start_triggered_at->value();
$wash_started_at = $session->wash_started_at->value() === null ? null : (string)$session->wash_started_at->value();
if ($wash_started_at === null) {
$wash_started_at = $machine_start_triggered_at;
}
if ($wash_started_at === null) {
$wash_started_at = $format_wash_started_at($selfserve->lane($lane_id)->getWashStartTime());
}
$machine_relay_enabled = (bool)$session->machine_relay_enabled->value();
$included_minutes = $machine_relay_enabled ? $included_minutes_when_machine_enabled : null;
$status = $session->status->value();
$in_progress_statusses = [
selfserve_wash_session_status::MACHINE_RELAY_ENABLED->value,
selfserve_wash_session_status::READY_FOR_MACHINE_START->value,
selfserve_wash_session_status::MACHINE_STARTED->value,
selfserve_wash_session_status::PENDING_QUESTIONS->value,
selfserve_wash_session_status::MACHINE_NOT_ALLOWED->value
];
$in_progress = in_array($status, $in_progress_statusses);
$response->success([
'lane_id' => $lane_id,
'status' => (string)$session->status->value(),
'in_progress' => $in_progress,
'elapsed_minutes' => $session->getElapsedMinutes(),
'session' => [
'id' => (int)$session->id,
'status' => (string)$session->status->value(),
'reg' => $session_reg,
'customer_number' => $customer_number,
'vehicle_id' => $vehicle_id,
'vehicle_type_id' => $session->vehicle_type_id->value() === null ? null : (int)$session->vehicle_type_id->value(),
'included_minutes' => $included_minutes ?? 0,
'machine_type_id' => $session->machine_type_id->value() === null ? null : (int)$session->machine_type_id->value(),
'machine_relay_enabled' => $machine_relay_enabled,
'machine_relay_enabled_at' => $session->machine_relay_enabled_at->value() === null ? null : (string)$session->machine_relay_enabled_at->value(),
'machine_start_triggered' => (bool)$session->machine_start_triggered->value(),
'machine_start_triggered_at' => $machine_start_triggered_at,
'wash_started_at' => $wash_started_at,
'created_at' => (string)$session->created_at->value(),
'updated_at' => $session->updated_at->value() === null ? null : (string)$session->updated_at->value(),
],
'customer' => $customer,
'vehicle' => $vehicle,
]);
},
[
'modules_selfserve_lane_wash_in_progress_view' => 'View customer and vehicle details for an in-progress self-serve wash on a lane',
]
);
/** Modules > Self Serve > Lane > Command */
$this->post('/modules/self-serve/lane/command', function () {
global $response;
self::requirePermission('modules_selfserve_lane_command_execute');
$selfserve = new selfserve();
// Get the request user
$user = (new authentication())->get_user();
$param_lane_id = 'lane_id';
$param_command = 'command';
// Validate parameters
self::requireParameters([$param_lane_id, $param_command]);
$lane_id = (int)$this->getParameter($param_lane_id);
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$commandParam = (string)$this->getParameter($param_command);
self::requireType($commandParam, self::type_string());
// Get the lane and command
$lane = $selfserve->lane($lane_id);
// If the user has the bypass permission, set the lane to bypass customer number validation
if (self::hasPermission('modules_selfserve_lane_command_bypass_customer_number_validation')) {
$lane->setBypassCustomerNumberValidation(true);
}
$command = selfserve_lane_command::tryFrom($commandParam);
if ($command === null) {
$response->error("Invalid command: " . $commandParam);
}
// Require permissions for specific commands
switch ($command) {
case selfserve_lane_command::START:
self::requirePermission('modules_selfserve_lane_command_execute_start');
break;
case selfserve_lane_command::STOP:
self::requirePermission('modules_selfserve_lane_command_execute_stop');
break;
case selfserve_lane_command::RESERVE:
self::requirePermission('modules_selfserve_lane_command_execute_reserve');
break;
case selfserve_lane_command::RELEASE:
self::requirePermission('modules_selfserve_lane_command_execute_release');
break;
case selfserve_lane_command::RESET:
self::requirePermission('modules_selfserve_lane_command_execute_reset');
break;
case selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE:
self::requirePermission('modules_selfserve_lane_command_execute_open_property_access_gate');
break;
case selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE:
self::requirePermission('modules_selfserve_lane_command_execute_open_property_exit_gate');
break;
}
// Execute the command
try {
$this->applyShellyTransportOverride($lane);
$args = new \modules\selfserve\classes\selfserve_lane_command_arguments();
$args->setParameters([
...$this->getParametersAsArray(), // Pass all parameters
'customer_number' => (int)$user->customer_number->value(), // Get customer number from request user
]);
$lane->execute($command, $args);
$response->success([
'id' => $lane->id,
'status' => $lane->getLaneStatus()->name,
'mode' => $lane->getLaneMode()->name,
'state' => $lane->getLaneState()->name,
'wash_start_time' => $lane->getWashStartTime(),
'elapsed_wash_time' => $lane->getElapsedWashTime(),
'license_plate' => $lane->getLicensePlate(),
'customer_number' => $lane->getCustomerNumber(),
'transport' => $this->requestedShellyTransportOverride(),
]);
} catch (\Exception $e) {
if (
$command === selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE ||
$command === selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE
) {
error_log(
'Failed to execute self-serve property gate command ' . $command->name .
' for lane ' . $lane_id . ': ' . $e->getMessage()
);
}
$response->error("Failed to execute command: " . $e->getMessage());
}
},
[
'modules_selfserve_lane_command_execute' => 'Execute self-serve lane command. This is required together with specific command permissions below.',
'modules_selfserve_lane_command_execute_start' => 'Execute self-serve lane START command',
'modules_selfserve_lane_command_execute_stop' => 'Execute self-serve lane STOP command',
'modules_selfserve_lane_command_execute_reserve' => 'Execute self-serve lane RESERVE command',
'modules_selfserve_lane_command_execute_release' => 'Execute self-serve lane RELEASE command (Release the lane reservation and reset its state)',
'modules_selfserve_lane_command_execute_reset' => 'Execute self-serve lane RESET command',
'modules_selfserve_lane_command_execute_open_property_access_gate' => 'Execute self-serve lane OPEN_PROPERTY_ACCESS_GATE command',
'modules_selfserve_lane_command_execute_open_property_exit_gate' => 'Execute self-serve lane OPEN_PROPERTY_EXIT_GATE command',
'modules_selfserve_lane_command_bypass_customer_number_validation' => 'Bypass customer number validation when executing commands',
]
);
/** Modules > Self Serve > Lane > Allowed services (derived from shown tasks) */
$this->post('/modules/self-serve/lane/services/allowed', function () {
global $response;
self::requirePermission('modules_selfserve_lane_services_set_allowed');
$selfserve = new selfserve();
// Validate parameters
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
// Parse task_ids from request (optional, may be empty to clear)
$task_ids_param = $this->isParametersSet(['task_ids']) ? $this->getParameter('task_ids') : [];
if (is_string($task_ids_param)) {
$decoded = json_decode($task_ids_param, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
$task_ids_param = $decoded;
} else {
$task_ids_param = array_filter(array_map(fn($s) => (int)trim($s), explode(',', $task_ids_param)), fn($v) => $v > 0);
}
}
if (!is_array($task_ids_param)) {
$response->error('Invalid format for task_ids. Expected array, JSON array, or comma-separated string of IDs.', 400);
}
$task_ids = array_values(array_unique(array_map(fn($v) => (int)$v, $task_ids_param)));
// Build allowed services from provided tasks
$lane = $selfserve->lane($lane_id);
$allowed_services = [];
foreach ($task_ids as $tid) {
if ($tid <= 0) continue;
$t = new \objects\department_selfserve_tasks_o();
$t->select($tid);
if (!$t->exists()) continue; // ignore unknown ids
// Validate the task belongs to the same lane
if ((int)$t->lane->value() !== $lane_id) continue;
// Merge services (if any)
$services = (array)$t->services->value();
foreach ($services as $srv) {
$name = strtoupper((string)$srv);
if (!in_array($name, $allowed_services, true)) {
$allowed_services[] = $name;
}
}
}
// Persist on lane cache (overwrites previous allowed services)
try {
$this->applyShellyTransportOverride($lane);
$relay_sync = $lane->syncMachineRelayFromVisibleServices($allowed_services, true);
$response->success([
'lane_id' => $lane_id,
'allowed_services' => $allowed_services,
'relay_sync' => $relay_sync,
'transport' => $this->requestedShellyTransportOverride(),
]);
} catch (\Exception $e) {
$response->error('Failed to set allowed services: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_services_set_allowed' => 'Set allowed services for a lane based on currently shown tasks (post-Q&A)'
]);
/** Modules > Self Serve > Lane > Gate > Open (ENTRANCE/EXIT) */
$this->post('/modules/self-serve/lane/gate/open', function () {
global $response;
self::requirePermission('modules_selfserve_lane_gate_open');
$selfserve = new selfserve();
self::requireParameters(['lane_id', 'gate']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$gate_name = strtoupper(trim((string)$this->getParameter('gate')));
$gate = match ($gate_name) {
'ENTRANCE' => selfserve_lane_port::ENTRANCE,
'EXIT' => selfserve_lane_port::EXIT,
default => null,
};
if ($gate === null) {
$response->error('Invalid gate value. Expected ENTRANCE or EXIT.', 400);
}
$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());
$response->error('Failed to open ' . strtolower($gate->name) . ' gate.', 400);
}
}, [
'modules_selfserve_lane_gate_open' => 'Open ENTRANCE or EXIT gate for a self-serve lane'
]);
/** Modules > Self Serve > Lane > Relay > MACHINE_PROGRAM_PICKER status */
$this->get('/modules/self-serve/lane/relay/machine_program_picker/status', function () {
global $response;
self::requirePermission('modules_selfserve_lane_relay_machine_program_picker_status_view');
$selfserve = new selfserve();
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$lane = $selfserve->lane($lane_id);
try {
$this->applyShellyTransportOverride($lane);
$status = $lane->getMachineProgramPickerRelayStatus();
$response->success($this->buildRelayStatusResponse($lane_id, 'MACHINE_PROGRAM_PICKER', $status));
} catch (\Exception $e) {
$response->error('Failed to get MACHINE_PROGRAM_PICKER relay status: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_relay_machine_program_picker_status_view' => 'Get MACHINE_PROGRAM_PICKER relay status for a lane'
]);
/** Modules > Self Serve > Lane > Relay > MACHINE_PROGRAM_PICKER set on/off */
$this->post('/modules/self-serve/lane/relay/machine_program_picker/set', function () {
global $response;
self::requirePermission('modules_selfserve_lane_relay_machine_program_picker_status_set');
$selfserve = new selfserve();
self::requireParameters(['lane_id', 'on']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$raw_on = $this->getParameter('on');
$on = is_bool($raw_on) ? $raw_on : filter_var($raw_on, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($on === null) {
$response->error('Invalid on value. Expected boolean true/false.', 400);
}
$lane = $selfserve->lane($lane_id);
try {
$this->applyShellyTransportOverride($lane);
$lane->setMachineProgramPickerRelayStatus((bool)$on);
$status = $lane->getMachineProgramPickerRelayStatus();
$response->success($this->buildRelayStatusResponse($lane_id, 'MACHINE_PROGRAM_PICKER', $status, [
'requested_on' => (bool)$on,
]));
} catch (\Exception $e) {
$response->error('Failed to set MACHINE_PROGRAM_PICKER relay status: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_relay_machine_program_picker_status_set' => 'Set MACHINE_PROGRAM_PICKER relay status (on/off) for a lane'
]);
/** Modules > Self Serve > Lane > Relay > MACHINE_CLEANER status */
$this->get('/modules/self-serve/lane/relay/machine_cleaner/status', function () {
global $response;
self::requirePermission('modules_selfserve_lane_relay_machine_cleaner_status_view');
$selfserve = new selfserve();
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$lane = $selfserve->lane($lane_id);
try {
$this->applyShellyTransportOverride($lane);
$status = $lane->getMachineCleanerRelayStatus();
$response->success($this->buildRelayStatusResponse($lane_id, 'MACHINE_CLEANER', $status));
} catch (\Exception $e) {
$response->error('Failed to get MACHINE_CLEANER relay status: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_relay_machine_cleaner_status_view' => 'Get MACHINE_CLEANER relay status for a lane'
]);
/** Modules > Self Serve > Lane > Relay > MACHINE_CLEANER set on/off */
$this->post('/modules/self-serve/lane/relay/machine_cleaner/set', function () {
global $response;
self::requirePermission('modules_selfserve_lane_relay_machine_cleaner_status_set');
$selfserve = new selfserve();
self::requireParameters(['lane_id', 'on']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$raw_on = $this->getParameter('on');
$on = is_bool($raw_on) ? $raw_on : filter_var($raw_on, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($on === null) {
$response->error('Invalid on value. Expected boolean true/false.', 400);
}
$lane = $selfserve->lane($lane_id);
try {
$this->applyShellyTransportOverride($lane);
$lane->setMachineCleanerRelayStatus((bool)$on);
$status = $lane->getMachineCleanerRelayStatus();
$response->success($this->buildRelayStatusResponse($lane_id, 'MACHINE_CLEANER', $status, [
'requested_on' => (bool)$on,
]));
} catch (\Exception $e) {
$response->error('Failed to set MACHINE_CLEANER relay status: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_relay_machine_cleaner_status_set' => 'Set MACHINE_CLEANER relay status (on/off) for a lane'
]);
/** Modules > Self Serve > Lane > Relay > MACHINE status */
$this->get('/modules/self-serve/lane/relay/machine/status', function () {
global $response;
self::requirePermission('modules_selfserve_lane_relay_machine_status_view');
$selfserve = new selfserve();
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$lane = $selfserve->lane($lane_id);
try {
$this->applyShellyTransportOverride($lane);
$status = $lane->getMachineRelayStatus();
$response->success($this->buildRelayStatusResponse($lane_id, 'MACHINE', $status));
} catch (\Exception $e) {
$response->error('Failed to get MACHINE relay status: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_relay_machine_status_view' => 'Get MACHINE relay status for a lane'
]);
/** Modules > Self Serve > Lane > Relay > MACHINE set on/off */
$this->post('/modules/self-serve/lane/relay/machine/set', function () {
global $response;
self::requirePermission('modules_selfserve_lane_relay_machine_status_set');
$selfserve = new selfserve();
self::requireParameters(['lane_id', 'on']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$raw_on = $this->getParameter('on');
$on = is_bool($raw_on) ? $raw_on : filter_var($raw_on, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($on === null) {
$response->error('Invalid on value. Expected boolean true/false.', 400);
}
$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 {
$machine_relay_id = '';
if (!empty($lane->department_lane) && !empty($lane->department_lane->relay_machine_id)) {
$machine_relay_id = trim((string)$lane->department_lane->relay_machine_id->value());
}
$is_demo_machine_relay = str_starts_with(strtolower($machine_relay_id), 'demo-');
$lane->setLaneState((bool)$on
? ($is_demo_machine_relay
? \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON_QUEUED
: \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON)
: ($is_demo_machine_relay
? \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_OFF_QUEUED
: \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_OFF)
);
} catch (\Throwable $ignored) {}
$status = $lane->getMachineRelayStatus();
$response->success($this->buildRelayStatusResponse($lane_id, 'MACHINE', $status, [
'requested_on' => (bool)$on,
]));
} catch (\Exception $e) {
$response->error('Failed to set MACHINE relay status: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_relay_machine_status_set' => 'Set MACHINE relay status (on/off) for a lane'
]);
/** Modules > Self Serve > Lane > Relay > Enable MACHINE_PROGRAM_PICKER (manual) */
$this->post('/modules/self-serve/lane/relay/machine_program_picker/enable', function () {
global $response;
self::requirePermission('modules_selfserve_lane_relay_enable_machine_program_picker');
$selfserve = new selfserve();
// Validate parameters
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$duration = null;
if ($this->isParametersSet(['duration'])) {
$duration = (int)$this->getParameter('duration');
self::requireMinValue($duration, 1);
}
$lane = $selfserve->lane($lane_id);
try {
$this->applyShellyTransportOverride($lane);
$lane->turnOnRelay(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $duration);
$response->success([
'lane_id' => $lane_id,
'relay' => 'MACHINE_PROGRAM_PICKER',
'enabled' => true,
'duration' => $duration,
'transport' => $this->requestedShellyTransportOverride(),
]);
} catch (\Exception $e) {
$response->error('Failed to enable MACHINE_PROGRAM_PICKER relay: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_relay_enable_machine_program_picker' => 'Manually enable MACHINE_PROGRAM_PICKER relay for a lane'
]);
/** Modules > Self Serve > Lane > Relay > Enable MACHINE_CLEANER (manual) */
$this->post('/modules/self-serve/lane/relay/machine_cleaner/enable', function () {
global $response;
self::requirePermission('modules_selfserve_lane_relay_enable_machine_cleaner');
$selfserve = new selfserve();
// Validate parameters
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$duration = null;
if ($this->isParametersSet(['duration'])) {
$duration = (int)$this->getParameter('duration');
self::requireMinValue($duration, 1);
}
$lane = $selfserve->lane($lane_id);
try {
$this->applyShellyTransportOverride($lane);
$lane->turnOnRelay(selfserve_lane_relay::MACHINE_CLEANER, $duration);
$response->success([
'lane_id' => $lane_id,
'relay' => 'MACHINE_CLEANER',
'enabled' => true,
'duration' => $duration,
'transport' => $this->requestedShellyTransportOverride(),
]);
} catch (\Exception $e) {
$response->error('Failed to enable MACHINE_CLEANER relay: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_relay_enable_machine_cleaner' => 'Manually enable MACHINE_CLEANER relay for a lane'
]);
/** Modules > Self Serve > Lane > Relay > Enable MACHINE (manual, gated by allowed services) */
$this->post('/modules/self-serve/lane/relay/machine/enable', function () {
global $response;
self::requirePermission('modules_selfserve_lane_relay_enable_machine');
$selfserve = new selfserve();
// Validate parameters
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$duration = null;
if ($this->isParametersSet(['duration'])) {
$duration = (int)$this->getParameter('duration');
self::requireMinValue($duration, 1);
}
$lane = $selfserve->lane($lane_id);
try {
$this->applyShellyTransportOverride($lane);
$lane->turnOnRelay(selfserve_lane_relay::MACHINE, $duration);
// A started wash should also turn on cleaner when configured.
try {
if (
!empty($lane->department_lane)
&& !empty($lane->department_lane->relay_machine_cleaner_id)
&& trim((string)$lane->department_lane->relay_machine_cleaner_id->value()) !== ''
) {
$lane->setMachineCleanerRelayStatusHard(true);
}
} catch (\Throwable $ignored) {}
$response->success([
'lane_id' => $lane_id,
'relay' => 'MACHINE',
'enabled' => true,
'duration' => $duration,
'transport' => $this->requestedShellyTransportOverride(),
]);
} catch (\Exception $e) {
$msg = $e->getMessage();
// If gating prevented activation, respond with 403
if (str_contains(strtoupper($msg), 'NOT ALLOWED')) {
$response->error($msg, 403);
}
$response->error('Failed to enable MACHINE relay: ' . $msg, 400);
}
}, [
'modules_selfserve_lane_relay_enable_machine' => 'Manually enable MACHINE relay for a lane (requires allowed task to be present)'
]);
/** Modules > Self Serve > Lane > Force > MACHINE_PROGRAM_PICKER enable */
$this->post('/modules/self-serve/lane/force/machine_program_picker/enable', function () {
global $response;
self::requirePermission('modules_selfserve_lane_force_machine_program_picker_enable');
$selfserve = new selfserve();
// Validate parameters
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$duration = null;
if ($this->isParametersSet(['duration'])) {
$duration = (int)$this->getParameter('duration');
self::requireMinValue($duration, 1);
}
$lane = $selfserve->lane($lane_id);
try {
$this->applyShellyTransportOverride($lane);
$lane->forceTurnOnRelay(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $duration);
$response->success([
'lane_id' => $lane_id,
'relay' => 'MACHINE_PROGRAM_PICKER',
'enabled' => true,
'duration' => $duration,
'transport' => $this->requestedShellyTransportOverride(),
]);
} catch (\Exception $e) {
$response->error('Failed to force enable MACHINE_PROGRAM_PICKER relay: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_force_machine_program_picker_enable' => 'Force enable MACHINE_PROGRAM_PICKER relay for a lane'
]);
/** Modules > Self Serve > Lane > Force > MACHINE_PROGRAM_PICKER disable */
$this->post('/modules/self-serve/lane/force/machine_program_picker/disable', function () {
global $response;
self::requirePermission('modules_selfserve_lane_force_machine_program_picker_disable');
$selfserve = new selfserve();
// Validate parameters
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$lane = $selfserve->lane($lane_id);
try {
$this->applyShellyTransportOverride($lane);
$lane->forceTurnOffRelay(selfserve_lane_relay::MACHINE_PROGRAM_PICKER);
$response->success([
'lane_id' => $lane_id,
'relay' => 'MACHINE_PROGRAM_PICKER',
'disabled' => true,
'transport' => $this->requestedShellyTransportOverride(),
]);
} catch (\Exception $e) {
$response->error('Failed to force disable MACHINE_PROGRAM_PICKER relay: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_force_machine_program_picker_disable' => 'Force disable MACHINE_PROGRAM_PICKER relay for a lane'
]);
/** Modules > Self Serve > Lane > Force > MACHINE_CLEANER enable */
$this->post('/modules/self-serve/lane/force/machine_cleaner/enable', function () {
global $response;
self::requirePermission('modules_selfserve_lane_force_machine_cleaner_enable');
$selfserve = new selfserve();
// Validate parameters
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$duration = null;
if ($this->isParametersSet(['duration'])) {
$duration = (int)$this->getParameter('duration');
self::requireMinValue($duration, 1);
}
$lane = $selfserve->lane($lane_id);
try {
$this->applyShellyTransportOverride($lane);
$lane->forceTurnOnRelay(selfserve_lane_relay::MACHINE_CLEANER, $duration);
$response->success([
'lane_id' => $lane_id,
'relay' => 'MACHINE_CLEANER',
'enabled' => true,
'duration' => $duration,
'transport' => $this->requestedShellyTransportOverride(),
]);
} catch (\Exception $e) {
$response->error('Failed to force enable MACHINE_CLEANER relay: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_force_machine_cleaner_enable' => 'Force enable MACHINE_CLEANER relay for a lane'
]);
/** Modules > Self Serve > Lane > Force > MACHINE_CLEANER disable */
$this->post('/modules/self-serve/lane/force/machine_cleaner/disable', function () {
global $response;
self::requirePermission('modules_selfserve_lane_force_machine_cleaner_disable');
$selfserve = new selfserve();
// Validate parameters
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$lane = $selfserve->lane($lane_id);
try {
$this->applyShellyTransportOverride($lane);
$lane->forceTurnOffRelay(selfserve_lane_relay::MACHINE_CLEANER);
$response->success([
'lane_id' => $lane_id,
'relay' => 'MACHINE_CLEANER',
'disabled' => true,
'transport' => $this->requestedShellyTransportOverride(),
]);
} catch (\Exception $e) {
$response->error('Failed to force disable MACHINE_CLEANER relay: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_force_machine_cleaner_disable' => 'Force disable MACHINE_CLEANER relay for a lane'
]);
/** Modules > Self Serve > Lane > Force > MACHINE enable (simulate started wash) */
$this->post('/modules/self-serve/lane/force/machine/enable', function () {
global $response;
self::requirePermission('modules_selfserve_lane_force_machine_enable');
$selfserve = new selfserve();
// Validate parameters
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$duration = null;
if ($this->isParametersSet(['duration'])) {
$duration = (int)$this->getParameter('duration');
self::requireMinValue($duration, 1);
}
$license_plate = $this->isParametersSet(['license_plate']) ? (string)$this->getParameter('license_plate') : null;
$lane = $selfserve->lane($lane_id);
try {
$this->applyShellyTransportOverride($lane);
// If lane looks idle/available, mark as occupied and in wash, set timers
if ($lane->getLaneStatus()->equals(\modules\selfserve\helpers\selfserve_lane_status::AVAILABLE)) {
$lane->setLaneStatus(\modules\selfserve\helpers\selfserve_lane_status::OCCUPIED);
}
if (!$lane->getLaneState()->equals(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH)) {
$lane->setLaneState(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH);
}
if ($license_plate !== null && $license_plate !== '') {
$lane->setLicensePlate($license_plate);
}
if ((int)$lane->getWashStartTime() <= 0) {
$lane->setWashStartTime(time());
}
// A started wash should always have cleaner relay enabled when configured.
try {
if (
!empty($lane->department_lane)
&& !empty($lane->department_lane->relay_machine_cleaner_id)
&& trim((string)$lane->department_lane->relay_machine_cleaner_id->value()) !== ''
) {
$lane->setMachineCleanerRelayStatusHard(true);
}
} catch (\Throwable $ignored) {}
// Force enable the machine relay (bypass gating)
$lane->forceTurnOnMachineRelay($duration);
// Reflect relay state explicitly
try {
$machine_relay_id = '';
if (!empty($lane->department_lane) && !empty($lane->department_lane->relay_machine_id)) {
$machine_relay_id = trim((string)$lane->department_lane->relay_machine_id->value());
}
$is_demo_machine_relay = str_starts_with(strtolower($machine_relay_id), 'demo-');
$lane->setLaneState(
$is_demo_machine_relay
? \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON_QUEUED
: \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_ON
);
} catch (\Throwable $ignored) {}
$response->success([
'lane_id' => $lane_id,
'forced' => true,
'machine' => 'ENABLED',
'duration' => $duration,
'status' => $lane->getLaneStatus()->name,
'state' => $lane->getLaneState()->name,
'wash_start_time' => $lane->getWashStartTime(),
'transport' => $this->requestedShellyTransportOverride(),
]);
} catch (\Exception $e) {
$response->error('Failed to force-enable MACHINE: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_force_machine_enable' => 'Force enable MACHINE relay and mark lane as in-wash (superusers only)'
]);
/** Modules > Self Serve > Lane > Force > MACHINE disable (simulate started wash without machine) */
$this->post('/modules/self-serve/lane/force/machine/disable', function () {
global $response;
self::requirePermission('modules_selfserve_lane_force_machine_disable');
$selfserve = new selfserve();
// Validate parameters
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$license_plate = $this->isParametersSet(['license_plate']) ? (string)$this->getParameter('license_plate') : null;
$lane = $selfserve->lane($lane_id);
try {
$this->applyShellyTransportOverride($lane);
// Ensure lane reflects an ongoing wash, but with machine off
if ($lane->getLaneStatus()->equals(\modules\selfserve\helpers\selfserve_lane_status::AVAILABLE)) {
$lane->setLaneStatus(\modules\selfserve\helpers\selfserve_lane_status::OCCUPIED);
}
if (!$lane->getLaneState()->equals(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH)) {
$lane->setLaneState(\modules\selfserve\helpers\selfserve_lane_state::IN_WASH);
}
if ($license_plate !== null && $license_plate !== '') {
$lane->setLicensePlate($license_plate);
}
if ((int)$lane->getWashStartTime() <= 0) {
$lane->setWashStartTime(time());
}
// A started wash should always have cleaner relay enabled when configured.
try {
if (
!empty($lane->department_lane)
&& !empty($lane->department_lane->relay_machine_cleaner_id)
&& trim((string)$lane->department_lane->relay_machine_cleaner_id->value()) !== ''
) {
$lane->setMachineCleanerRelayStatusHard(true);
}
} catch (\Throwable $ignored) {}
// Turn off the machine relay (do not swallow errors)
$lane->forceTurnOffMachineRelay();
// Reflect relay state explicitly
try {
$machine_relay_id = '';
if (!empty($lane->department_lane) && !empty($lane->department_lane->relay_machine_id)) {
$machine_relay_id = trim((string)$lane->department_lane->relay_machine_id->value());
}
$is_demo_machine_relay = str_starts_with(strtolower($machine_relay_id), 'demo-');
$lane->setLaneState(
$is_demo_machine_relay
? \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_OFF_QUEUED
: \modules\selfserve\helpers\selfserve_lane_state::MACHINE_RELAY_OFF
);
} catch (\Throwable $ignored) {}
$response->success([
'lane_id' => $lane_id,
'forced' => true,
'machine' => 'DISABLED',
'status' => $lane->getLaneStatus()->name,
'state' => $lane->getLaneState()->name,
'wash_start_time' => $lane->getWashStartTime(),
'transport' => $this->requestedShellyTransportOverride(),
]);
} catch (\Exception $e) {
$response->error('Failed to force-disable MACHINE: ' . $e->getMessage(), 400);
}
}, [
'modules_selfserve_lane_force_machine_disable' => 'Force disable MACHINE relay but keep lane as in-wash (superusers only)'
]);
}
/**
* @param array<string,mixed> $status
* @param array<string,mixed> $extra
* @return array<string,mixed>
*/
private function buildRelayStatusResponse(int $lane_id, string $relay, array $status, array $extra = []): array
{
$on = (bool)($status['on'] ?? false);
$switch_status = is_array($status['status'] ?? null) ? $status['status'] : [];
if (!isset($switch_status['switch:0']) || !is_array($switch_status['switch:0'])) {
$switch_status['switch:0'] = [];
}
$switch_status['switch:0']['output'] = (bool)($switch_status['switch:0']['output'] ?? $on);
$payload = [
'lane_id' => $lane_id,
'relay' => $relay,
...$extra,
'relay_id' => (string)($status['relay_id'] ?? ''),
'online' => (bool)($status['online'] ?? false),
'on' => $on,
'status' => $switch_status,
'transport' => $this->requestedShellyTransportOverride(),
];
foreach (['binding', 'execution', 'raw'] as $metadata_key) {
if (isset($status[$metadata_key]) && is_array($status[$metadata_key]) && $status[$metadata_key] !== []) {
$payload[$metadata_key] = $status[$metadata_key];
}
}
return $payload;
}
/**
* @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, gateway, or cloud.');
}
return $normalized;
}
}