$payload * @return array */ 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 $payload * @param array $context * @return array */ 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 $payload * @return array */ 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 $payload * @return array */ 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 $payload * @return array */ 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 */ 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 $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 $payload * @param array $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 $payload * @param array $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 $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 $payload * @return array|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> */ 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 ); } }