Skip to main content

Overview

The Invernaderos API uses STOMP over WebSocket to broadcast real-time sensor data to connected web and mobile clients. This enables live dashboard updates without polling.
Protocol: STOMP (Simple Text Oriented Messaging Protocol) over WebSocketFallback: SockJS for browsers without native WebSocket supportMessage Broker: Simple in-memory broker (production-ready for moderate load)

Configuration

WebSocket Endpoints

The system exposes two WebSocket endpoints:
WebSocketConfig.kt
@Configuration
@EnableWebSocketMessageBroker
class WebSocketConfig : WebSocketMessageBrokerConfigurer {

    private val logger = LoggerFactory.getLogger(WebSocketConfig::class.java)

    /**
     * Configure the message broker
     *
     * - /topic: prefix for topics (one-to-many broadcast)
     * - /app: prefix for messages directed to the application
     * - /user: prefix for user-specific messages
     */
    override fun configureMessageBroker(registry: MessageBrokerRegistry) {
        logger.info("Configuring Message Broker for WebSocket")

        // Enable simple in-memory broker
        registry.enableSimpleBroker("/topic", "/queue")

        // Prefix for messages to @MessageMapping methods
        registry.setApplicationDestinationPrefixes("/app")

        // Prefix for sending messages to specific users
        registry.setUserDestinationPrefix("/user")
    }

    /**
     * Register STOMP endpoints
     *
     * - Main endpoint: /ws/greenhouse
     * - SockJS enabled for browser compatibility
     * - CORS allowed from any origin (adjust in production)
     */
    override fun registerStompEndpoints(registry: StompEndpointRegistry) {
        logger.info("Registering STOMP endpoints")

        // Endpoint with SockJS fallback
        registry.addEndpoint("/ws/greenhouse")
            .setAllowedOriginPatterns("*")  // ⚠️ Configure for production
            .withSockJS()

        // Native WebSocket endpoint (no SockJS)
        registry.addEndpoint("/ws/greenhouse-native")
            .setAllowedOriginPatterns("*")

        logger.info("WebSocket endpoints registered:")
        logger.info("  - ws://host/ws/greenhouse (with SockJS)")
        logger.info("  - ws://host/ws/greenhouse-native (native)")
        logger.info("Topics available:")
        logger.info("  - /topic/greenhouse/messages")
        logger.info("  - /topic/greenhouse/statistics")
    }
}
CORS Configuration: The current configuration allows connections from any origin (*). In production, restrict this to your frontend domains:
.setAllowedOriginPatterns("https://app.invernaderos.com", "https://dashboard.invernaderos.com")

Subscription Topics

Clients can subscribe to the following STOMP topics:
Topic: /topic/greenhouse/messages
Purpose: Real-time sensor data broadcasts
Message Format: RealDataDto (JSON with 22 fields)
Update Frequency: Every 5 seconds (or on MQTT message)

Sample Message:
{
  "timestamp": "2025-11-16T10:30:00Z",
  "TEMPERATURA INVERNADERO 01": 24.5,
  "HUMEDAD INVERNADERO 01": 62.3,
  "INVERNADERO_01_SECTOR_01": 1,
  "INVERNADERO_01_EXTRACTOR": 0,
  "greenhouseId": "SARA"
}
Use Case: Live dashboard displays, real-time charts, alerts

Event-Driven Architecture

Message Flow

WebSocket broadcasting is decoupled from MQTT processing using Spring’s event system:
Benefits:
  • Decoupling: MQTT processing doesn’t depend on WebSocket availability
  • Non-blocking: Event publishing is asynchronous (doesn’t block MQTT thread)
  • Scalability: Multiple listeners can react to the same event
  • Testability: Easy to test components in isolation
Alternative (NOT used):
// ❌ BAD: Direct coupling
fun processGreenhouseData(payload: String) {
    // ... process data ...
    webSocketHandler.broadcast(data)  // Direct dependency
}

Client Integration

JavaScript Client (SockJS + STOMP.js)

Connect to WebSocket endpoint from browser:
<!DOCTYPE html>
<html>
<head>
    <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/stomp.min.js"></script>
</head>
<body>
    <h1>Greenhouse Dashboard</h1>
    <div id="sensor-data"></div>
    <script src="app.js"></script>
</body>
</html>

Kotlin Client (Spring WebSocket)

Connect from another Spring Boot application:
@Configuration
class WebSocketClientConfig {

    @Bean
    fun webSocketClient(): WebSocketStompClient {
        val client = WebSocketStompClient(StandardWebSocketClient())
        client.messageConverter = MappingJackson2MessageConverter()
        return client
    }

    @Bean
    fun connectToGreenhouseWebSocket(client: WebSocketStompClient): StompSession {
        val sessionHandler = object : StompSessionHandlerAdapter() {
            override fun afterConnected(
                session: StompSession,
                connectedHeaders: StompHeaders
            ) {
                println("Connected to Greenhouse WebSocket")
                
                // Subscribe to messages
                session.subscribe("/topic/greenhouse/messages", this)
            }

            override fun handleFrame(headers: StompHeaders, payload: Any?) {
                val data = payload as RealDataDto
                println("Received: ${data.timestamp} - Temp: ${data.temperaturaInvernadero01}")
            }
        }

        return client.connect("ws://localhost:8080/ws/greenhouse", sessionHandler).get()
    }
}

Broadcasting Messages

Programmatic Broadcasting

Send messages to topics from application code:
GreenhouseWebSocketHandler.kt
@Component
class GreenhouseWebSocketHandler(
    private val messagingTemplate: SimpMessagingTemplate
) {

    /**
     * Send message to specific topic
     */
    fun sendMessage(destination: String, payload: Any) {
        try {
            messagingTemplate.convertAndSend(destination, payload)
            logger.debug("Message sent to {}", destination)
        } catch (e: Exception) {
            logger.error("Error sending message to {}", destination, e)
        }
    }

    /**
     * Send message to specific user
     */
    fun sendToUser(username: String, destination: String, payload: Any) {
        try {
            messagingTemplate.convertAndSendToUser(username, destination, payload)
            logger.debug("Message sent to user {} at {}", username, destination)
        } catch (e: Exception) {
            logger.error("Error sending message to user {}", username, e)
        }
    }

    /**
     * Broadcast statistics to all clients
     */
    fun broadcastStatistics(statistics: Any) {
        try {
            messagingTemplate.convertAndSend("/topic/greenhouse/statistics", statistics)
            logger.debug("Statistics broadcasted via WebSocket")
        } catch (e: Exception) {
            logger.error("Error broadcasting statistics via WebSocket", e)
        }
    }
}

User-Specific Messages

Send alerts to individual users:
@Service
class AlertService(
    private val webSocketHandler: GreenhouseWebSocketHandler
) {

    fun sendAlertToUser(username: String, alert: Alert) {
        webSocketHandler.sendToUser(
            username = username,
            destination = "/queue/alerts",
            payload = alert
        )
    }
}

// Client subscribes to:
// /user/{username}/queue/alerts

Scaling Considerations

In-Memory Broker Limitations

The current simple in-memory broker has limitations:
Limitations:
  • Single-instance only (no horizontal scaling)
  • No message persistence (lost on restart)
  • All messages processed in same JVM
When to upgrade: If you need multi-instance deployment or high availability.

External Message Broker (RabbitMQ)

For production scale, use an external broker:
application.yaml
spring:
  rabbitmq:
    host: rabbitmq.invernaderos.com
    port: 5672
    username: ${RABBITMQ_USERNAME}
    password: ${RABBITMQ_PASSWORD}
@Configuration
@EnableWebSocketMessageBroker
class WebSocketConfig : WebSocketMessageBrokerConfigurer {

    override fun configureMessageBroker(registry: MessageBrokerRegistry) {
        // Use external RabbitMQ broker instead of simple in-memory
        registry.enableStompBrokerRelay("/topic", "/queue")
            .setRelayHost("rabbitmq.invernaderos.com")
            .setRelayPort(61613)  // STOMP port
            .setClientLogin("api-client")
            .setClientPasscode("password")
            .setSystemLogin("system")
            .setSystemPasscode("password")

        registry.setApplicationDestinationPrefixes("/app")
    }
}
Benefits of RabbitMQ:
  • Multiple API instances can share broker
  • Message persistence and durability
  • Higher throughput and scalability
  • Advanced routing and filtering

Testing WebSocket Connections

Browser DevTools

Test WebSocket connection in browser console:
// Open browser console (F12) and run:
const socket = new SockJS('http://localhost:8080/ws/greenhouse');
const client = Stomp.over(socket);

client.connect({}, function() {
    console.log('Connected!');
    
    client.subscribe('/topic/greenhouse/messages', function(message) {
        console.log('Received:', JSON.parse(message.body));
    });
});

wscat (Command-Line Tool)

Test native WebSocket endpoint:
# Install wscat
npm install -g wscat

# Connect to native WebSocket endpoint
wscat -c ws://localhost:8080/ws/greenhouse-native

# Send STOMP CONNECT frame
CONNECT
accept-version:1.1,1.0
heart-beat:10000,10000

# Send SUBSCRIBE frame
SUBSCRIBE
id:sub-0
destination:/topic/greenhouse/messages

# You should start receiving messages

Integration Test

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class WebSocketIntegrationTest {

    @LocalServerPort
    private var port: Int = 0

    private lateinit var stompClient: WebSocketStompClient
    private lateinit var session: StompSession

    @BeforeEach
    fun setup() {
        stompClient = WebSocketStompClient(StandardWebSocketClient())
        stompClient.messageConverter = MappingJackson2MessageConverter()
    }

    @Test
    fun `should receive greenhouse messages via WebSocket`() {
        // Given
        val completableFuture = CompletableFuture<RealDataDto>()
        val url = "ws://localhost:$port/ws/greenhouse"

        // Connect and subscribe
        val sessionHandler = object : StompSessionHandlerAdapter() {
            override fun afterConnected(session: StompSession, connectedHeaders: StompHeaders) {
                session.subscribe("/topic/greenhouse/messages", object : StompFrameHandler {
                    override fun getPayloadType(headers: StompHeaders): Type {
                        return RealDataDto::class.java
                    }

                    override fun handleFrame(headers: StompHeaders, payload: Any?) {
                        completableFuture.complete(payload as RealDataDto)
                    }
                })
            }
        }

        session = stompClient.connect(url, sessionHandler).get(5, TimeUnit.SECONDS)

        // When - trigger MQTT message that broadcasts to WebSocket
        mqttPublisher.publish("GREENHOUSE/SARA", testPayload)

        // Then - receive message via WebSocket
        val receivedData = completableFuture.get(5, TimeUnit.SECONDS)
        assertThat(receivedData.greenhouseId).isEqualTo("SARA")
        assertThat(receivedData.temperaturaInvernadero01).isNotNull()
    }
}

Best Practices

  1. Use SockJS fallback for browser compatibility
  2. Implement auto-reconnect with exponential backoff
  3. Restrict CORS origins in production
  4. Use user-specific topics for private notifications
  5. Monitor connection count to prevent resource exhaustion
  6. Consider external broker (RabbitMQ) for multi-instance deployments
  7. Handle disconnections gracefully in client code
  8. Compress messages for mobile clients (low bandwidth)
For MQTT message ingestion, see MQTT Integration.For multi-tenant data filtering, see Multi-Tenant Architecture.

Build docs developers (and LLMs) love