Add unit tests for lane relay Shelly batching, rate limit handling, and retry logic. Refactor Shelly relay state handling with snapshot caching, readiness checks, and reduced delay intervals.

This commit is contained in:
Jeppe Bundgaard
2026-03-25 13:40:12 +01:00
parent 5a29683fe8
commit 4c81cfd0fe
6 changed files with 1142 additions and 165 deletions
+146
View File
@@ -7288,6 +7288,107 @@ paths:
schema:
$ref: '#/components/schemas/SelfServeLaneStatus'
/modules/self-serve/lane/wash/in-progress:
get:
tags:
- Modules
summary: Get in-progress self-serve wash customer and vehicle details
description: |
Returns the current open self-serve wash session details for a lane (if any),
including resolved customer and vehicle details.
operationId: getSelfServeLaneWashInProgress
parameters:
- name: lane_id
in: query
required: true
schema:
type: integer
responses:
'200':
description: In-progress wash details resolved
content:
application/json:
schema:
type: object
properties:
lane_id:
type: integer
in_progress:
type: boolean
session:
type: object
nullable: true
properties:
id:
type: integer
status:
type: string
reg:
type: string
customer_number:
type: integer
nullable: true
vehicle_id:
type: integer
nullable: true
vehicle_type_id:
type: integer
nullable: true
machine_type_id:
type: integer
nullable: true
machine_start_triggered:
type: boolean
machine_start_triggered_at:
type: string
nullable: true
created_at:
type: string
updated_at:
type: string
nullable: true
customer:
type: object
nullable: true
properties:
id:
type: integer
nullable: true
customer_number:
type: integer
nullable: true
display_name:
type: string
nullable: true
email:
type: string
nullable: true
phone_country_code:
type: integer
nullable: true
phone:
type: string
nullable: true
vehicle:
type: object
nullable: true
properties:
id:
type: integer
customer_id:
type: integer
type:
type: integer
reg:
type: string
reference:
type: string
nullable: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
/modules/self-serve/lane/command:
post:
tags:
@@ -7366,6 +7467,51 @@ paths:
'403':
$ref: '#/components/responses/Forbidden'
/modules/self-serve/lane/gate/open:
post:
tags:
- Modules
summary: Open a self-serve lane gate
description: |
Opens either the ENTRANCE or EXIT gate relay for a self-serve lane.
operationId: openSelfServeLaneGate
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- lane_id
- gate
properties:
lane_id:
type: integer
gate:
type: string
enum: [ENTRANCE, EXIT]
responses:
'200':
description: Lane gate opened
content:
application/json:
schema:
type: object
properties:
lane_id:
type: integer
gate:
type: string
enum: [ENTRANCE, EXIT]
opened:
type: boolean
state:
type: string
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
/modules/self-serve/lane/relay/machine/status:
get:
tags:
@@ -5,18 +5,20 @@ require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php';
require_once WD . '/modules/selfserve/classes/selfserve_lane.php';
use classes\shelly;
use modules\selfserve\helpers\selfserve_lane_log_action;
use modules\selfserve\helpers\selfserve_lane_relay;
use modules\selfserve\helpers\selfserve_lane_state;
use modules\selfserve\helpers\selfserve_lane_status;
use modules\selfserve\helpers\selfserve_lane_services;
use modules\shelly\helpers\shelly_device_switch;
use modules\selfserve\helpers\selfserve_lane_status;
use modules\shelly\helpers\shelly_request_body_get_states;
trait selfserve_lane_relay_controller_t
{
private const SHELLY_STATUS_WAIT_TIMEOUT_SECONDS = 20;
private const SHELLY_RETRY_SLEEP_MICROSECONDS = 750000;
private const SHELLY_RETRY_SLEEP_MICROSECONDS = 250000;
private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 1000;
private const SHELLY_RATE_LIMIT_GATE_KEY = 'selfserve_shelly_cloud_rate_limit_gate';
private const SHELLY_STATUS_SNAPSHOT_TTL_SECONDS = 1;
private const SHELLY_STATUS_SNAPSHOT_KEY_PREFIX = 'selfserve_lane_shelly_status_snapshot_';
private const SHELLY_DEFAULT_CHANNEL = 0;
/**
* Get current MACHINE relay status from Shelly.
@@ -57,15 +59,15 @@ trait selfserve_lane_relay_controller_t
public function getRelayStatus(selfserve_lane_relay $relay): array
{
$relay_id = $this->getRelayId($relay);
$devices = $this->fetchRelaySwitches($relay_id);
if (count($devices) < 1) {
throw new \Exception("No Shelly device state returned for {$relay->name} relay");
$snapshot = $this->getLaneShellyStatusSnapshot([$relay_id]);
if (!array_key_exists($relay_id, $snapshot)) {
throw new \Exception("Shelly did not return a status entry for {$relay->name} relay");
}
$device = $devices[0];
$device = $snapshot[$relay_id];
return [
'relay_id' => $relay_id,
'online' => isset($device->online) && (int)$device->online === 1,
'online' => $this->extractRelayOnlineState($device),
'on' => $this->extractRelayOnState($device, $relay),
];
}
@@ -127,92 +129,127 @@ trait selfserve_lane_relay_controller_t
if (empty($this->department_lane)) {
throw new \Exception("Department lane object not found for lane ID {$this->id}");
}
$relay_id = match ($relay) {
selfserve_lane_relay::MACHINE => (string)$this->department_lane->relay_machine_id->value(),
selfserve_lane_relay::MACHINE_PROGRAM_PICKER => (string)$this->department_lane->relay_machine_program_picker_id->value(),
selfserve_lane_relay::MACHINE_CLEANER => (string)$this->department_lane->relay_machine_cleaner_id->value(),
default => throw new \Exception("Invalid relay type: {$relay->name}"),
};
if ($relay_id === '') {
throw new \Exception("Invalid relay ID for {$relay->name} relay");
}
return $relay_id;
}
/**
* Fetch Shelly switch state objects for a relay ID.
* @param string $relay_id
* @return array<shelly_device_switch>
* @return string[]
* @throws \Exception
*/
private function fetchRelaySwitches(string $relay_id): array
private function getConfiguredLaneRelayIds(): array
{
$shelly = new shelly();
$shelly->requireModuleEnabled();
$shelly->requireValidSecretKey();
if (empty($this->department_lane)) {
throw new \Exception("Department lane object not found for lane ID {$this->id}");
}
$parameters = new shelly_request_body_get_states();
$parameters->ids = [$relay_id];
$parameters->select = ['status'];
$deadline = microtime(true) + self::SHELLY_STATUS_WAIT_TIMEOUT_SECONDS;
$ids = [
(string)$this->department_lane->relay_machine_id->value(),
(string)$this->department_lane->relay_machine_program_picker_id->value(),
(string)$this->department_lane->relay_machine_cleaner_id->value(),
];
$ids = array_values(array_unique(array_filter(array_map('trim', $ids), static fn(string $id): bool => $id !== '')));
if (count($ids) < 1) {
throw new \Exception('No configured Shelly relay IDs for this lane');
}
return $ids;
}
/**
* @param string[] $required_relay_ids
* @return array<string,array>
* @throws \Exception
*/
private function getLaneShellyStatusSnapshot(array $required_relay_ids): array
{
$cached = $this->getCachedLaneShellyStatusSnapshot();
if ($cached !== null && $this->snapshotHasUsablePayload($cached, $required_relay_ids)) {
return $cached;
}
$fresh = $this->fetchLaneShellyStatusSnapshotWithReadiness($required_relay_ids);
$this->setCachedLaneShellyStatusSnapshot($fresh);
return $fresh;
}
/**
* @param string[] $required_relay_ids
* @return array<string,array>
* @throws \Exception
*/
private function fetchLaneShellyStatusSnapshotWithReadiness(array $required_relay_ids): array
{
$relay_ids = $this->getConfiguredLaneRelayIds();
$deadline = $this->nowTimestamp() + self::SHELLY_STATUS_WAIT_TIMEOUT_SECONDS;
$last_reason = 'Shelly relay status is not ready yet';
do {
$result = $shelly->sendPostRequest('/v2/devices/api/get', (array)$parameters);
$devices = $this->normalizeRelaySwitchesResponse($result);
$result = $this->sendShellyPostRateLimited('/v2/devices/api/get', $this->buildStatusGetPayload($relay_ids));
$snapshot = $this->mapResponseToRelaySnapshot($result);
if (count($devices) > 0 && $this->relayStatusPayloadExists($devices[0])) {
return $devices;
if ($this->snapshotHasUsablePayload($snapshot, $required_relay_ids)) {
return $snapshot;
}
$last_reason = $this->describeShellyNotReadyReason($result, $devices);
if (microtime(true) >= $deadline) {
$last_reason = $this->describeShellyNotReadyReason($result, $snapshot, $required_relay_ids);
if ($this->nowTimestamp() >= $deadline) {
break;
}
usleep(self::SHELLY_RETRY_SLEEP_MICROSECONDS);
$this->sleepMicroseconds(self::SHELLY_RETRY_SLEEP_MICROSECONDS);
} while (true);
throw new \Exception($last_reason);
}
/**
* Extract boolean on/off status from a Shelly switch state object.
* @throws \Exception
* @param string[] $relay_ids
*/
private function extractRelayOnState(shelly_device_switch $device, selfserve_lane_relay $relay): bool
private function buildStatusGetPayload(array $relay_ids): array
{
if (isset($device->on)) {
return (bool)$device->on;
}
if (!isset($device->status) || !is_object($device->status)) {
throw new \Exception('Missing status payload from Shelly response');
}
$status = (array)$device->status;
foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) {
if (!array_key_exists($switch_key, $status)) {
continue;
}
$switch_state = $status[$switch_key];
if (is_object($switch_state) && isset($switch_state->output)) {
return (bool)$switch_state->output;
}
if (is_array($switch_state) && array_key_exists('output', $switch_state)) {
return (bool)$switch_state['output'];
}
}
throw new \Exception("Unable to determine {$relay->name} relay state from Shelly status payload");
$parameters = new shelly_request_body_get_states();
$parameters->ids = $relay_ids;
$parameters->select = ['status'];
return (array)$parameters;
}
/**
* Normalize Shelly relay status response to a switch object list.
* @param array|object|null $response
* @return array<shelly_device_switch>
* @return array<string,array>
*/
private function normalizeRelaySwitchesResponse(array|object|null $response): array
private function mapResponseToRelaySnapshot(array|object|null $response): array
{
$snapshot = [];
foreach ($this->normalizeRelayDevicesResponse($response) as $device) {
$id = isset($device['id']) ? trim((string)$device['id']) : '';
if ($id === '') {
continue;
}
$snapshot[$id] = $this->normalizeRelaySnapshotEntry($device);
}
return $snapshot;
}
/**
* @param array|object|null $response
* @return array<int,array>
*/
private function normalizeRelayDevicesResponse(array|object|null $response): array
{
if ($response === null) {
return [];
}
if (is_object($response)) {
$response = [$response];
}
@@ -221,67 +258,143 @@ trait selfserve_lane_relay_controller_t
}
$devices = [];
foreach ($response as $device) {
if (!is_array($device) && !is_object($device)) {
foreach ($response as $item) {
if (is_object($item)) {
$item = $this->normalizeRelaySnapshotEntry($item);
}
if (!is_array($item)) {
continue;
}
$devices[] = (new shelly_device_switch())->populate($device);
$devices[] = $this->normalizeRelaySnapshotEntry($item);
}
return $devices;
}
/**
* Determine if a parsed Shelly device has enough payload to resolve relay state.
* @param array|object $device
* @return array
*/
private function relayStatusPayloadExists(shelly_device_switch $device): bool
private function normalizeRelaySnapshotEntry(array|object $device): array
{
if (isset($device->on)) {
return true;
$encoded = json_encode($device, JSON_UNESCAPED_UNICODE);
if (!is_string($encoded)) {
return [];
}
if (!isset($device->status) || !is_object($device->status)) {
return false;
}
$status = (array)$device->status;
foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) {
if (!array_key_exists($switch_key, $status)) {
continue;
}
$switch_state = $status[$switch_key];
if (is_object($switch_state) && isset($switch_state->output)) {
return true;
}
if (is_array($switch_state) && array_key_exists('output', $switch_state)) {
return true;
}
}
return false;
$decoded = json_decode($encoded, true);
return is_array($decoded) ? $decoded : [];
}
/**
* Build a user-facing reason while waiting for Shelly to become ready.
* @param array|object|null $response
* @param array<shelly_device_switch> $devices
* @param array<string,array> $snapshot
* @param string[] $required_relay_ids
*/
private function describeShellyNotReadyReason(array|object|null $response, array $devices): string
private function snapshotHasUsablePayload(array $snapshot, array $required_relay_ids): bool
{
foreach ($required_relay_ids as $relay_id) {
if (!array_key_exists($relay_id, $snapshot)) {
return false;
}
if (!$this->relayStatusPayloadExists($snapshot[$relay_id])) {
return false;
}
}
return true;
}
private function relayStatusPayloadExists(array $device): bool
{
$status = $this->getPayloadValue($device, 'status');
if ($status !== null) {
foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) {
$switch_state = $this->getPayloadValue($status, $switch_key);
if ($switch_state === null) {
continue;
}
if ($this->getPayloadValue($switch_state, 'output') !== null) {
return true;
}
}
}
return $this->getPayloadValue($device, 'on') !== null;
}
private function extractRelayOnlineState(array $device): bool
{
$online = $this->getPayloadValue($device, 'online');
if ($online === null) {
return false;
}
if (is_bool($online)) {
return $online;
}
return (int)$online === 1;
}
/**
* Extract boolean on/off status from Shelly switch payload.
* @throws \Exception
*/
private function extractRelayOnState(array $device, selfserve_lane_relay $relay): bool
{
$direct = $this->getPayloadValue($device, 'on');
if ($direct !== null) {
return (bool)$direct;
}
$status = $this->getPayloadValue($device, 'status');
foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) {
$switch_state = $this->getPayloadValue($status, $switch_key);
if ($switch_state === null) {
continue;
}
$output = $this->getPayloadValue($switch_state, 'output');
if ($output !== null) {
return (bool)$output;
}
}
throw new \Exception("Unable to determine {$relay->name} relay state from Shelly status payload");
}
private function getPayloadValue(array|object|null $payload, string $key): mixed
{
if (is_array($payload)) {
return $payload[$key] ?? null;
}
if (is_object($payload)) {
return $payload->$key ?? null;
}
return null;
}
/**
* @param array|object|null $response
* @param array<string,array> $snapshot
* @param string[] $required_relay_ids
*/
private function describeShellyNotReadyReason(array|object|null $response, array $snapshot, array $required_relay_ids): string
{
$payload_text = strtolower($this->serializeShellyResponse($response));
// Shelly may respond with rate-limit style payloads when polled too quickly.
foreach (['rate limit', 'ratelimit', 'too many', '429', 'throttle', 'retry'] as $token) {
if (str_contains($payload_text, $token)) {
return 'Shelly rate limit reached, waiting for next available window';
}
}
if (count($devices) < 1) {
return 'Shelly returned no device status yet';
foreach ($required_relay_ids as $relay_id) {
if (!array_key_exists($relay_id, $snapshot)) {
return 'Shelly returned no device status yet';
}
if (!$this->relayStatusPayloadExists($snapshot[$relay_id])) {
return 'Shelly relay status payload is not ready yet';
}
}
return 'Shelly relay status payload is not ready yet';
return 'Shelly relay status is not ready yet';
}
/**
* Serialize a Shelly response payload into a safe compact string.
*/
private function serializeShellyResponse(array|object|null $response): string
{
if ($response === null) {
@@ -290,44 +403,174 @@ trait selfserve_lane_relay_controller_t
if (is_scalar($response)) {
return (string)$response;
}
if (is_array($response) || is_object($response)) {
$encoded = json_encode($response, JSON_UNESCAPED_UNICODE);
return is_string($encoded) ? $encoded : '';
$encoded = json_encode($response, JSON_UNESCAPED_UNICODE);
return is_string($encoded) ? $encoded : '';
}
private function getLaneShellyStatusSnapshotCacheKey(): string
{
return self::SHELLY_STATUS_SNAPSHOT_KEY_PREFIX . (int)$this->id;
}
/**
* @return array<string,array>|null
*/
private function getCachedLaneShellyStatusSnapshot(): ?array
{
$redis = $this->redisFacade();
if ($redis === null) {
return null;
}
return '';
$raw = $redis->get($this->getLaneShellyStatusSnapshotCacheKey());
if (!is_string($raw) || $raw === '') {
return null;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return null;
}
$snapshot = [];
foreach ($decoded as $relay_id => $entry) {
$id = trim((string)$relay_id);
if ($id === '' || !is_array($entry)) {
continue;
}
$snapshot[$id] = $entry;
}
return $snapshot;
}
/**
* @param array<string,array> $snapshot
*/
private function setCachedLaneShellyStatusSnapshot(array $snapshot): void
{
$redis = $this->redisFacade();
if ($redis === null) {
return;
}
$encoded = json_encode($snapshot, JSON_UNESCAPED_UNICODE);
if (!is_string($encoded)) {
return;
}
$redis->setEx(
$this->getLaneShellyStatusSnapshotCacheKey(),
$encoded,
self::SHELLY_STATUS_SNAPSHOT_TTL_SECONDS
);
}
/**
* Execute switch command and retry while Shelly is rate-limited.
* @throws \Exception
*/
private function switchRelayWithRetry(
shelly_device_switch $device,
bool $on,
selfserve_lane_relay $relay
): void {
$deadline = microtime(true) + self::SHELLY_STATUS_WAIT_TIMEOUT_SECONDS;
private function sendShellyPostRateLimited(string $endpoint, array $payload): array|object|null
{
$this->waitForShellyRateLimitWindow();
return $this->sendShellyPost($endpoint, $payload);
}
/**
* @throws \Exception
*/
private function waitForShellyRateLimitWindow(): void
{
$deadline = $this->nowTimestamp() + self::SHELLY_STATUS_WAIT_TIMEOUT_SECONDS;
do {
$switch_response = $device->switch($on, true);
$payload_text = strtolower($this->serializeShellyResponse($switch_response));
$rate_limited = false;
foreach (['rate limit', 'ratelimit', 'too many', '429', 'throttle', 'retry'] as $token) {
if (str_contains($payload_text, $token)) {
$rate_limited = true;
break;
}
}
if (!$rate_limited) {
if ($this->tryAcquireShellyRateLimitSlot()) {
return;
}
if (microtime(true) >= $deadline) {
throw new \Exception("Shelly rate limit prevented switching {$relay->name} relay in time");
if ($this->nowTimestamp() >= $deadline) {
throw new \Exception('Shelly rate limit gate wait timed out');
}
usleep(self::SHELLY_RETRY_SLEEP_MICROSECONDS);
$remaining_ms = $this->getShellyRateLimitSlotRemainingMs();
if ($remaining_ms <= 0) {
$remaining_ms = 50;
}
$this->sleepMicroseconds(min($remaining_ms, 250) * 1000);
} while (true);
}
private function tryAcquireShellyRateLimitSlot(): bool
{
$redis = $this->redisFacade();
if ($redis === null) {
return true;
}
try {
$result = $redis->get_client()->set(
self::SHELLY_RATE_LIMIT_GATE_KEY,
(string)$this->nowTimestamp(),
'PX',
self::SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS,
'NX'
);
return $result === true || strtoupper((string)$result) === 'OK';
} catch (\Throwable) {
// If Redis gate can't be evaluated, fail open to avoid blocking relay control completely.
return true;
}
}
private function getShellyRateLimitSlotRemainingMs(): int
{
$redis = $this->redisFacade();
if ($redis === null) {
return 0;
}
try {
$ttl = $redis->get_client()->pttl(self::SHELLY_RATE_LIMIT_GATE_KEY);
if (!is_numeric($ttl)) {
return 0;
}
$ttl = (int)$ttl;
return $ttl > 0 ? $ttl : 0;
} catch (\Throwable) {
return 0;
}
}
/**
* @throws \Exception
*/
protected function sendShellyPost(string $endpoint, array $payload): array|object|null
{
$shelly = $this->createShellyClient();
$shelly->requireModuleEnabled();
$shelly->requireValidSecretKey();
return $shelly->sendPostRequest($endpoint, $payload);
}
protected function createShellyClient(): shelly
{
return new shelly();
}
protected function redisFacade(): mixed
{
return defined('redis') ? redis : null;
}
protected function nowTimestamp(): float
{
return microtime(true);
}
protected function sleepMicroseconds(int $microseconds): void
{
if ($microseconds > 0) {
usleep($microseconds);
}
}
/**
* Turn on the lane relay
* @param selfserve_lane_relay $relay The relay to turn on (MACHINE or MACHINE_PROGRAM_PICKER)
@@ -337,33 +580,19 @@ trait selfserve_lane_relay_controller_t
*/
public function turnOnRelay(selfserve_lane_relay $relay, ?int $duration = null): bool
{
// Require department lane object
if (empty($this->department_lane)) throw new \Exception("Department lane object not found for lane ID {$this->id}");
// Check lane status (ensure initialization via getter)
if ($this->getLaneStatus()->equals(selfserve_lane_status::CLOSED)) throw new \Exception("Cannot turn on relay on CLOSED lane");
if ($this->getLaneStatus()->equals(selfserve_lane_status::MAINTENANCE)) throw new \Exception("Cannot turn on relay on MAINTENANCE lane");
if ($this->getLaneStatus()->equals(selfserve_lane_status::FAULT)) throw new \Exception("Cannot turn on relay on FAULT lane");
// Gating
if ($relay === selfserve_lane_relay::MACHINE) {
// Allowed services are stored as an array of names in lane cache
$allowed = $this->getLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES);
if (!is_array($allowed) || !in_array(selfserve_lane_services::MACHINE->name, $allowed, true)) {
throw new \Exception("MACHINE relay is not allowed to be enabled at this time");
}
}
// Get the relay ID based on the relay type
$relay_id = $this->getRelayId($relay);
$result = $this->fetchRelaySwitches($relay_id);
// Turn on the switch
foreach ($result as $device) {
// Ensure machine switches never auto-toggle off; enforce toggle_after = 0
$device->toggle_after = 0;
$this->switchRelayWithRetry($device, true, $relay);
}
return true;
return $this->sendRelaySwitchCommand($relay, true, $duration);
}
/**
@@ -387,23 +616,12 @@ trait selfserve_lane_relay_controller_t
*/
public function forceTurnOnRelay(selfserve_lane_relay $relay, ?int $duration = null): bool
{
// Require department lane object
if (empty($this->department_lane)) throw new \Exception("Department lane object not found for lane ID {$this->id}");
// Basic sanity checks on lane status (still disallow clearly invalid states)
if ($this->getLaneStatus()->equals(selfserve_lane_status::CLOSED)) throw new \Exception("Cannot turn on relay on CLOSED lane");
if ($this->getLaneStatus()->equals(selfserve_lane_status::MAINTENANCE)) throw new \Exception("Cannot turn on relay on MAINTENANCE lane");
if ($this->getLaneStatus()->equals(selfserve_lane_status::FAULT)) throw new \Exception("Cannot turn on relay on FAULT lane");
// Directly control Shelly without checking allowed services
$relay_id = $this->getRelayId($relay);
$result = $this->fetchRelaySwitches($relay_id);
// Turn on the switch
foreach ($result as $device) {
// Ensure machine switches never auto-toggle off; enforce toggle_after = 0
$device->toggle_after = 0;
$this->switchRelayWithRetry($device, true, $relay);
}
return true;
return $this->sendRelaySwitchCommand($relay, true, $duration);
}
/**
@@ -425,21 +643,12 @@ trait selfserve_lane_relay_controller_t
*/
public function forceTurnOffRelay(selfserve_lane_relay $relay): bool
{
// Require department lane object
if (empty($this->department_lane)) throw new \Exception("Department lane object not found for lane ID {$this->id}");
// Basic sanity checks on lane status (still disallow clearly invalid states)
if ($this->getLaneStatus()->equals(selfserve_lane_status::CLOSED)) throw new \Exception("Cannot turn off relay on CLOSED lane");
if ($this->getLaneStatus()->equals(selfserve_lane_status::MAINTENANCE)) throw new \Exception("Cannot turn off relay on MAINTENANCE lane");
if ($this->getLaneStatus()->equals(selfserve_lane_status::FAULT)) throw new \Exception("Cannot turn off relay on FAULT lane");
// Directly control Shelly without checking allowed services
$relay_id = $this->getRelayId($relay);
$result = $this->fetchRelaySwitches($relay_id);
// Turn off the switch
foreach ($result as $device) {
$this->switchRelayWithRetry($device, false, $relay);
}
return true;
return $this->sendRelaySwitchCommand($relay, false);
}
/**
@@ -450,21 +659,82 @@ trait selfserve_lane_relay_controller_t
*/
public function turnOffRelay(selfserve_lane_relay $relay): bool
{
// Require department lane object
if (empty($this->department_lane)) throw new \Exception("Department lane object not found for lane ID {$this->id}");
// Check lane status (ensure initialization via getter)
if ($this->getLaneStatus()->equals(selfserve_lane_status::CLOSED)) throw new \Exception("Cannot turn off relay on CLOSED lane");
if ($this->getLaneStatus()->equals(selfserve_lane_status::MAINTENANCE)) throw new \Exception("Cannot turn off relay on MAINTENANCE lane");
if ($this->getLaneStatus()->equals(selfserve_lane_status::FAULT)) throw new \Exception("Cannot turn off relay on FAULT lane");
// Get the relay ID based on the relay type
$relay_id = $this->getRelayId($relay);
$result = $this->fetchRelaySwitches($relay_id);
// Turn off the switch
foreach ($result as $device) {
$this->switchRelayWithRetry($device, false, $relay);
return $this->sendRelaySwitchCommand($relay, false);
}
/**
* @throws \Exception
*/
private function sendRelaySwitchCommand(selfserve_lane_relay $relay, bool $on, ?int $duration = null): bool
{
$relay_id = $this->getRelayId($relay);
$payload = [
'id' => $relay_id,
'channel' => self::SHELLY_DEFAULT_CHANNEL,
'on' => $on,
// We keep toggle_after=0 to avoid unintended auto toggle behavior.
'toggle_after' => 0,
];
if ($duration !== null && $duration > 0) {
// Current relay API behavior intentionally keeps manual relay commands explicit;
// duration is accepted by route contracts but does not auto-toggle at Shelly level.
$payload['toggle_after'] = 0;
}
$response = $this->sendShellyPostRateLimited('/v2/devices/api/set/switch', $payload);
$this->seedLaneShellySnapshotFromSwitch($relay_id, $on, $response);
return true;
}
/**
* @throws \Exception
*/
private function seedLaneShellySnapshotFromSwitch(string $relay_id, bool $on, array|object|null $response): void
{
$snapshot = $this->getCachedLaneShellyStatusSnapshot() ?? [];
$existing = $snapshot[$relay_id] ?? [];
if (!is_array($existing)) {
$existing = [];
}
$seed = [
'id' => $relay_id,
'on' => $on,
'status' => ['switch:0' => ['output' => $on]],
];
$response_entry = $this->extractRelaySnapshotEntryFromSwitchResponse($response, $relay_id);
if ($response_entry !== null) {
$seed = array_replace_recursive($seed, $response_entry);
}
$snapshot[$relay_id] = array_replace_recursive($existing, $seed);
$this->setCachedLaneShellyStatusSnapshot($snapshot);
}
/**
* @return array|null
*/
private function extractRelaySnapshotEntryFromSwitchResponse(array|object|null $response, string $relay_id): ?array
{
$devices = $this->normalizeRelayDevicesResponse($response);
if (count($devices) < 1) {
return null;
}
foreach ($devices as $device) {
$id = isset($device['id']) ? (string)$device['id'] : '';
if ($id === $relay_id) {
return $device;
}
}
return $devices[0];
}
}
@@ -9,11 +9,15 @@ 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 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
@@ -50,6 +54,123 @@ class moduleSelfServeRoute
]
);
/** 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);
$rows = (new selfserve_wash_sessions_o())->getFieldsWhere([
'lane_id' => $lane_id,
'completed_at' => null,
'deleted_at' => null,
], ['id']);
if ($rows === []) {
$response->success([
'lane_id' => $lane_id,
'in_progress' => false,
'session' => null,
'customer' => null,
'vehicle' => null,
]);
return;
}
usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']);
$session = (new selfserve_wash_sessions_o())->select((int)$rows[0]['id']);
if (!$session->exists()) {
$response->success([
'lane_id' => $lane_id,
'in_progress' => false,
'session' => null,
'customer' => null,
'vehicle' => null,
]);
return;
}
$customer = null;
$customer_number = $session->customer_number->value() === null ? null : (int)$session->customer_number->value();
if ($customer_number !== null && $customer_number > 0) {
$customer_obj = (new users_o())->getUserByCustomerNumber($customer_number);
if ($customer_obj->exists()) {
$customer = [
'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(),
];
} else {
$customer = [
'id' => null,
'customer_number' => $customer_number,
'display_name' => null,
'email' => null,
'phone_country_code' => null,
'phone' => null,
];
}
}
$vehicle = null;
$vehicle_obj = null;
$vehicle_id = $session->vehicle_id->value() === null ? null : (int)$session->vehicle_id->value();
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) {
$session_reg = trim((string)$session->reg->value());
if ($session_reg !== '') {
$tmp_vehicle = (new customer_vehicles_o())->selectByPlate($session_reg);
if ($tmp_vehicle->exists()) {
$vehicle_obj = $tmp_vehicle;
}
}
}
if ($vehicle_obj !== null) {
$vehicle = [
'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(),
];
}
$response->success([
'lane_id' => $lane_id,
'in_progress' => true,
'session' => [
'id' => (int)$session->id,
'status' => (string)$session->status->value(),
'reg' => (string)$session->reg->value(),
'customer_number' => $customer_number,
'vehicle_id' => $vehicle_id,
'vehicle_type_id' => $session->vehicle_type_id->value() === null ? null : (int)$session->vehicle_type_id->value(),
'machine_type_id' => $session->machine_type_id->value() === null ? null : (int)$session->machine_type_id->value(),
'machine_start_triggered' => (bool)$session->machine_start_triggered->value(),
'machine_start_triggered_at' => $session->machine_start_triggered_at->value() === null ? null : (string)$session->machine_start_triggered_at->value(),
'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;
@@ -177,6 +298,42 @@ class moduleSelfServeRoute
'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 {
$lane->open($gate);
$response->success([
'lane_id' => $lane_id,
'gate' => $gate->name,
'opened' => true,
'state' => $lane->getLaneState()->name,
]);
} catch (\Exception $e) {
$response->error('Failed to open lane gate: ' . $e->getMessage(), 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;
@@ -0,0 +1,381 @@
<?php
app_require('modules/selfserve/traits/selfserve_lane_relay_controller_t.php');
app_require('modules/selfserve/helpers/selfserve_lane_relay.php');
app_require('modules/selfserve/helpers/selfserve_lane_status.php');
use modules\selfserve\helpers\selfserve_lane_relay;
use modules\selfserve\helpers\selfserve_lane_status;
use modules\selfserve\traits\selfserve_lane_relay_controller_t;
class SelfserveRelayValueFake
{
public function __construct(private readonly string $value) {}
public function value(): string
{
return $this->value;
}
}
class SelfserveDepartmentLaneRelayFake
{
public SelfserveRelayValueFake $relay_machine_id;
public SelfserveRelayValueFake $relay_machine_program_picker_id;
public SelfserveRelayValueFake $relay_machine_cleaner_id;
public function __construct(
string $machineRelayId = 'relay-machine',
string $programPickerRelayId = 'relay-program',
string $cleanerRelayId = 'relay-cleaner'
) {
$this->relay_machine_id = new SelfserveRelayValueFake($machineRelayId);
$this->relay_machine_program_picker_id = new SelfserveRelayValueFake($programPickerRelayId);
$this->relay_machine_cleaner_id = new SelfserveRelayValueFake($cleanerRelayId);
}
}
class SelfserveShellyRedisClientFake
{
public function __construct(private readonly SelfserveShellyRedisFake $owner) {}
public function set(string $key, string $value, mixed ...$args): bool|string
{
return $this->owner->clientSet($key, $value, $args);
}
public function pttl(string $key): int
{
return $this->owner->clientPttl($key);
}
}
class SelfserveShellyRedisFake
{
/** @var array<string,string> */
private array $store = [];
/** @var array<string,float> */
private array $expiresAt = [];
private SelfserveShellyRedisClientFake $client;
/** @var callable():float */
private $nowProvider;
public function __construct(callable $nowProvider)
{
$this->nowProvider = $nowProvider;
$this->client = new SelfserveShellyRedisClientFake($this);
}
public function get(string $key): ?string
{
$this->purgeExpired($key);
return $this->store[$key] ?? null;
}
public function setEx(string $key, string $value, int $ttl): self
{
$this->store[$key] = $value;
$this->expiresAt[$key] = $this->now() + max(1, $ttl);
return $this;
}
public function get_client(): SelfserveShellyRedisClientFake
{
return $this->client;
}
public function clientSet(string $key, string $value, array $args): bool|string
{
$this->purgeExpired($key);
$useNx = false;
$ttlMs = null;
$count = count($args);
for ($i = 0; $i < $count; $i++) {
$token = strtoupper((string)$args[$i]);
if ($token === 'NX') {
$useNx = true;
continue;
}
if ($token === 'PX' && isset($args[$i + 1])) {
$ttlMs = max(1, (int)$args[$i + 1]);
$i++;
}
}
if ($useNx && array_key_exists($key, $this->store)) {
return false;
}
$this->store[$key] = $value;
if ($ttlMs === null) {
unset($this->expiresAt[$key]);
} else {
$this->expiresAt[$key] = $this->now() + ($ttlMs / 1000);
}
return 'OK';
}
public function clientPttl(string $key): int
{
$this->purgeExpired($key);
if (!array_key_exists($key, $this->store)) {
return -2;
}
if (!isset($this->expiresAt[$key])) {
return -1;
}
$remainingMs = (int)ceil(($this->expiresAt[$key] - $this->now()) * 1000);
return $remainingMs > 0 ? $remainingMs : 0;
}
private function now(): float
{
$provider = $this->nowProvider;
return (float)$provider();
}
private function purgeExpired(string $key): void
{
if (!isset($this->expiresAt[$key])) {
return;
}
if ($this->now() < $this->expiresAt[$key]) {
return;
}
unset($this->expiresAt[$key], $this->store[$key]);
}
}
class SelfserveLaneRelayControllerHarness
{
use selfserve_lane_relay_controller_t;
public const CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES = 'allowed_services';
public int $id = 1;
public object $department_lane;
public float $now = 0.0;
/** @var array<int,int> */
public array $sleepCalls = [];
/** @var array<int,array{endpoint:string,payload:array,time:float}> */
public array $shellyCalls = [];
/** @var array<string,array<int,array|object|null>> */
private array $queuedResponses = [];
private selfserve_lane_status $laneStatus;
private ?SelfserveShellyRedisFake $redis;
public function __construct(?SelfserveShellyRedisFake $redis = null)
{
$this->department_lane = new SelfserveDepartmentLaneRelayFake();
$this->laneStatus = selfserve_lane_status::AVAILABLE;
$this->redis = $redis;
}
public function queueShellyResponse(string $endpoint, array|object|null $response): void
{
if (!isset($this->queuedResponses[$endpoint])) {
$this->queuedResponses[$endpoint] = [];
}
$this->queuedResponses[$endpoint][] = $response;
}
public function getShellyCallCount(string $endpoint): int
{
$count = 0;
foreach ($this->shellyCalls as $call) {
if ($call['endpoint'] === $endpoint) {
$count++;
}
}
return $count;
}
public function getLaneStatus(): selfserve_lane_status
{
return $this->laneStatus;
}
public function setLaneStatus(selfserve_lane_status $laneStatus): void
{
$this->laneStatus = $laneStatus;
}
public function getLaneCache(int $lane_id, string $key): mixed
{
return [];
}
protected function sendShellyPost(string $endpoint, array $payload): array|object|null
{
$this->shellyCalls[] = [
'endpoint' => $endpoint,
'payload' => $payload,
'time' => $this->now,
];
if (!isset($this->queuedResponses[$endpoint]) || count($this->queuedResponses[$endpoint]) < 1) {
return [];
}
return array_shift($this->queuedResponses[$endpoint]);
}
protected function redisFacade(): mixed
{
return $this->redis;
}
protected function nowTimestamp(): float
{
return $this->now;
}
protected function sleepMicroseconds(int $microseconds): void
{
if ($microseconds > 0) {
$this->sleepCalls[] = $microseconds;
$this->now += ($microseconds / 1000000);
}
}
}
function selfserve_lane_shelly_test_harness(bool $withRedis = true): SelfserveLaneRelayControllerHarness
{
$harness = null;
$redis = null;
if ($withRedis) {
$redis = new SelfserveShellyRedisFake(static function () use (&$harness): float {
return $harness?->now ?? 0.0;
});
}
$harness = new SelfserveLaneRelayControllerHarness($redis);
return $harness;
}
it('batches sequential machine relay status requests into a single Shelly get call', function (): void {
$harness = selfserve_lane_shelly_test_harness();
$harness->queueShellyResponse('/v2/devices/api/get', [
[
'id' => 'relay-machine',
'online' => true,
'status' => ['switch:0' => ['output' => true]],
],
[
'id' => 'relay-program',
'online' => true,
'status' => ['switch:0' => ['output' => false]],
],
[
'id' => 'relay-cleaner',
'online' => true,
'status' => ['switch:0' => ['output' => true]],
],
]);
$machine = $harness->getMachineRelayStatus();
$programPicker = $harness->getMachineProgramPickerRelayStatus();
$cleaner = $harness->getMachineCleanerRelayStatus();
expect($machine['relay_id'])->toBe('relay-machine');
expect($machine['on'])->toBeTrue();
expect($programPicker['relay_id'])->toBe('relay-program');
expect($programPicker['on'])->toBeFalse();
expect($cleaner['relay_id'])->toBe('relay-cleaner');
expect($cleaner['on'])->toBeTrue();
expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(1);
expect($harness->shellyCalls[0]['payload']['ids'])->toBe(['relay-machine', 'relay-program', 'relay-cleaner']);
expect($harness->shellyCalls[0]['payload']['select'])->toBe(['status']);
});
it('respects the 1 request per second Shelly gate for back-to-back requests', function (): void {
$harness = selfserve_lane_shelly_test_harness();
$harness->queueShellyResponse('/v2/devices/api/get', [
[
'id' => 'relay-machine',
'online' => true,
'status' => ['switch:0' => ['output' => true]],
],
]);
$harness->queueShellyResponse('/v2/devices/api/set/switch', [
[
'id' => 'relay-machine',
'online' => true,
'status' => ['switch:0' => ['output' => false]],
],
]);
$harness->getMachineRelayStatus();
$harness->setMachineRelayStatus(false);
expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(1);
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1);
expect($harness->shellyCalls[0]['endpoint'])->toBe('/v2/devices/api/get');
expect($harness->shellyCalls[1]['endpoint'])->toBe('/v2/devices/api/set/switch');
expect(array_sum($harness->sleepCalls))->toBeGreaterThanOrEqual(1000000);
});
it('retries missing Shelly status payloads until relay status becomes ready', function (): void {
$harness = selfserve_lane_shelly_test_harness(false);
$harness->queueShellyResponse('/v2/devices/api/get', [
['id' => 'relay-machine', 'online' => true],
]);
$harness->queueShellyResponse('/v2/devices/api/get', [
[
'id' => 'relay-machine',
'online' => true,
'status' => ['switch:0' => ['output' => true]],
],
]);
$status = $harness->getMachineRelayStatus();
expect($status['relay_id'])->toBe('relay-machine');
expect($status['on'])->toBeTrue();
expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(2);
expect($harness->sleepCalls)->toContain(250000);
});
it('times out with a clear Shelly readiness error when payload remains missing', function (): void {
$harness = selfserve_lane_shelly_test_harness(false);
for ($i = 0; $i < 100; $i++) {
$harness->queueShellyResponse('/v2/devices/api/get', [
['id' => 'relay-machine', 'online' => true],
]);
}
$exception = null;
try {
$harness->getMachineRelayStatus();
} catch (\Exception $e) {
$exception = $e;
}
expect($exception)->toBeInstanceOf(\Exception::class);
expect($exception?->getMessage())->toContain('not ready');
expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBeGreaterThan(1);
});
it('uses direct set/switch and seeds cache so immediate status read does not call Shelly get', function (): void {
$harness = selfserve_lane_shelly_test_harness();
$harness->queueShellyResponse('/v2/devices/api/set/switch', [
[
'id' => 'relay-program',
'online' => true,
'status' => ['switch:0' => ['output' => true]],
],
]);
$harness->setMachineProgramPickerRelayStatus(true);
$status = $harness->getMachineProgramPickerRelayStatus();
expect($status['relay_id'])->toBe('relay-program');
expect($status['on'])->toBeTrue();
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1);
expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(0);
expect($harness->shellyCalls[0]['endpoint'])->toBe('/v2/devices/api/set/switch');
});
@@ -31,8 +31,10 @@ it('documents self-serve machine type, eligibility, summary, and webhook endpoin
expect($content)->toContain('/department/selfserve/vehicle/allowed:');
expect($content)->toContain('/department/selfserve/washes/summary:');
expect($content)->toContain('/relay/button/press/post:');
expect($content)->toContain('/modules/self-serve/lane/wash/in-progress:');
expect($content)->toContain('/modules/self-serve/lane/relay/machine/status:');
expect($content)->toContain('/modules/self-serve/lane/relay/machine/set:');
expect($content)->toContain('/modules/self-serve/lane/gate/open:');
expect($content)->toContain('/modules/self-serve/lane/relay/machine_program_picker/status:');
expect($content)->toContain('/modules/self-serve/lane/relay/machine_program_picker/set:');
expect($content)->toContain('/modules/self-serve/lane/relay/machine_cleaner/status:');
@@ -49,3 +49,24 @@ it('wires machine relay status get and set endpoints', function (): void {
expect($moduleSelfServeRoute)->toContain('getMachineCleanerRelayStatus');
expect($moduleSelfServeRoute)->toContain('setMachineCleanerRelayStatus');
});
it('wires self-serve lane gate open endpoint', function (): void {
$moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php'));
expect($moduleSelfServeRoute)->not->toBeFalse();
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/gate/open');
expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_gate_open');
expect($moduleSelfServeRoute)->toContain('selfserve_lane_port::ENTRANCE');
expect($moduleSelfServeRoute)->toContain('selfserve_lane_port::EXIT');
expect($moduleSelfServeRoute)->toContain('$lane->open($gate)');
});
it('wires in-progress self-serve wash details endpoint', function (): void {
$moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php'));
expect($moduleSelfServeRoute)->not->toBeFalse();
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/wash/in-progress');
expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_wash_in_progress_view');
expect($moduleSelfServeRoute)->toContain("'in_progress' => true");
expect($moduleSelfServeRoute)->toContain("'in_progress' => false");
});