Add MACHINE_PROGRAM_PICKER and MACHINE_CLEANER relay endpoints with GET/POST actions, unit tests, retry logic, and OpenAPI spec updates.

This commit is contained in:
Jeppe Bundgaard
2026-03-25 12:21:47 +01:00
parent 7fc70184df
commit 5a29683fe8
7 changed files with 318 additions and 70 deletions
+149 -1
View File
@@ -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:
@@ -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. |
@@ -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<shelly_device_switch>
*/
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<shelly_device_switch> $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;
}
@@ -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);
}
@@ -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);
@@ -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 {
@@ -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');
});