Files
api/services/nginx/app/modules/selfserve/classes/selfserve_machine_signal.php
T

447 lines
15 KiB
PHP

<?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->recordEdgeGatewaySignalForDepartment($gatewayId, $departmentId, $payload);
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
public function recordBrokerEdgeGatewaySignal(int $gatewayId, array $payload): array
{
$gateway = (new edge_gateway_manager())->getGateway($gatewayId);
return $this->recordEdgeGatewaySignalForDepartment($gatewayId, (int)$gateway['department_id'], $payload);
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
private function recordEdgeGatewaySignalForDepartment(int $gatewayId, int $departmentId, array $payload): array
{
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
);
}
}