clientId = $clientId ?? $this->generateRandomClientId(); $this->repository = $repository ?? new MemoryRepository(); $this->logger = new Logger($this->host, $this->port, $this->clientId, $logger); $this->messageProcessor = match ($protocol) { self::MQTT_3_1_1 => new Mqtt311MessageProcessor($this->clientId, $this->logger), default => new Mqtt31MessageProcessor($this->clientId, $this->logger), }; $this->initializeEventHandlers(); } /** * {@inheritDoc} */ public function connect(ConnectionSettings $settings = null, bool $useCleanSession = false): void { // Always abruptly close any previous connection if we are opening a new one. // The caller should make sure this does not happen. $this->closeSocket(); $this->logger->debug('Connecting to broker.'); $this->settings = $settings ?? new ConnectionSettings(); $this->ensureConnectionSettingsAreValid($this->settings); // Because a clean session would make reconnects inherently more complex since all subscriptions would need to be replayed after reconnecting, // we simply do not allow using these two features together. if ($useCleanSession && $this->settings->shouldReconnectAutomatically()) { throw new ConfigurationInvalidException('Automatic reconnects cannot be used together with the clean session flag.'); } // When a clean session is requested, we have to reset the repository to forget about persisted states. if ($useCleanSession) { $this->repository->reset(); } $this->connectInternal($useCleanSession); } /** * Connect to the MQTT broker using the configured settings. * * @throws ConnectingToBrokerFailedException */ protected function connectInternal(bool $useCleanSession = false, bool $isAutoReconnect = false): void { try { $this->establishSocketConnection(); $this->performConnectionHandshake($useCleanSession); } catch (ConnectingToBrokerFailedException $e) { $this->closeSocket(); throw $e; } $this->connected = true; $this->runConnectedEventHandlers($isAutoReconnect); } /** * Opens a socket that connects to the host and port set on the object. * * When this method is called, all connection settings have been validated. * * @throws ConnectingToBrokerFailedException */ protected function establishSocketConnection(): void { $contextOptions = []; // Only if TLS is enabled, we add all TLS options to the context options. if ($this->settings->shouldUseTls()) { $this->logger->debug('Using TLS for the connection to the broker.'); $shouldVerifyPeer = $this->settings->shouldTlsVerifyPeer() || $this->settings->getTlsCertificateAuthorityFile() !== null || $this->settings->getTlsCertificateAuthorityPath() !== null; if (!$shouldVerifyPeer) { $this->logger->warning('Using TLS without peer verification is discouraged. Are you aware of the security risk?'); } if ($this->settings->isTlsSelfSignedAllowed()) { $this->logger->warning('Using TLS with self-signed certificates is discouraged. Please use a CA file to verify it.'); } $tlsOptions = [ 'verify_peer' => $shouldVerifyPeer, 'verify_peer_name' => $this->settings->shouldTlsVerifyPeerName(), 'allow_self_signed' => $this->settings->isTlsSelfSignedAllowed(), ]; if ($this->settings->getTlsCertificateAuthorityFile() !== null) { $tlsOptions['cafile'] = $this->settings->getTlsCertificateAuthorityFile(); } if ($this->settings->getTlsCertificateAuthorityPath() !== null) { $tlsOptions['capath'] = $this->settings->getTlsCertificateAuthorityPath(); } if ($this->settings->getTlsClientCertificateFile() !== null) { $tlsOptions['local_cert'] = $this->settings->getTlsClientCertificateFile(); } if ($this->settings->getTlsClientCertificateKeyFile() !== null) { $tlsOptions['local_pk'] = $this->settings->getTlsClientCertificateKeyFile(); } if ($this->settings->getTlsClientCertificateKeyPassphrase() !== null) { $tlsOptions['passphrase'] = $this->settings->getTlsClientCertificateKeyPassphrase(); } $contextOptions['ssl'] = $tlsOptions; } $connectionString = 'tcp://' . $this->getHost() . ':' . $this->getPort(); $socketContext = stream_context_create($contextOptions); $socket = @stream_socket_client( $connectionString, $errorCode, $errorMessage, $this->settings->getConnectTimeout(), STREAM_CLIENT_CONNECT, $socketContext ); // The socket will be set to false if stream_socket_client() returned an error. if ($socket === false) { $this->logger->error('Establishing a connection with the broker using the connection string [{connectionString}] failed: {error}', [ 'connectionString' => $connectionString, 'error' => $errorMessage, 'code' => $errorCode, ]); throw new ConnectingToBrokerFailedException( ConnectingToBrokerFailedException::EXCEPTION_CONNECTION_SOCKET_ERROR, sprintf('Socket error [%d]: %s', $errorCode, $errorMessage), (string) $errorCode, $errorMessage ); } // If TLS is enabled, we need to enable it on the already created stream. // Until now, we only created a normal TCP stream. if ($this->settings->shouldUseTls()) { // Since stream_socket_enable_crypto() communicates errors using error_get_last(), // we need to clear a potentially set error at this point to be sure the error we // retrieve in the error handling part is actually of this function call and not // from some unrelated code of the users application. error_clear_last(); $this->logger->debug('Enabling TLS on the existing socket connection.'); $enableEncryptionResult = @stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_ANY_CLIENT); if ($enableEncryptionResult === false) { // At this point, PHP should have given us something like this: // SSL operation failed with code 1. OpenSSL Error messages: // error:1416F086:SSL routines:tls_process_server_certificate:certificate verify failed // We need to get our hands dirty and extract the OpenSSL error // from the PHP message, which luckily gives us a handy newline. $this->parseTlsErrorMessage(error_get_last(), $tlsErrorCode, $tlsErrorMessage); // Before returning an exception, we need to close the already opened socket. @fclose($socket); $this->logger->error('Enabling TLS on the connection with the MQTT broker failed (code {errorCode}): {errorMessage}', [ 'errorMessage' => $tlsErrorMessage, 'errorCode' => $tlsErrorCode, ]); throw new ConnectingToBrokerFailedException( ConnectingToBrokerFailedException::EXCEPTION_CONNECTION_TLS_ERROR, sprintf('TLS error [%s]: %s', $tlsErrorCode, $tlsErrorMessage), $tlsErrorCode, $tlsErrorMessage ); } $this->logger->debug('TLS enabled successfully.'); } stream_set_timeout($socket, $this->settings->getSocketTimeout()); stream_set_blocking($socket, false); $this->logger->debug('Socket opened and ready to use.'); $this->socket = $socket; } /** * Internal parser for SSL-related PHP error messages. */ private function parseTlsErrorMessage(?array $phpError, ?string &$tlsErrorCode = null, ?string &$tlsErrorMessage = null): void { if (!$phpError || !isset($phpError['message'])) { $tlsErrorCode = "UNKNOWN:1"; $tlsErrorMessage = "Unknown error"; return; } if (!preg_match('/:\n(?:error:([0-9A-Z]+):)?(.+)$/', $phpError['message'], $matches)) { $tlsErrorCode = "UNKNOWN:2"; $tlsErrorMessage = $phpError['message']; return; } if ($matches[1] == "") { $tlsErrorCode = "UNKNOWN:3"; $tlsErrorMessage = $matches[2]; return; } $tlsErrorCode = $matches[1]; $tlsErrorMessage = $matches[2]; } /** * Performs the connection handshake with the help of the configured message processor. * The connection handshake is expected to have the same flow all the time: * - Connect request with variable length * - Connect acknowledgement with variable length * * @throws ConnectingToBrokerFailedException */ protected function performConnectionHandshake(bool $useCleanSession = false): void { try { $connectionHandshakeStartedAt = microtime(true); $data = $this->messageProcessor->buildConnectMessage($this->settings, $useCleanSession); $this->logger->debug('Sending connection handshake to broker.'); $this->writeToSocket($data); // Start by waiting for the first byte, then using polling logic to fetch all remaining // data from the socket. $buffer = $this->readFromSocket(1); $requiredBytes = -1; while (true) { if ($requiredBytes > 0) { $buffer .= $this->readFromSocket($requiredBytes); } else { $buffer .= $this->readAllAvailableDataFromSocket(); } $message = null; $result = $this->messageProcessor->tryFindMessageInBuffer($buffer, strlen($buffer), $message, $requiredBytes); // We only need to wait for the bytes we don't have in the buffer yet. $requiredBytes = $requiredBytes - strlen($buffer); if ($result === true) { /** @var string $message */ // Remove the parsed data from the buffer. $buffer = substr($buffer, strlen($message)); // Process the acknowledgement message. $this->messageProcessor->handleConnectAcknowledgement($message); break; } // If no acknowledgement has been received from the broker within the configured connection timeout period, // we abort the connection attempt and assume broker unavailability. if (microtime(true) - $this->settings->getConnectTimeout() > $connectionHandshakeStartedAt) { throw new ConnectingToBrokerFailedException( ConnectingToBrokerFailedException::EXCEPTION_CONNECTION_BROKER_UNAVAILABLE, 'The broker did not acknowledge the connection attempt within the configured connection timeout period.' ); } } // We need to set the global buffer to the remaining data we might already have read. $this->buffer = $buffer; } catch (DataTransferException) { $this->logger->error('While connecting to the broker, a transfer error occurred.'); throw new ConnectingToBrokerFailedException( ConnectingToBrokerFailedException::EXCEPTION_CONNECTION_FAILED, 'A connection could not be established due to data transfer issues.' ); } } /** * Attempts to reconnect to the broker. If a connection cannot be established within the configured number of retries, * the last caught exception is thrown. * * @throws ConnectingToBrokerFailedException */ protected function reconnect(): void { $maxReconnectAttempts = $this->settings->getMaxReconnectAttempts(); $delayBetweenReconnectAttempts = $this->settings->getDelayBetweenReconnectAttempts(); for ($i = 1; $i <= $maxReconnectAttempts; $i++) { try { $this->connectInternal(false, true); return; } catch (ConnectingToBrokerFailedException $e) { if ($i === $maxReconnectAttempts) { throw $e; } if ($delayBetweenReconnectAttempts > 0) { usleep($delayBetweenReconnectAttempts * 1000); } } } } /** * {@inheritDoc} */ public function interrupt(): void { $this->interrupted = true; } /** * {@inheritDoc} */ public function getHost(): string { return $this->host; } /** * {@inheritDoc} */ public function getPort(): int { return $this->port; } /** * {@inheritDoc} */ public function getClientId(): string { return $this->clientId; } /** * {@inheritDoc} */ public function getReceivedBytes(): int { return $this->bytesReceived; } /** * {@inheritDoc} */ public function getSentBytes(): int { return $this->bytesSent; } /** * {@inheritDoc} */ public function isConnected(): bool { return $this->connected; } /** * Ensures the client is connected to a broker (or at least thinks it is). * This method does not account for closed sockets. * * @throws ClientNotConnectedToBrokerException */ protected function ensureConnected(): void { if (!$this->isConnected()) { throw new ClientNotConnectedToBrokerException( 'The client is not connected to a broker. The requested operation is impossible at this point.' ); } } /** * {@inheritDoc} */ public function disconnect(): void { $this->ensureConnected(); $this->sendDisconnect(); if ($this->socket !== null && is_resource($this->socket)) { $this->logger->debug('Closing the socket to the broker.'); stream_socket_shutdown($this->socket, STREAM_SHUT_WR); } $this->connected = false; } /** * {@inheritDoc} */ public function publish(string $topic, string $message, int $qualityOfService = 0, bool $retain = false): void { $this->ensureConnected(); $messageId = null; if ($qualityOfService > self::QOS_AT_MOST_ONCE) { $messageId = $this->repository->newMessageId(); $pendingMessage = new PublishedMessage($messageId, $topic, $message, $qualityOfService, $retain); $this->repository->addPendingOutgoingMessage($pendingMessage); } $this->publishMessage($topic, $message, $qualityOfService, $retain, $messageId); } /** * Actually publishes a message after using the configured message processor to build it. * This is an internal method used for both, initial publishing of messages as well as * re-publishing in case of timeouts. * * @throws DataTransferException */ protected function publishMessage( string $topic, string $message, int $qualityOfService, bool $retain, ?int $messageId = null, bool $isDuplicate = false ): void { $this->logger->debug('Publishing a message on topic [{topic}]: {message}', [ 'topic' => $topic, 'message' => $message, 'qos' => $qualityOfService, 'retain' => $retain, 'messageId' => $messageId, 'isDuplicate' => $isDuplicate, ]); $this->runPublishEventHandlers($topic, $message, $messageId, $qualityOfService, $retain); $data = $this->messageProcessor->buildPublishMessage($topic, $message, $qualityOfService, $retain, $messageId, $isDuplicate); $this->writeToSocketWithAutoReconnect($data); } /** * {@inheritDoc} */ public function subscribe(string $topicFilter, callable $callback = null, int $qualityOfService = self::QOS_AT_MOST_ONCE): void { $this->ensureConnected(); $this->logger->debug('Subscribing to topic [{topicFilter}] with maximum QoS [{qos}].', [ 'topicFilter' => $topicFilter, 'qos' => $qualityOfService, ]); $messageId = $this->repository->newMessageId(); // Create the subscription representation now, but it will become an // actual subscription only upon acknowledgement from the broker. $subscriptions = [new Subscription($topicFilter, $qualityOfService, $callback)]; $pendingMessage = new SubscribeRequest($messageId, $subscriptions); $this->repository->addPendingOutgoingMessage($pendingMessage); $data = $this->messageProcessor->buildSubscribeMessage($messageId, $subscriptions); $this->writeToSocketWithAutoReconnect($data); } /** * {@inheritDoc} */ public function unsubscribe(string $topicFilter): void { $this->ensureConnected(); $this->logger->debug('Unsubscribing from topic [{topicFilter}].', ['topicFilter' => $topicFilter]); $messageId = $this->repository->newMessageId(); $topicFilters = [$topicFilter]; $pendingMessage = new UnsubscribeRequest($messageId, $topicFilters); $this->repository->addPendingOutgoingMessage($pendingMessage); $data = $this->messageProcessor->buildUnsubscribeMessage($messageId, $topicFilters); $this->writeToSocketWithAutoReconnect($data); } /** * Returns the next time the broker expects to be pinged. */ protected function nextPingAt(): float { return ($this->lastPingAt + $this->settings->getKeepAliveInterval()); } /** * {@inheritDoc} */ public function loop(bool $allowSleep = true, bool $exitWhenQueuesEmpty = false, int $queueWaitLimit = null): void { $this->logger->debug('Starting client loop to process incoming messages and the resend queue.'); $loopStartedAt = microtime(true); while (true) { if ($this->interrupted) { $this->interrupted = false; break; } $this->loopOnce($loopStartedAt, $allowSleep); // If configured, the loop is exited if all queues are empty or a certain time limit is reached (i.e. retry is aborted). // In any case, there may not be any active subscriptions though. if ($exitWhenQueuesEmpty && $this->repository->countSubscriptions() === 0) { if ($this->allQueuesAreEmpty()) { break; } // The time limit is reached. This most likely means the outgoing queues could not be emptied in time. // Probably the server did not respond with an acknowledgement. if ($queueWaitLimit !== null && (microtime(true) - $loopStartedAt) > $queueWaitLimit) { break; } } } } /** * {@inheritDoc} */ public function loopOnce(float $loopStartedAt, bool $allowSleep = false, int $sleepMicroseconds = 100000): void { $elapsedTime = microtime(true) - $loopStartedAt; $this->runLoopEventHandlers($elapsedTime); // Read data from the socket - as much as available. $this->buffer .= $this->readAllAvailableDataFromSocket(true); // Try to parse a message from the buffer and handle it, as long as messages can be parsed. if (strlen($this->buffer) > 0) { $this->processMessageBuffer(); } elseif ($allowSleep) { usleep($sleepMicroseconds); } // Republish messages expired without confirmation. // This includes published messages, subscribe and unsubscribe requests. $this->resendPendingMessages(); // If the last message of the broker has been received more seconds ago // than specified by the keep alive time, we will send a ping to ensure // the connection is kept alive. if ($this->nextPingAt() <= microtime(true)) { $this->ping(); } } /** * Processes the incoming message buffer by parsing and handling the messages, until the buffer is empty. * * @throws DataTransferException * @throws InvalidMessageException * @throws MqttClientException * @throws ProtocolViolationException */ private function processMessageBuffer(): void { while (true) { $data = ''; $requiredBytes = -1; $hasMessage = $this->messageProcessor->tryFindMessageInBuffer($this->buffer, strlen($this->buffer), $data, $requiredBytes); // When there is no full message in the buffer, we stop processing for now and go on // with the next iteration. if ($hasMessage === false) { break; } // If we found a message, the buffer needs to be reduced by the message length. $this->buffer = substr($this->buffer, strlen($data)); // We then pass the message over to the message processor to parse and validate it. $message = $this->messageProcessor->parseAndValidateMessage($data); // The result is used by us to perform required actions according to the protocol. if ($message !== null) { $this->handleMessage($message); } } } /** * Handles the given message according to its contents. * * @throws DataTransferException * @throws ProtocolViolationException */ protected function handleMessage(Message $message): void { // PUBLISH (incoming) if ($message->getType()->equals(MessageType::PUBLISH())) { if ($message->getQualityOfService() === self::QOS_AT_LEAST_ONCE) { // QoS 1. $this->sendPublishAcknowledgement($message->getMessageId()); } if ($message->getQualityOfService() === self::QOS_EXACTLY_ONCE) { // QoS 2, part 1. try { $pendingMessage = new PublishedMessage( $message->getMessageId(), $message->getTopic(), $message->getContent(), 2, $message->getRetained() ); $this->repository->addPendingIncomingMessage($pendingMessage); } catch (PendingMessageAlreadyExistsException) { // We already received and processed this message. } // Always acknowledge, even if we received it multiple times. $this->sendPublishReceived($message->getMessageId()); // We only deliver this published message as soon as we receive a publish complete. return; } // For QoS 0 and QoS 1 we can deliver right away. $this->deliverPublishedMessage($message->getTopic(), $message->getContent(), $message->getQualityOfService(), $message->getRetained()); return; } // PUBACK (outgoing, QoS 1) // Receiving an acknowledgement allows us to remove the published message from the retry queue. if ($message->getType()->equals(MessageType::PUBLISH_ACKNOWLEDGEMENT())) { $result = $this->repository->removePendingOutgoingMessage($message->getMessageId()); if ($result === false) { $this->logger->notice('Received publish acknowledgement from the broker for already acknowledged message.', [ 'messageId' => $message->getMessageId() ]); } return; } // PUBREC (outgoing, QoS 2, part 1) // Receiving a receipt allows us to mark the published message as received. if ($message->getType()->equals(MessageType::PUBLISH_RECEIPT())) { try { $result = $this->repository->markPendingOutgoingPublishedMessageAsReceived($message->getMessageId()); } catch (PendingMessageNotFoundException) { // This should never happen as we should have received all PUBREC messages before we see the first // PUBCOMP which actually remove the message. So we do this for safety only. $result = false; } if ($result === false) { $this->logger->notice('Received publish receipt from the broker for already acknowledged message.', [ 'messageId' => $message->getMessageId() ]); } // We always reply blindly to keep the flow moving. $this->sendPublishRelease($message->getMessageId()); return; } // PUBREL (incoming, QoS 2, part 2) // When the broker tells us we can release the received published message, we deliver it to subscribed callbacks. if ($message->getType()->equals(MessageType::PUBLISH_RELEASE())) { $pendingMessage = $this->repository->getPendingIncomingMessage($message->getMessageId()); if (!$pendingMessage || !$pendingMessage instanceof PublishedMessage) { $this->logger->notice('Received publish release from the broker for already released message.', [ 'messageId' => $message->getMessageId(), ]); } else { $this->deliverPublishedMessage( $pendingMessage->getTopicName(), $pendingMessage->getMessage(), $pendingMessage->getQualityOfServiceLevel(), $pendingMessage->wantsToBeRetained() ); $this->repository->removePendingIncomingMessage($message->getMessageId()); } // Always reply with the PUBCOMP packet so it stops resending it. $this->sendPublishComplete($message->getMessageId()); return; } // PUBCOMP (outgoing, QoS 2 part 3) // Receiving a completion allows us to remove a published message from the retry queue. // At this point, the publish process is complete. if ($message->getType()->equals(MessageType::PUBLISH_COMPLETE())) { $result = $this->repository->removePendingOutgoingMessage($message->getMessageId()); if ($result === false) { $this->logger->notice('Received publish completion from the broker for already acknowledged message.', [ 'messageId' => $message->getMessageId(), ]); } return; } // SUBACK if ($message->getType()->equals(MessageType::SUBSCRIBE_ACKNOWLEDGEMENT())) { $pendingMessage = $this->repository->getPendingOutgoingMessage($message->getMessageId()); if (!$pendingMessage || !$pendingMessage instanceof SubscribeRequest) { $this->logger->notice('Received subscribe acknowledgement from the broker for already acknowledged request.', [ 'messageId' => $message->getMessageId(), ]); return; } $acknowledgedSubscriptions = $pendingMessage->getSubscriptions(); if (count($acknowledgedSubscriptions) != count($message->getAcknowledgedQualityOfServices())) { throw new ProtocolViolationException(sprintf( 'The MQTT broker responded with a different amount of QoS acknowledgements (%d) than we expected (%d).', count($message->getAcknowledgedQualityOfServices()), count($acknowledgedSubscriptions) )); } foreach ($message->getAcknowledgedQualityOfServices() as $index => $qualityOfService) { // Starting from MQTT 3.1.1, the broker is able to reject individual subscriptions. // Instead of failing the whole bulk, we log the incident and skip the single subscription. if ($qualityOfService === 128) { $this->logger->notice('The broker rejected the subscription to [{topicFilter}].', [ 'topicFilter' => $acknowledgedSubscriptions[$index]->getTopicFilter(), ]); continue; } // It may happen that the server registers our subscription // with a lower quality of service than requested, in this // case this is the one that we will record. $acknowledgedSubscriptions[$index]->setQualityOfServiceLevel($qualityOfService); $this->repository->addSubscription($acknowledgedSubscriptions[$index]); } $this->repository->removePendingOutgoingMessage($message->getMessageId()); return; } // UNSUBACK if ($message->getType()->equals(MessageType::UNSUBSCRIBE_ACKNOWLEDGEMENT())) { $pendingMessage = $this->repository->getPendingOutgoingMessage($message->getMessageId()); if (!$pendingMessage || !$pendingMessage instanceof UnsubscribeRequest) { $this->logger->notice('Received unsubscribe acknowledgement from the broker for already acknowledged request.', [ 'messageId' => $message->getMessageId(), ]); return; } foreach ($pendingMessage->getTopicFilters() as $topicFilter) { $this->repository->removeSubscription($topicFilter); } $this->repository->removePendingOutgoingMessage($message->getMessageId()); return; } // PINGREQ if ($message->getType()->equals(MessageType::PING_REQUEST())) { // Respond with PINGRESP. $this->writeToSocketWithAutoReconnect($this->messageProcessor->buildPingResponseMessage()); return; } } /** * Determines if all queues are empty. */ protected function allQueuesAreEmpty(): bool { return $this->repository->countPendingOutgoingMessages() === 0 && $this->repository->countPendingIncomingMessages() === 0; } /** * Delivers a published message to subscribed callbacks. */ protected function deliverPublishedMessage(string $topic, string $message, int $qualityOfServiceLevel, bool $retained = false): void { $subscribers = $this->repository->getSubscriptionsMatchingTopic($topic); $this->logger->debug('Delivering message received on topic [{topic}] with QoS [{qos}] from the broker to [{subscribers}] subscribers.', [ 'topic' => $topic, 'message' => $message, 'qos' => $qualityOfServiceLevel, 'subscribers' => count($subscribers), ]); foreach ($subscribers as $subscriber) { if ($subscriber->getCallback() === null) { continue; } try { call_user_func($subscriber->getCallback(), $topic, $message, $retained, $subscriber->getMatchedWildcards($topic)); } catch (\Throwable $e) { $this->logger->error('Subscriber callback threw exception for published message on topic [{topic}].', [ 'topic' => $topic, 'message' => $message, 'exception' => $e, ]); } } $this->runMessageReceivedEventHandlers($topic, $message, $qualityOfServiceLevel, $retained); } /** * Republishes pending messages. * * @throws DataTransferException * @throws InvalidMessageException */ protected function resendPendingMessages(): void { /** @noinspection PhpUnhandledExceptionInspection */ $dateTime = (new \DateTime())->sub(new \DateInterval('PT' . $this->settings->getResendTimeout() . 'S')); $messages = $this->repository->getPendingOutgoingMessagesLastSentBefore($dateTime); foreach ($messages as $pendingMessage) { if ($pendingMessage instanceof PublishedMessage) { $this->logger->debug('Re-publishing pending message to the broker.', [ 'messageId' => $pendingMessage->getMessageId(), ]); $this->publishMessage( $pendingMessage->getTopicName(), $pendingMessage->getMessage(), $pendingMessage->getQualityOfServiceLevel(), $pendingMessage->wantsToBeRetained(), $pendingMessage->getMessageId(), true ); } elseif ($pendingMessage instanceof SubscribeRequest) { $this->logger->debug('Re-sending pending subscribe request to the broker.', [ 'messageId' => $pendingMessage->getMessageId(), ]); $data = $this->messageProcessor->buildSubscribeMessage($pendingMessage->getMessageId(), $pendingMessage->getSubscriptions(), true); $this->writeToSocketWithAutoReconnect($data); } elseif ($pendingMessage instanceof UnsubscribeRequest) { $this->logger->debug('Re-sending pending unsubscribe request to the broker.', [ 'messageId' => $pendingMessage->getMessageId(), ]); $data = $this->messageProcessor->buildUnsubscribeMessage($pendingMessage->getMessageId(), $pendingMessage->getTopicFilters(), true); $this->writeToSocketWithAutoReconnect($data); } else { throw new InvalidMessageException('Unexpected message type encountered while resending pending messages.'); } $pendingMessage->setLastSentAt(new \DateTime()); $pendingMessage->incrementSendingAttempts(); } } /** * Sends a publish acknowledgement for the given message identifier. * * @throws DataTransferException */ protected function sendPublishAcknowledgement(int $messageId): void { $this->logger->debug('Sending publish acknowledgement to the broker (message id: {messageId}).', ['messageId' => $messageId]); $this->writeToSocketWithAutoReconnect($this->messageProcessor->buildPublishAcknowledgementMessage($messageId)); } /** * Sends a publish received message for the given message identifier. * * @throws DataTransferException */ protected function sendPublishReceived(int $messageId): void { $this->logger->debug('Sending publish received message to the broker (message id: {messageId}).', ['messageId' => $messageId]); $this->writeToSocketWithAutoReconnect($this->messageProcessor->buildPublishReceivedMessage($messageId)); } /** * Sends a publish release message for the given message identifier. * * @throws DataTransferException */ protected function sendPublishRelease(int $messageId): void { $this->logger->debug('Sending publish release message to the broker (message id: {messageId}).', ['messageId' => $messageId]); $this->writeToSocketWithAutoReconnect($this->messageProcessor->buildPublishReleaseMessage($messageId)); } /** * Sends a publish complete message for the given message identifier. * * @throws DataTransferException */ protected function sendPublishComplete(int $messageId): void { $this->logger->debug('Sending publish complete message to the broker (message id: {messageId}).', ['messageId' => $messageId]); $this->writeToSocketWithAutoReconnect($this->messageProcessor->buildPublishCompleteMessage($messageId)); } /** * Sends a ping message to the broker to keep the connection alive. * * @throws DataTransferException */ protected function ping(): void { $this->logger->debug('Sending ping to the broker to keep the connection alive.'); $this->writeToSocketWithAutoReconnect($this->messageProcessor->buildPingRequestMessage()); } /** * Sends a disconnect message to the broker. Does not close the socket. * * @throws DataTransferException */ protected function sendDisconnect(): void { $data = $this->messageProcessor->buildDisconnectMessage(); $this->logger->debug('Sending disconnect package to the broker.'); $this->writeToSocketWithAutoReconnect($data); } /** * Writes some data to the socket. If a {@see $length} is given, and it is shorter * than the data, only {@see $length} amount of bytes will be sent. * If configured, this method will try to reconnect in case of transmission errors. * * @throws DataTransferException */ protected function writeToSocketWithAutoReconnect(string $data, ?int $length = null): void { try { $this->writeToSocket($data, $length); } catch (DataTransferException $e) { if (!$this->settings->shouldReconnectAutomatically()) { throw $e; } try { $this->reconnect(); } catch (ConnectingToBrokerFailedException) { $this->logger->error('Automatically reconnecting to the broker while writing data to the socket failed.'); // Throw the original exception. throw $e; } // Retry writing to the socket. If this fails again, the exception is thrown as-is. $this->writeToSocket($data, $length); } } /** * Writes some data to the socket. If a {@see $length} is given, and it is shorter * than the data, only {@see $length} amount of bytes will be sent. * * @throws DataTransferException */ protected function writeToSocket(string $data, ?int $length = null): void { $calculatedLength = strlen($data); $length = min($length ?? $calculatedLength, $calculatedLength); if ($this->settings->shouldUseBlockingSocket()) { socket_set_blocking($this->socket, true); } $result = @fwrite($this->socket, $data, $length); if ($this->settings->shouldUseBlockingSocket()) { socket_set_blocking($this->socket, false); } if ($result === false || $result !== $length) { $this->logger->error('Sending data over the socket to the broker failed.'); throw new DataTransferException( DataTransferException::EXCEPTION_TX_DATA, 'Sending data over the socket failed. Has it been closed?' ); } $this->bytesSent += $length; $this->logger->debug('Sent data over the socket: {data}', ['data' => $data]); // After writing successfully to the socket, the broker should have received a new message from us. // Because we only need to send a ping if no other messages are delivered, we can safely reset the ping timer. $this->lastPingAt = microtime(true); } /** * Reads data from the socket. If the second parameter {@see $withoutBlocking} is set to true, * a maximum of {@see $limit} bytes will be read and returned. If {@see $withoutBlocking} is set to false, * the method will wait until {@see $limit} bytes have been received. * If configured, this method will try to reconnect in case of transmission errors. * * @throws DataTransferException */ protected function readFromSocketWithAutoReconnect(int $limit = self::SOCKET_READ_BUFFER_SIZE, bool $withoutBlocking = false): string { try { return $this->readFromSocket($limit, $withoutBlocking); } catch (DataTransferException $e) { if (!$this->settings->shouldReconnectAutomatically()) { throw $e; } try { $this->reconnect(); } catch (ConnectingToBrokerFailedException) { $this->logger->error('Automatically reconnecting to the broker while reading data from the socket failed.'); // Throw the original exception. throw $e; } // Retry writing to the socket. If this fails again, the exception is thrown as-is. return $this->readFromSocket($limit, $withoutBlocking); } } /** * Reads data from the socket. If the second parameter {@see $withoutBlocking} is set to true, * a maximum of {@see $limit} bytes will be read and returned. If {@see $withoutBlocking} is set to false, * the method will wait until {@see $limit} bytes have been received. * * @throws DataTransferException */ protected function readFromSocket(int $limit = self::SOCKET_READ_BUFFER_SIZE, bool $withoutBlocking = false): string { if ($withoutBlocking) { $result = fread($this->socket, $limit); if ($result === false) { $this->logger->error('Reading data from the socket of the broker failed.'); throw new DataTransferException( DataTransferException::EXCEPTION_RX_DATA, 'Reading data from the socket failed. Has it been closed?' ); } $this->bytesReceived += strlen($result); $this->logger->debug('Read data from the socket (without blocking): {data}', ['data' => $result]); return $result; } $result = ''; $remaining = $limit; $this->logger->debug('Waiting for {bytes} bytes of data.', ['bytes' => $remaining]); while (feof($this->socket) === false && $remaining > 0) { $receivedData = fread($this->socket, $remaining); if ($receivedData === false) { $this->logger->error('Reading data from the socket of the broker failed.'); throw new DataTransferException( DataTransferException::EXCEPTION_RX_DATA, 'Reading data from the socket failed. Has it been closed?' ); } $result .= $receivedData; $remaining = $limit - strlen($result); } $this->bytesReceived += strlen($result); $this->logger->debug('Read data from the socket: {data}', ['data' => $result]); return $result; } /** * Reads all the available data from the socket using non-blocking mode. Essentially this means * that {@see MqttClient::readFromSocketWithAutoReconnect()} is called over and over again, as long as data is * returned. * * @throws DataTransferException */ protected function readAllAvailableDataFromSocket(bool $withAutoReconnectIfConfigured = false): string { $result = ''; while (true) { $buffer = ($withAutoReconnectIfConfigured && $this->settings->shouldReconnectAutomatically()) ? $this->readFromSocketWithAutoReconnect(self::SOCKET_READ_BUFFER_SIZE, true) : $this->readFromSocket(self::SOCKET_READ_BUFFER_SIZE, true); $result .= $buffer; if (strlen($buffer) < self::SOCKET_READ_BUFFER_SIZE) { break; } } return $result; } /** * Closes the socket connection immediately, without flushing queued data. */ protected function closeSocket(): void { if ($this->socket === null || !is_resource($this->socket)) { return; } if (@fclose($this->socket)) { $this->logger->debug('Successfully closed socket connection to the broker.'); } else { $phpError = error_get_last(); $this->logger->notice('Closing socket connection failed: {error}', [ 'error' => $phpError ? $phpError['message'] : 'undefined', ]); } $this->socket = null; } }