595 lines
19 KiB
PHP
595 lines
19 KiB
PHP
<?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 bool $selfServeEnabled = true;
|
|
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,mixed> */
|
|
public array $laneCache = [];
|
|
/** @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 $this->laneCache[$key . '_' . $lane_id] ?? null;
|
|
}
|
|
|
|
public function setLaneCache(int $lane_id, string $key, mixed $value): self
|
|
{
|
|
$this->laneCache[$key . '_' . $lane_id] = $value;
|
|
return $this;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
protected function isDepartmentSelfServeEnabled(): bool
|
|
{
|
|
return $this->selfServeEnabled;
|
|
}
|
|
}
|
|
|
|
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('keeps back-to-back get/switch requests ordered through the relay controller', 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');
|
|
});
|
|
|
|
it('executes sequential switch requests used by wash start and stop flows', function (): void {
|
|
$harness = selfserve_lane_shelly_test_harness();
|
|
|
|
for ($i = 0; $i < 5; $i++) {
|
|
$harness->queueShellyResponse('/v2/devices/api/set/switch', [
|
|
[
|
|
'id' => 'relay-machine',
|
|
'online' => true,
|
|
'status' => ['switch:0' => ['output' => true]],
|
|
],
|
|
]);
|
|
}
|
|
|
|
// START-like sequence.
|
|
$harness->setMachineCleanerRelayStatusHard(true);
|
|
$harness->setMachineRelayStatusHard(true);
|
|
// STOP-like sequence.
|
|
$harness->setMachineCleanerRelayStatusHard(false);
|
|
$harness->setMachineProgramPickerRelayStatusHard(false);
|
|
$harness->setMachineRelayStatusHard(false);
|
|
|
|
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(5);
|
|
});
|
|
|
|
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');
|
|
expect($harness->shellyCalls[0]['payload'])->toMatchArray([
|
|
'id' => 'relay-program',
|
|
'channel' => 0,
|
|
'on' => true,
|
|
]);
|
|
expect(array_key_exists('toggle_after', $harness->shellyCalls[0]['payload']))->toBeFalse();
|
|
});
|
|
|
|
it('supports hard relay set even when lane status is CLOSED', function (): void {
|
|
$harness = selfserve_lane_shelly_test_harness();
|
|
$harness->setLaneStatus(selfserve_lane_status::CLOSED);
|
|
$harness->queueShellyResponse('/v2/devices/api/set/switch', [
|
|
[
|
|
'id' => 'relay-machine',
|
|
'online' => true,
|
|
'status' => ['switch:0' => ['output' => false]],
|
|
],
|
|
]);
|
|
|
|
$result = $harness->setMachineRelayStatusHard(false);
|
|
|
|
expect($result)->toBeTrue();
|
|
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1);
|
|
});
|
|
|
|
it('blocks explicit relay writes when department self-serve is disabled', function (): void {
|
|
$harness = selfserve_lane_shelly_test_harness();
|
|
$harness->selfServeEnabled = false;
|
|
|
|
expect(fn() => $harness->setMachineRelayStatusHard(false))
|
|
->toThrow(\Exception::class, 'Self-serve is not enabled');
|
|
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0);
|
|
});
|
|
|
|
it('enables MACHINE relay when MACHINE is visible in allowed services', function (): void {
|
|
$harness = selfserve_lane_shelly_test_harness();
|
|
|
|
$result = $harness->syncMachineRelayFromVisibleServices(['machine'], true);
|
|
|
|
expect($result)->toMatchArray([
|
|
'machine_visible' => true,
|
|
'relay_action' => 'enabled',
|
|
'relay_target_on' => true,
|
|
]);
|
|
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1);
|
|
expect($harness->shellyCalls[0]['payload'])->toMatchArray([
|
|
'id' => 'relay-machine',
|
|
'channel' => 0,
|
|
'on' => true,
|
|
]);
|
|
});
|
|
|
|
it('turns MACHINE relay off immediately when MACHINE is not visible', function (): void {
|
|
$harness = selfserve_lane_shelly_test_harness();
|
|
|
|
$result = $harness->syncMachineRelayFromVisibleServices(['MACHINE_CLEANER'], true);
|
|
|
|
expect($result)->toMatchArray([
|
|
'machine_visible' => false,
|
|
'relay_action' => 'disabled',
|
|
'relay_target_on' => false,
|
|
]);
|
|
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1);
|
|
expect($harness->shellyCalls[0]['payload'])->toMatchArray([
|
|
'id' => 'relay-machine',
|
|
'channel' => 0,
|
|
'on' => false,
|
|
]);
|
|
});
|
|
|
|
it('does not enable MACHINE relay when allowEnable is false even if MACHINE is visible', function (): void {
|
|
$harness = selfserve_lane_shelly_test_harness();
|
|
|
|
$result = $harness->syncMachineRelayFromVisibleServices(['MACHINE'], false);
|
|
|
|
expect($result)->toMatchArray([
|
|
'machine_visible' => true,
|
|
'relay_action' => 'noop_enable_blocked',
|
|
'relay_target_on' => true,
|
|
]);
|
|
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0);
|
|
});
|
|
|
|
it('returns disabled no-op from machine relay visibility sync when department self-serve is disabled', function (): void {
|
|
$harness = selfserve_lane_shelly_test_harness();
|
|
$harness->selfServeEnabled = false;
|
|
|
|
$result = $harness->syncMachineRelayFromVisibleServices(['machine'], true);
|
|
|
|
expect($result)->toMatchArray([
|
|
'machine_visible' => true,
|
|
'relay_action' => 'noop_selfserve_disabled',
|
|
'relay_target_on' => true,
|
|
]);
|
|
expect($harness->getLaneCache($harness->id, $harness::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES))->toBe(['MACHINE']);
|
|
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0);
|
|
});
|
|
|
|
it('is a safe no-op when MACHINE relay is not configured', function (): void {
|
|
$harness = selfserve_lane_shelly_test_harness();
|
|
$harness->department_lane = new SelfserveDepartmentLaneRelayFake(
|
|
machineRelayId: '',
|
|
programPickerRelayId: 'relay-program',
|
|
cleanerRelayId: 'relay-cleaner'
|
|
);
|
|
|
|
$result = $harness->syncMachineRelayFromVisibleServices(['MACHINE'], true);
|
|
|
|
expect($result)->toMatchArray([
|
|
'machine_visible' => true,
|
|
'relay_action' => 'noop_missing_machine_relay',
|
|
'relay_target_on' => true,
|
|
]);
|
|
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0);
|
|
});
|
|
|
|
it('uses local demo responses and skips Shelly cloud calls for demo relay ids', function (): void {
|
|
$harness = selfserve_lane_shelly_test_harness();
|
|
$harness->department_lane = new SelfserveDepartmentLaneRelayFake(
|
|
machineRelayId: 'demo-machine',
|
|
programPickerRelayId: 'demo-program',
|
|
cleanerRelayId: 'demo-cleaner'
|
|
);
|
|
|
|
$result = $harness->setMachineRelayStatus(true);
|
|
$status = $harness->getMachineRelayStatus();
|
|
|
|
expect($result)->toBeTrue();
|
|
expect($status)->toMatchArray([
|
|
'relay_id' => 'demo-machine',
|
|
'online' => true,
|
|
'on' => true,
|
|
]);
|
|
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0);
|
|
expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(0);
|
|
});
|
|
|
|
it('returns default OFF status for demo relay ids without Shelly lookups', function (): void {
|
|
$harness = selfserve_lane_shelly_test_harness();
|
|
$harness->department_lane = new SelfserveDepartmentLaneRelayFake(
|
|
machineRelayId: 'demo-machine',
|
|
programPickerRelayId: 'demo-program',
|
|
cleanerRelayId: 'demo-cleaner'
|
|
);
|
|
|
|
$status = $harness->getMachineRelayStatus();
|
|
|
|
expect($status)->toMatchArray([
|
|
'relay_id' => 'demo-machine',
|
|
'online' => true,
|
|
'on' => false,
|
|
]);
|
|
expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(0);
|
|
});
|
|
|
|
it('excludes demo relay ids from Shelly status batch payloads', function (): void {
|
|
$harness = selfserve_lane_shelly_test_harness();
|
|
$harness->department_lane = new SelfserveDepartmentLaneRelayFake(
|
|
machineRelayId: 'demo-machine',
|
|
programPickerRelayId: 'relay-program',
|
|
cleanerRelayId: 'demo-cleaner'
|
|
);
|
|
$harness->queueShellyResponse('/v2/devices/api/get', [
|
|
[
|
|
'id' => 'relay-program',
|
|
'online' => true,
|
|
'status' => ['switch:0' => ['output' => true]],
|
|
],
|
|
]);
|
|
|
|
$status = $harness->getMachineProgramPickerRelayStatus();
|
|
|
|
expect($status['relay_id'])->toBe('relay-program');
|
|
expect($status['on'])->toBeTrue();
|
|
expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(1);
|
|
expect($harness->shellyCalls[0]['payload']['ids'])->toBe(['relay-program']);
|
|
});
|