Add unit tests for department lane dynamic image overrides and introduce classes for self-serve signal and virtual hardware management
- Add `DepartmentLaneDynamicImageRouteTest` to verify dynamic image preview handling for studio lanes. - Introduce `selfserve_machine_signal` class to standardize signal normalization, recording, and gateway signal management workflows. - Add `selfserve_virtual_hardware` class to handle virtual hardware configurations, including gateway and binding management. - Enhance structure with auxiliary methods for payload normalization, workspace merging, and validation warnings.
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
<?php
|
||||
|
||||
namespace modules\selfserve\classes;
|
||||
|
||||
require_once WD . '/classes/selfserve.php';
|
||||
require_once WD . '/modules/edgegateway/classes/edge_gateway_manager.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_wash_flow.php';
|
||||
require_once WD . '/objects/department_lanes_o.php';
|
||||
require_once WD . '/objects/edge_gateway_relay_bindings_o.php';
|
||||
|
||||
use classes\edge_gateway_manager;
|
||||
use classes\selfserve;
|
||||
use objects\department_lanes_o;
|
||||
use objects\edge_gateway_relay_bindings_o;
|
||||
|
||||
class selfserve_machine_signal
|
||||
{
|
||||
/**
|
||||
* Normalize Shelly Cloud webhook payloads and local edge gateway poll payloads.
|
||||
*
|
||||
* Shelly Gen2 emits switch.on/input.toggle_on webhooks and exposes Switch.GetStatus.output
|
||||
* / Input.GetStatus.state for state reads. Legacy or custom payloads are accepted as long
|
||||
* as they carry equivalent boolean fields.
|
||||
*
|
||||
* @param array<string,mixed> $payload
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function normalizeShellyPayload(array $payload): array
|
||||
{
|
||||
if (isset($payload['events']) && is_array($payload['events'])) {
|
||||
foreach ((array)$payload['events'] as $eventPayload) {
|
||||
if (is_array($eventPayload)) {
|
||||
$payload = array_replace($payload, (array)$eventPayload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$event = $this->firstString($payload, ['event', 'event_type', 'eventType', 'name', 'type']);
|
||||
$component = $this->normalizeComponent($this->firstString($payload, ['component', 'component_id', 'componentId']));
|
||||
$relayId = $this->firstString($payload, ['relay_id', 'logical_relay_id', 'logicalRelayId', 'relayId']);
|
||||
$deviceId = $this->firstString($payload, ['device_id', 'deviceId', 'device']);
|
||||
$channel = $this->firstInt($payload, ['channel', 'id', 'input_id', 'switch_id']);
|
||||
$on = $this->extractOnState($payload);
|
||||
$eventName = strtolower(trim((string)$event));
|
||||
|
||||
if ($component === null && str_starts_with($eventName, 'input.')) {
|
||||
$component = 'input';
|
||||
}
|
||||
if ($component === null && str_starts_with($eventName, 'switch.')) {
|
||||
$component = 'switch';
|
||||
}
|
||||
|
||||
$positiveEvents = [
|
||||
'on',
|
||||
'toggle_on',
|
||||
'btn_down',
|
||||
'single_push',
|
||||
'machine.on',
|
||||
'switch.on',
|
||||
'switch.toggle_on',
|
||||
'input.on',
|
||||
'input.toggle_on',
|
||||
'input.btn_down',
|
||||
'input.single_push',
|
||||
];
|
||||
$negativeEvents = [
|
||||
'off',
|
||||
'toggle_off',
|
||||
'btn_up',
|
||||
'machine.off',
|
||||
'switch.off',
|
||||
'switch.toggle_off',
|
||||
'input.off',
|
||||
'input.toggle_off',
|
||||
'input.btn_up',
|
||||
];
|
||||
|
||||
$eventIsOn = in_array($eventName, $positiveEvents, true);
|
||||
$eventIsOff = in_array($eventName, $negativeEvents, true);
|
||||
$recognized = $eventIsOn || $eventIsOff || $on !== null;
|
||||
$onState = $eventIsOn || ($on === true && !$eventIsOff);
|
||||
|
||||
return [
|
||||
'recognized' => $recognized,
|
||||
'on' => $onState,
|
||||
'event' => $event !== null ? (string)$event : null,
|
||||
'component' => $component,
|
||||
'relay_id' => $relayId,
|
||||
'device_id' => $deviceId,
|
||||
'channel' => $channel,
|
||||
'source' => (string)($payload['source'] ?? 'shelly'),
|
||||
'raw_status' => $this->extractStatusPayload($payload),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @param array<string,mixed> $context
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function recordCloudShellySignal(int $departmentId, ?int $laneId, array $payload, array $context = []): array
|
||||
{
|
||||
$signal = $this->normalizeShellyPayload($payload + ['source' => 'shelly_cloud']);
|
||||
if (!$signal['recognized']) {
|
||||
return [
|
||||
'recorded' => false,
|
||||
'ignored' => true,
|
||||
'reason' => 'Payload does not contain a recognized Shelly ON/OFF signal.',
|
||||
'signal' => $signal,
|
||||
];
|
||||
}
|
||||
if (!$signal['on']) {
|
||||
return [
|
||||
'recorded' => false,
|
||||
'ignored' => true,
|
||||
'reason' => 'Shelly signal was recognized but it was not ON.',
|
||||
'signal' => $signal,
|
||||
];
|
||||
}
|
||||
|
||||
$resolvedLaneId = $this->resolveLaneId($departmentId, $laneId, $signal['relay_id'] ?? null);
|
||||
$summary = (new selfserve_wash_flow())->recordMachineStartWebhook(
|
||||
$resolvedLaneId,
|
||||
$this->extractRegistration($payload),
|
||||
$payload + [
|
||||
'source' => 'shelly_cloud',
|
||||
'shelly_signal' => $signal,
|
||||
'context' => $context,
|
||||
]
|
||||
);
|
||||
|
||||
return [
|
||||
'recorded' => true,
|
||||
'ignored' => false,
|
||||
'lane_id' => $resolvedLaneId,
|
||||
'signal' => $signal,
|
||||
'selfserve' => $summary,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function recordEdgeGatewaySignal(int $gatewayId, string $agentToken, array $payload): array
|
||||
{
|
||||
$gateway = (new edge_gateway_manager())->authenticateGateway($gatewayId, $agentToken);
|
||||
$departmentId = (int)$gateway->department_id->value();
|
||||
|
||||
return $this->recordCloudShellySignal(
|
||||
$departmentId,
|
||||
isset($payload['lane_id']) ? (int)$payload['lane_id'] : null,
|
||||
$payload + [
|
||||
'source' => 'edge_gateway',
|
||||
'gateway_id' => $gatewayId,
|
||||
],
|
||||
[
|
||||
'source' => 'edge_gateway',
|
||||
'gateway_id' => $gatewayId,
|
||||
'agent_instance_id' => $payload['agent_instance_id'] ?? null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function listEdgeGatewayMachineSignalMonitors(int $gatewayId, string $agentToken): array
|
||||
{
|
||||
$manager = new edge_gateway_manager();
|
||||
$gateway = $manager->authenticateGateway($gatewayId, $agentToken);
|
||||
$departmentId = (int)$gateway->department_id->value();
|
||||
|
||||
$bindings = [];
|
||||
foreach ($this->bindingRows($gatewayId, $departmentId) as $binding) {
|
||||
$relayId = trim((string)($binding['relay_id'] ?? ''));
|
||||
if ($relayId !== '') {
|
||||
$bindings[$relayId] = $binding;
|
||||
}
|
||||
}
|
||||
|
||||
$monitors = [];
|
||||
foreach ((new department_lanes_o())->getDepartmentLanes($departmentId) as $lane) {
|
||||
$relayId = trim((string)$lane->relay_machine_id->value());
|
||||
if ($relayId === '' || !isset($bindings[$relayId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$binding = $bindings[$relayId];
|
||||
$metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : [];
|
||||
$component = $this->normalizeMonitorComponent((string)($metadata['machine_signal_component'] ?? $metadata['signal_component'] ?? 'input'));
|
||||
$channel = (int)($metadata['machine_signal_channel'] ?? $metadata['input_channel'] ?? $binding['channel'] ?? 0);
|
||||
|
||||
$monitors[] = [
|
||||
'gateway_id' => $gatewayId,
|
||||
'department_id' => $departmentId,
|
||||
'lane_id' => (int)$lane->id,
|
||||
'lane_label' => (string)$lane->name->value(),
|
||||
'relay_id' => $relayId,
|
||||
'device_id' => (string)($metadata['machine_signal_device_id'] ?? $binding['device_id'] ?? ''),
|
||||
'local_ip' => $metadata['machine_signal_local_ip'] ?? $binding['local_ip'] ?? null,
|
||||
'channel' => $channel,
|
||||
'component' => $component,
|
||||
'expected_event' => $component === 'switch' ? 'switch.on' : 'input.toggle_on',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'gateway_id' => $gatewayId,
|
||||
'department_id' => $departmentId,
|
||||
'monitors' => $monitors,
|
||||
];
|
||||
}
|
||||
|
||||
public function resolveLaneId(int $departmentId, ?int $laneId, ?string $relayId = null): int
|
||||
{
|
||||
if ($laneId !== null && $laneId > 0) {
|
||||
$lane = (new department_lanes_o())->select($laneId);
|
||||
if (!$lane->exists()) {
|
||||
throw new \RuntimeException('Department lane not found.');
|
||||
}
|
||||
if ((int)$lane->department->value() !== $departmentId) {
|
||||
throw new \RuntimeException('The lane does not belong to the Shelly signal department.');
|
||||
}
|
||||
|
||||
return (int)$lane->id;
|
||||
}
|
||||
|
||||
$relayId = trim((string)$relayId);
|
||||
if ($relayId !== '') {
|
||||
$matches = (new department_lanes_o())->getFieldsWhere(
|
||||
[
|
||||
'department' => $departmentId,
|
||||
'relay_machine_id' => $relayId,
|
||||
'deleted_at' => null,
|
||||
],
|
||||
['id']
|
||||
);
|
||||
if ($matches !== []) {
|
||||
return (int)$matches[0]['id'];
|
||||
}
|
||||
}
|
||||
|
||||
$lanes = (new department_lanes_o())->getDepartmentLanes($departmentId);
|
||||
if (count($lanes) === 1) {
|
||||
return (int)$lanes[0]->id;
|
||||
}
|
||||
|
||||
throw new \RuntimeException('lane_id or relay_id is required when the department has multiple self-serve lanes.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
*/
|
||||
private function extractRegistration(array $payload): ?string
|
||||
{
|
||||
$reg = $this->firstString($payload, ['reg', 'registration', 'license_plate', 'licensePlate', 'plate']);
|
||||
return $reg === null || trim($reg) === '' ? null : selfserve::standardize_registration($reg);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @param array<int,string> $keys
|
||||
*/
|
||||
private function firstString(array $payload, array $keys): ?string
|
||||
{
|
||||
foreach ($keys as $key) {
|
||||
if (!array_key_exists($key, $payload)) {
|
||||
continue;
|
||||
}
|
||||
$value = $payload[$key];
|
||||
if (is_scalar($value) && trim((string)$value) !== '') {
|
||||
return trim((string)$value);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['params', 'data', 'status'] as $container) {
|
||||
if (!isset($payload[$container]) || !is_array($payload[$container])) {
|
||||
continue;
|
||||
}
|
||||
$match = $this->firstString((array)$payload[$container], $keys);
|
||||
if ($match !== null) {
|
||||
return $match;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @param array<int,string> $keys
|
||||
*/
|
||||
private function firstInt(array $payload, array $keys): ?int
|
||||
{
|
||||
foreach ($keys as $key) {
|
||||
if (array_key_exists($key, $payload) && is_numeric($payload[$key])) {
|
||||
return (int)$payload[$key];
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['params', 'data', 'status'] as $container) {
|
||||
if (!isset($payload[$container]) || !is_array($payload[$container])) {
|
||||
continue;
|
||||
}
|
||||
$match = $this->firstInt((array)$payload[$container], $keys);
|
||||
if ($match !== null) {
|
||||
return $match;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
*/
|
||||
private function extractOnState(array $payload): ?bool
|
||||
{
|
||||
foreach (['on', 'output', 'state', 'ison'] as $key) {
|
||||
if (array_key_exists($key, $payload)) {
|
||||
return $this->boolValue($payload[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['input', 'switch', 'params', 'data', 'status'] as $key) {
|
||||
if (!isset($payload[$key]) || !is_array($payload[$key])) {
|
||||
continue;
|
||||
}
|
||||
$value = $this->extractOnState((array)$payload[$key]);
|
||||
if ($value !== null) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['input:0', 'switch:0'] as $componentKey) {
|
||||
if (!isset($payload[$componentKey]) || !is_array($payload[$componentKey])) {
|
||||
continue;
|
||||
}
|
||||
$value = $this->extractOnState((array)$payload[$componentKey]);
|
||||
if ($value !== null) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function boolValue(mixed $value): ?bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return (int)$value === 1;
|
||||
}
|
||||
if (is_string($value)) {
|
||||
$normalized = strtolower(trim($value));
|
||||
if (in_array($normalized, ['1', 'true', 'on', 'yes'], true)) {
|
||||
return true;
|
||||
}
|
||||
if (in_array($normalized, ['0', 'false', 'off', 'no'], true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeComponent(?string $component): ?string
|
||||
{
|
||||
$component = strtolower(trim((string)$component));
|
||||
if ($component === '') {
|
||||
return null;
|
||||
}
|
||||
if (str_starts_with($component, 'input')) {
|
||||
return 'input';
|
||||
}
|
||||
if (str_starts_with($component, 'switch') || str_starts_with($component, 'relay')) {
|
||||
return 'switch';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeMonitorComponent(string $component): string
|
||||
{
|
||||
return $this->normalizeComponent($component) === 'switch' ? 'switch' : 'input';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function extractStatusPayload(array $payload): ?array
|
||||
{
|
||||
if (isset($payload['status']) && is_array($payload['status'])) {
|
||||
return (array)$payload['status'];
|
||||
}
|
||||
if (isset($payload['raw']) && is_array($payload['raw'])) {
|
||||
return (array)$payload['raw'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private function bindingRows(int $gatewayId, int $departmentId): array
|
||||
{
|
||||
$rows = (new edge_gateway_relay_bindings_o())->getFieldsWhere(
|
||||
[
|
||||
'gateway_id' => $gatewayId,
|
||||
'department_id' => $departmentId,
|
||||
'deleted_at' => null,
|
||||
],
|
||||
['id']
|
||||
);
|
||||
|
||||
return array_map(
|
||||
static fn(array $row): array => (new edge_gateway_relay_bindings_o())->select((int)$row['id'])->asArray(),
|
||||
$rows
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user