Skip to main content

Overview

The Invernaderos API uses RealDataDto as the standard message format for greenhouse sensor data. Each message contains 22 fields representing temperature, humidity, sector irrigation, and extractor status across 3 greenhouses.
The payload structure is defined in RealDataDto.kt with Jackson @JsonProperty annotations for mixed naming conventions.

Message Structure

Complete Schema

Example Payload
{
  "timestamp": "2025-11-16T10:30:00Z",
  "TEMPERATURA INVERNADERO 01": 25.5,
  "HUMEDAD INVERNADERO 01": 65.2,
  "TEMPERATURA INVERNADERO 02": 26.1,
  "HUMEDAD INVERNADERO 02": 63.8,
  "TEMPERATURA INVERNADERO 03": 24.9,
  "HUMEDAD INVERNADERO 03": 67.5,
  "INVERNADERO_01_SECTOR_01": 1.0,
  "INVERNADERO_01_SECTOR_02": 0.0,
  "INVERNADERO_01_SECTOR_03": 1.0,
  "INVERNADERO_01_SECTOR_04": 0.0,
  "INVERNADERO_02_SECTOR_01": 1.0,
  "INVERNADERO_02_SECTOR_02": 1.0,
  "INVERNADERO_02_SECTOR_03": 0.0,
  "INVERNADERO_02_SECTOR_04": 1.0,
  "INVERNADERO_03_SECTOR_01": 0.0,
  "INVERNADERO_03_SECTOR_02": 1.0,
  "INVERNADERO_03_SECTOR_03": 1.0,
  "INVERNADERO_03_SECTOR_04": 0.0,
  "INVERNADERO_01_EXTRACTOR": 0.0,
  "INVERNADERO_02_EXTRACTOR": 1.0,
  "INVERNADERO_03_EXTRACTOR": 0.0,
  "RESERVA": 0.0,
  "greenhouseId": "550e8400-e29b-41d4-a716-446655440000",
  "tenantId": "SARA"
}

Field Groups

Temperature & Humidity (6 fields)

Environmental sensors for 3 greenhouses:
FieldTypeUnitDescription
TEMPERATURA INVERNADERO 01Double?°CTemperature in greenhouse 1
HUMEDAD INVERNADERO 01Double?%Humidity in greenhouse 1
Typical Range:
  • Temperature: 15-30°C
  • Humidity: 40-80%
Example:
{
  "TEMPERATURA INVERNADERO 01": 25.5,
  "HUMEDAD INVERNADERO 01": 65.2
}

Irrigation Sectors (12 fields)

Binary status (0=OFF, 1=ON) for 4 sectors per greenhouse:
Field names use UNDERSCORES (INVERNADERO_01_SECTOR_01), unlike temperature/humidity which use SPACES.
GreenhouseSectors
01INVERNADERO_01_SECTOR_01 through INVERNADERO_01_SECTOR_04
02INVERNADERO_02_SECTOR_01 through INVERNADERO_02_SECTOR_04
03INVERNADERO_03_SECTOR_01 through INVERNADERO_03_SECTOR_04
Values:
  • 0.0 or 0 - Sector irrigation OFF
  • 1.0 or 1 - Sector irrigation ON
Example:
{
  "INVERNADERO_01_SECTOR_01": 1.0,  // ON
  "INVERNADERO_01_SECTOR_02": 0.0,  // OFF
  "INVERNADERO_01_SECTOR_03": 1.0,  // ON
  "INVERNADERO_01_SECTOR_04": 0.0   // OFF
}

Extractors (3 fields)

Ventilation extractor status (0=OFF, 1=ON):
FieldTypeDescription
INVERNADERO_01_EXTRACTORDouble?Extractor in greenhouse 1 (0=OFF, 1=ON)
INVERNADERO_02_EXTRACTORDouble?Extractor in greenhouse 2 (0=OFF, 1=ON)
INVERNADERO_03_EXTRACTORDouble?Extractor in greenhouse 3 (0=OFF, 1=ON)
Example:
{
  "INVERNADERO_01_EXTRACTOR": 0.0,  // OFF
  "INVERNADERO_02_EXTRACTOR": 1.0,  // ON (extracting air)
  "INVERNADERO_03_EXTRACTOR": 0.0   // OFF
}

Metadata Fields (4 fields)

FieldTypeRequiredDescription
timestampInstant✅ YesISO-8601 timestamp (auto-generated if omitted)
greenhouseIdString?❌ OptionalUUID of greenhouse (auto-assigned by API)
tenantIdString?❌ OptionalTenant identifier for multi-tenancy
RESERVADouble?❌ OptionalReserved field for future use
Timestamp Format:
"timestamp": "2025-11-16T10:30:00Z"  // ISO-8601 UTC
"timestamp": "2025-11-16T10:30:00.123Z"  // With milliseconds

Data Types & Validation

Type Definitions

From RealDataDto.kt:8-82:
data class RealDataDto(
    val timestamp: Instant,
    @JsonProperty("TEMPERATURA INVERNADERO 01") val temperaturaInvernadero01: Double? = null,
    @JsonProperty("HUMEDAD INVERNADERO 01") val humedadInvernadero01: Double? = null,
    // ... 18 more fields ...
    @JsonProperty("RESERVA") val reserva: Double? = null,
    val greenhouseId: String? = null,
    val tenantId: String? = null
)

Validation Rules

Configured in staging.validation_rules table:
SELECT * FROM staging.validation_rules WHERE sensor_type = 'TEMPERATURE';
RuleValue
Min Value-50°C
Max Value100°C
Typical Range15-30°C
Unit°C (Celsius)
Validation Logic:
if (value < -50 || value > 100) {
    throw ValidationException("Temperature out of range: $value°C")
}
The API uses nullable types (Double?). Missing fields default to null, not 0.0.

Message Processing

API Processing Flow

From MqttMessageProcessor.kt:128-260:
1

Receive MQTT Message

Message arrives on topic GREENHOUSE/{tenantId} or legacy GREENHOUSE.
fun processGreenhouseData(jsonPayload: String, tenantId: String)
2

Parse JSON to DTO

Convert JSON to RealDataDto using Jackson:
val messageDto = jsonPayload.toRealDataDto(
    timestamp = Instant.now(),
    greenhouseId = tenantId,
    tenantId = tenantId
)
3

Cache in Redis

Store complete message in Redis Sorted Set:
greenhouseCacheService.cacheMessage(messageDto)
// Stored in: "greenhouse:messages:{tenantId}"
4

Transform to SensorReading Entities

Split 22 fields into individual time-series records:
data.properties().forEach { (key, value) ->
    SensorReading(
        time = timestamp,
        sensorId = key,  // "TEMPERATURA INVERNADERO 01"
        greenhouseId = greenhouse.id,  // UUID
        tenantId = tenant.id,  // UUID
        sensorType = determineSensorType(key),  // "TEMPERATURE"
        value = value.asDouble(),
        unit = determineUnit(key)  // "°C"
    )
}
Result: 22 JSON fields → 22 database rows
5

Save to TimescaleDB

Batch insert to iot.sensor_readings hypertable:
sensorReadingRepository.saveAll(sensorReadings)
6

Broadcast via WebSocket

Publish to /topic/greenhouse/messages for real-time clients:
eventPublisher.publishEvent(GreenhouseMessageEvent(this, messageDto))

Code Examples

Publishing Messages

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper

val mapper = jacksonObjectMapper()
val sensorData = mapOf(
    "TEMPERATURA INVERNADERO 01" to 25.5,
    "HUMEDAD INVERNADERO 01" to 65.2,
    "INVERNADERO_01_SECTOR_01" to 1.0,
    "INVERNADERO_01_EXTRACTOR" to 0.0
)

val payload = mapper.writeValueAsString(sensorData)
client.publish("GREENHOUSE/SARA", payload)

Parsing Received Messages

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue

val mapper = jacksonObjectMapper()

client.subscribe("GREENHOUSE/+") { topic, message ->
    val payload = String(message.payload)
    val data = mapper.readValue<Map<String, Any>>(payload)
    
    val temp01 = data["TEMPERATURA INVERNADERO 01"] as? Double
    val humidity01 = data["HUMEDAD INVERNADERO 01"] as? Double
    
    println("Temp: $temp01°C, Humidity: $humidity01%")
}

Validating Messages

Validator.kt
fun validateSensorData(data: Map<String, Any?>): List<String> {
    val errors = mutableListOf<String>()
    
    // Validate temperature fields
    listOf("01", "02", "03").forEach { num ->
        val temp = data["TEMPERATURA INVERNADERO $num"] as? Double
        if (temp != null && (temp < -50 || temp > 100)) {
            errors.add("Temperature $num out of range: $temp°C")
        }
        
        val humidity = data["HUMEDAD INVERNADERO $num"] as? Double
        if (humidity != null && (humidity < 0 || humidity > 100)) {
            errors.add("Humidity $num out of range: $humidity%")
        }
    }
    
    // Validate sectors
    listOf("01", "02", "03").forEach { greenhouse ->
        listOf("01", "02", "03", "04").forEach { sector ->
            val value = data["INVERNADERO_${greenhouse}_SECTOR_${sector}"] as? Double
            if (value != null && value != 0.0 && value != 1.0) {
                errors.add("Sector ${greenhouse}_${sector} must be 0 or 1: $value")
            }
        }
    }
    
    return errors
}

Common Patterns

Partial Updates

You can send partial payloads with only changed fields:
Partial Payload Example
{
  "TEMPERATURA INVERNADERO 01": 26.3,
  "INVERNADERO_01_SECTOR_01": 1.0
}
API Behavior:
  • Missing fields are stored as null
  • Only provided fields are inserted into TimescaleDB
  • Redis cache stores the complete DTO (with nulls)

Bulk Publishing

For high-frequency sensors, batch multiple readings:
Bulk Publishing
const readings = [
  { "TEMPERATURA INVERNADERO 01": 25.5, timestamp: "2025-11-16T10:30:00Z" },
  { "TEMPERATURA INVERNADERO 01": 25.6, timestamp: "2025-11-16T10:30:05Z" },
  { "TEMPERATURA INVERNADERO 01": 25.7, timestamp: "2025-11-16T10:30:10Z" }
];

readings.forEach(reading => {
  client.publish('GREENHOUSE/SARA', JSON.stringify(reading));
});
The API applies rate limiting to reduce database load. See SensorRateLimiter for throttling logic.

Field Naming Gotcha

CRITICAL: Field names use inconsistent separators:
  • Temperature/Humidity: SPACES ("TEMPERATURA INVERNADERO 01")
  • Sectors/Extractors: UNDERSCORES ("INVERNADERO_01_SECTOR_01")
This matches the actual hardware output format. Do NOT change field names or hardware integration will break.

Troubleshooting

Cause: Incorrect field names (spaces vs underscores)Solution:
  • Temperature/Humidity: Use SPACES ("TEMPERATURA INVERNADERO 01")
  • Sectors/Extractors: Use UNDERSCORES ("INVERNADERO_01_SECTOR_01")
  • Check JSON keys match exactly (case-sensitive)
Cause: Values outside acceptable rangesSolution:
  • Temperature: -50 to 100°C
  • Humidity: 0 to 100%
  • Sectors/Extractors: Only 0 or 1
  • Check staging.validation_rules table for limits
Cause: Invalid ISO-8601 formatSolution:
  • Use UTC timezone: 2025-11-16T10:30:00Z
  • Or omit timestamp (API generates current time)
  • Avoid non-standard formats: 2025/11/16 10:30:00
Cause: Rate limiting or validation failureSolution:
  • Check API logs for validation errors
  • Verify SensorRateLimiter allows save frequency
  • Confirm greenhouse/tenant exists in database

Next Steps

Setup MQTT Client

Configure connection to EMQX broker

Authentication

Manage MQTT credentials and ACLs

Build docs developers (and LLMs) love