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:
+146
@@ -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:
|
||||
|
||||
+435
-165
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user