diff --git a/openapi.yaml b/openapi.yaml index 986732af..eb9b20ca 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -7392,6 +7392,154 @@ paths: '403': $ref: '#/components/responses/Forbidden' + /modules/self-serve/lane/relay/machine_program_picker/status: + get: + tags: + - Modules + summary: Get MACHINE_PROGRAM_PICKER relay status for a lane + description: | + Reads the current Shelly MACHINE_PROGRAM_PICKER relay status (`on`/`off`) for the given lane. + operationId: getSelfServeLaneMachineProgramPickerRelayStatus + parameters: + - name: lane_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: MACHINE_PROGRAM_PICKER relay status retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/SelfServeLaneMachineRelayStatus' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/relay/machine_program_picker/set: + post: + tags: + - Modules + summary: Set MACHINE_PROGRAM_PICKER relay status for a lane + description: | + Sets the Shelly MACHINE_PROGRAM_PICKER relay state for the lane to on or off and returns the latest status. + operationId: setSelfServeLaneMachineProgramPickerRelayStatus + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + - on + properties: + lane_id: + type: integer + on: + type: boolean + responses: + '200': + description: MACHINE_PROGRAM_PICKER relay status updated + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + relay: + type: string + enum: [MACHINE_PROGRAM_PICKER] + requested_on: + type: boolean + relay_id: + type: string + online: + type: boolean + on: + type: boolean + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/relay/machine_cleaner/status: + get: + tags: + - Modules + summary: Get MACHINE_CLEANER relay status for a lane + description: | + Reads the current Shelly MACHINE_CLEANER relay status (`on`/`off`) for the given lane. + operationId: getSelfServeLaneMachineCleanerRelayStatus + parameters: + - name: lane_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: MACHINE_CLEANER relay status retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/SelfServeLaneMachineRelayStatus' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/relay/machine_cleaner/set: + post: + tags: + - Modules + summary: Set MACHINE_CLEANER relay status for a lane + description: | + Sets the Shelly MACHINE_CLEANER relay state for the lane to on or off and returns the latest status. + operationId: setSelfServeLaneMachineCleanerRelayStatus + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + - on + properties: + lane_id: + type: integer + on: + type: boolean + responses: + '200': + description: MACHINE_CLEANER relay status updated + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + relay: + type: string + enum: [MACHINE_CLEANER] + requested_on: + type: boolean + relay_id: + type: string + online: + type: boolean + on: + type: boolean + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + /modules/self-serve/lane/relay/machine/set: post: tags: @@ -12348,7 +12496,7 @@ components: type: integer relay: type: string - enum: [MACHINE] + enum: [MACHINE, MACHINE_PROGRAM_PICKER, MACHINE_CLEANER] relay_id: type: string online: diff --git a/services/nginx/app/modules/selfserve/selfserve.md b/services/nginx/app/modules/selfserve/selfserve.md index 11bff2ae..24d11d93 100644 --- a/services/nginx/app/modules/selfserve/selfserve.md +++ b/services/nginx/app/modules/selfserve/selfserve.md @@ -358,6 +358,10 @@ Purpose: operational lane control and relay management. | `GET /modules/self-serve/lane/status` | optional `lane_id`, default `1` | `modules_selfserve_lane_status_view` | Returns lane status, mode, state, wash timer, reg, and customer number. | | `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`. | | `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed` | Writes allowed service names to the lane cache. | +| `GET /modules/self-serve/lane/relay/machine_program_picker/status` | `lane_id` | `modules_selfserve_lane_relay_machine_program_picker_status_view` | Reads the Shelly MACHINE_PROGRAM_PICKER relay state (`on`/`off`) for the lane. | +| `POST /modules/self-serve/lane/relay/machine_program_picker/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_program_picker_status_set` | Sets Shelly MACHINE_PROGRAM_PICKER relay state directly (`on=true/false`) and returns updated status. | +| `GET /modules/self-serve/lane/relay/machine_cleaner/status` | `lane_id` | `modules_selfserve_lane_relay_machine_cleaner_status_view` | Reads the Shelly MACHINE_CLEANER relay state (`on`/`off`) for the lane. | +| `POST /modules/self-serve/lane/relay/machine_cleaner/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_cleaner_status_set` | Sets Shelly MACHINE_CLEANER relay state directly (`on=true/false`) and returns updated status. | | `GET /modules/self-serve/lane/relay/machine/status` | `lane_id` | `modules_selfserve_lane_relay_machine_status_view` | Reads the Shelly MACHINE relay state (`on`/`off`) for the lane. | | `POST /modules/self-serve/lane/relay/machine/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_status_set` | Sets Shelly MACHINE relay state directly (`on=true/false`) and returns updated status. | | `POST /modules/self-serve/lane/relay/machine/enable` | `lane_id`, optional `duration` | `modules_selfserve_lane_relay_enable_machine` | Manual enable, still gated by allowed services. | diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_relay_controller_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_relay_controller_t.php index 344b0691..95189d77 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_relay_controller_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_relay_controller_t.php @@ -15,6 +15,9 @@ 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; + /** * Get current MACHINE relay status from Shelly. * @return array{relay_id: string, online: bool, on: bool} @@ -151,18 +154,25 @@ trait selfserve_lane_relay_controller_t $parameters = new shelly_request_body_get_states(); $parameters->ids = [$relay_id]; $parameters->select = ['status']; - $result = $shelly->sendPostRequest('/v2/devices/api/get', (array)$parameters); + $deadline = microtime(true) + self::SHELLY_STATUS_WAIT_TIMEOUT_SECONDS; + $last_reason = 'Shelly relay status is not ready yet'; - if (is_object($result)) { - $result = [$result]; - } - if (!is_array($result)) { - return []; - } + do { + $result = $shelly->sendPostRequest('/v2/devices/api/get', (array)$parameters); + $devices = $this->normalizeRelaySwitchesResponse($result); - return array_map(function ($device) { - return (new shelly_device_switch())->populate($device); - }, $result); + if (count($devices) > 0 && $this->relayStatusPayloadExists($devices[0])) { + return $devices; + } + + $last_reason = $this->describeShellyNotReadyReason($result, $devices); + if (microtime(true) >= $deadline) { + break; + } + usleep(self::SHELLY_RETRY_SLEEP_MICROSECONDS); + } while (true); + + throw new \Exception($last_reason); } /** @@ -196,6 +206,128 @@ trait selfserve_lane_relay_controller_t throw new \Exception("Unable to determine {$relay->name} relay state from Shelly status payload"); } + /** + * Normalize Shelly relay status response to a switch object list. + * @param array|object|null $response + * @return array + */ + private function normalizeRelaySwitchesResponse(array|object|null $response): array + { + if (is_object($response)) { + $response = [$response]; + } + if (!is_array($response)) { + return []; + } + + $devices = []; + foreach ($response as $device) { + if (!is_array($device) && !is_object($device)) { + continue; + } + $devices[] = (new shelly_device_switch())->populate($device); + } + return $devices; + } + + /** + * Determine if a parsed Shelly device has enough payload to resolve relay state. + */ + private function relayStatusPayloadExists(shelly_device_switch $device): bool + { + if (isset($device->on)) { + return true; + } + 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; + } + + /** + * Build a user-facing reason while waiting for Shelly to become ready. + * @param array|object|null $response + * @param array $devices + */ + private function describeShellyNotReadyReason(array|object|null $response, array $devices): 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'; + } + return 'Shelly relay status payload 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) { + return ''; + } + 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 : ''; + } + return ''; + } + + /** + * 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; + + 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) { + return; + } + if (microtime(true) >= $deadline) { + throw new \Exception("Shelly rate limit prevented switching {$relay->name} relay in time"); + } + usleep(self::SHELLY_RETRY_SLEEP_MICROSECONDS); + } while (true); + } + /** * Turn on the lane relay * @param selfserve_lane_relay $relay The relay to turn on (MACHINE or MACHINE_PROGRAM_PICKER) @@ -224,24 +356,12 @@ trait selfserve_lane_relay_controller_t // Get the relay ID based on the relay type $relay_id = $this->getRelayId($relay); - $shelly = new shelly(); - $shelly->requireModuleEnabled(); - $shelly->requireValidSecretKey(); - $parameters = new shelly_request_body_get_states(); - $parameters->ids = [$relay_id]; - $parameters->select = ['status']; - $result = $shelly->sendPostRequest('/v2/devices/api/get', (array)$parameters); - // Wait 1 second - sleep(1); - // Format the result - $result = array_map(function ($device) { - return (new shelly_device_switch())->populate($device); - }, $result); + $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; - $device->switch(true); + $this->switchRelayWithRetry($device, true, $relay); } return true; } @@ -276,24 +396,12 @@ trait selfserve_lane_relay_controller_t // Directly control Shelly without checking allowed services $relay_id = $this->getRelayId($relay); - $shelly = new shelly(); - $shelly->requireModuleEnabled(); - $shelly->requireValidSecretKey(); - $parameters = new shelly_request_body_get_states(); - $parameters->ids = [$relay_id]; - $parameters->select = ['status']; - $result = $shelly->sendPostRequest('/v2/devices/api/get', (array)$parameters); - // Wait 1 second - sleep(1); - // Format the result - $result = array_map(function ($device) { - return (new shelly_device_switch())->populate($device); - }, $result); + $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; - $device->switch(true); + $this->switchRelayWithRetry($device, true, $relay); } return true; } @@ -326,22 +434,10 @@ trait selfserve_lane_relay_controller_t // Directly control Shelly without checking allowed services $relay_id = $this->getRelayId($relay); - $shelly = new shelly(); - $shelly->requireModuleEnabled(); - $shelly->requireValidSecretKey(); - $parameters = new shelly_request_body_get_states(); - $parameters->ids = [$relay_id]; - $parameters->select = ['status']; - $result = $shelly->sendPostRequest('/v2/devices/api/get', (array)$parameters); - // Wait 1 second - sleep(1); - // Format the result - $result = array_map(function ($device) { - return (new shelly_device_switch())->populate($device); - }, $result); + $result = $this->fetchRelaySwitches($relay_id); // Turn off the switch foreach ($result as $device) { - $device->switch(false); + $this->switchRelayWithRetry($device, false, $relay); } return true; } @@ -364,22 +460,10 @@ trait selfserve_lane_relay_controller_t // Get the relay ID based on the relay type $relay_id = $this->getRelayId($relay); - $shelly = new shelly(); - $shelly->requireModuleEnabled(); - $shelly->requireValidSecretKey(); - $parameters = new shelly_request_body_get_states(); - $parameters->ids = [$relay_id]; - $parameters->select = ['status']; - $result = $shelly->sendPostRequest('/v2/devices/api/get', (array)$parameters); - // Wait 1 second - sleep(1); - // Format the result - $result = array_map(function ($device) { - return (new shelly_device_switch())->populate($device); - }, $result); + $result = $this->fetchRelaySwitches($relay_id); // Turn off the switch foreach ($result as $device) { - $device->switch(false); + $this->switchRelayWithRetry($device, false, $relay); } return true; } diff --git a/services/nginx/app/modules/shelly/helpers/shelly_device_switch.php b/services/nginx/app/modules/shelly/helpers/shelly_device_switch.php index d16287c3..c6b07870 100644 --- a/services/nginx/app/modules/shelly/helpers/shelly_device_switch.php +++ b/services/nginx/app/modules/shelly/helpers/shelly_device_switch.php @@ -45,7 +45,7 @@ class shelly_device_switch extends shelly_device_state 'id' => (string)$this->id, 'channel' => (int)$this->channel, 'on' => $on, - 'toggle_after' => $skip_toggle_after ? 0 : (int)$this->toggle_after, + ...($skip_toggle_after ? [] : ['toggle_after' => (int)$this->toggle_after]), ]; return (new shelly())->sendPostRequest('/v2/devices/api/set/switch', $parameters); } diff --git a/services/nginx/app/routes/moduleSelfServeRoute.php b/services/nginx/app/routes/moduleSelfServeRoute.php index 6a4fa48b..58dcdd80 100644 --- a/services/nginx/app/routes/moduleSelfServeRoute.php +++ b/services/nginx/app/routes/moduleSelfServeRoute.php @@ -387,7 +387,7 @@ class moduleSelfServeRoute } $lane = $selfserve->lane($lane_id); try { - $lane->turnOnRelay(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $duration); + $lane->turnOnRelay(selfserve_lane_relay::MACHINE_PROGRAM_PICKER); $response->success(['lane_id' => $lane_id, 'relay' => 'MACHINE_PROGRAM_PICKER', 'enabled' => true, 'duration' => $duration]); } catch (\Exception $e) { $response->error('Failed to enable MACHINE_PROGRAM_PICKER relay: ' . $e->getMessage(), 400); @@ -413,7 +413,7 @@ class moduleSelfServeRoute } $lane = $selfserve->lane($lane_id); try { - $lane->turnOnRelay(selfserve_lane_relay::MACHINE_CLEANER, $duration); + $lane->turnOnRelay(selfserve_lane_relay::MACHINE_CLEANER); $response->success(['lane_id' => $lane_id, 'relay' => 'MACHINE_CLEANER', 'enabled' => true, 'duration' => $duration]); } catch (\Exception $e) { $response->error('Failed to enable MACHINE_CLEANER relay: ' . $e->getMessage(), 400); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php index 620aa544..951b443a 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php @@ -33,6 +33,10 @@ it('documents self-serve machine type, eligibility, summary, and webhook endpoin expect($content)->toContain('/relay/button/press/post:'); 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/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:'); + expect($content)->toContain('/modules/self-serve/lane/relay/machine_cleaner/set:'); }); it('defines reusable self-serve wash and machine type schemas', function (): void { diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php index 25ce17df..e1790a68 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php @@ -38,6 +38,14 @@ it('wires machine relay status get and set endpoints', function (): void { expect($moduleSelfServeRoute)->not->toBeFalse(); expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine/status'); expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine/set'); + expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine_program_picker/status'); + expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine_program_picker/set'); + expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine_cleaner/status'); + expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/relay/machine_cleaner/set'); expect($moduleSelfServeRoute)->toContain('getMachineRelayStatus'); expect($moduleSelfServeRoute)->toContain('setMachineRelayStatus'); + expect($moduleSelfServeRoute)->toContain('getMachineProgramPickerRelayStatus'); + expect($moduleSelfServeRoute)->toContain('setMachineProgramPickerRelayStatus'); + expect($moduleSelfServeRoute)->toContain('getMachineCleanerRelayStatus'); + expect($moduleSelfServeRoute)->toContain('setMachineCleanerRelayStatus'); });