Spring Boot example
A complete, copy-pasteable Spring Boot (Java) integration that consumes realtime telemetry over AMQPS and publishes commands back to Voke, including HMAC signing for high-stakes commands.
Overview
This page is an end-to-end Spring Boot example for the two flows partners use most:
- Consume realtime telemetry from your AMQPS queue.
- Publish commands back to Voke (setpoints, battery charge/discharge, …).
It uses Spring AMQP (spring-boot-starter-amqp) and matches the VCP 1.1 wire contract exactly, including the canonical-JSON HMAC signature required for high-stakes commands.
This example as a runnable project: voke-partner-examples/java/spring-boot
— clone, copy .env.example to .env, mvn spring-boot:run. (TypeScript version:
typescript/node-amqp.)
The queues and the vcp topic exchange already exist in your per-key vhost — your app
only consumes and publishes; it never declares topology.
AMQP topology
| Detail | |
|---|---|
| Connect | amqps://{orgSlug}:{apiKey}@amqp.voke.turena.cz:5671/partner-{keyId} (TLS; vhost is per API key) |
| Consume realtime telemetry | queue vcp.{slug}.event.telemetry, routing key …telemetry.realtime.{siteId}. The 1-minute meter readings share the queue on …telemetry.meter.{siteId}. The default queue is bound with #, so it receives every site — match on the routing-key prefix, not an exact suffix (see below). To stream a single PLC, bind your own queue per site. |
| Publish commands | topic exchange vcp, routing key {slug}.command.site-setpoint (also .device, .mode, .emergency, plus {slug}.schedule.create / .cancel) |
| Acks / results | queues vcp.{slug}.event.status (command ACK/NACK) and vcp.{slug}.event.execution, correlate by correlationId |
Every message is a VCP envelope:
{
"version": "1.1",
"messageId": "<uuid v4 — must be unique per message>",
"timestamp": "2026-06-13T09:00:00.000Z",
"source": "obzor-energy-flems",
"siteId": "<plant UUID from GET /vcp/sites>",
"payload": { "...": "..." },
"signatureAlgo": "HMAC-SHA256",
"signature": "<base64url — only on signed commands>"
}HMAC is required for command.device, command.mode, and schedule.*.
command.site-setpoint and command.emergency do not need a signature.
messageId must be unique — Voke rejects replays.
Dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- Required by the JSON listeners below. `spring-boot-starter-amqp` does NOT
pull jackson-databind transitively (spring-amqp declares it optional), so
a telemetry-only app must add it explicitly. -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- java.time + crypto are JDK -->Configuration
# application.yml
spring:
rabbitmq:
host: amqp.voke.turena.cz
port: 5671
username: obzor-energy # your org slug (AMQP user)
password: ${VOKE_API_KEY} # the API key from the bundle (AMQP password)
virtual-host: partner-<your-key-id>
ssl:
enabled: true # AMQPS / TLS on 5671
algorithm: TLSv1.3 # REQUIRED — see TLS note below. `enabled: true`
# alone negotiates TLS 1.2 and the broker
# (TLS-1.3-only) rejects it with `protocol_version`.
voke:
org-slug: obzor-energy
source: obzor-energy-flems # free-form publisher id, goes in envelope.source
hmac-key-hex: ${VOKE_HMAC_KEY} # signing key (hex) — only for device/mode/scheduleEnvelope + HMAC signing
The envelope is built as a Map so we control exactly which fields are present. The signature is an
HMAC-SHA256 over the canonical JSON (keys sorted lexicographically, no whitespace) of the
envelope without signature but with signatureAlgo, encoded base64url without padding.
The signing key is a hex string — hex-decode it to raw bytes before using it as the HMAC key
(do not use the UTF-8 bytes of the hex string). This is the most common cause of
INVALID_SIGNATURE.
package com.obzor.voke;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.*;
/** Builds + (optionally) signs a VCP 1.1 envelope. */
public final class VcpEnvelope {
// Canonical mapper: sort every object's keys, compact output (no whitespace).
private static final ObjectMapper CANONICAL = JsonMapper.builder()
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true)
.build();
/** Build an unsigned envelope (use for site-setpoint / emergency). */
public static Map<String, Object> build(String source, String siteId, Object payload) {
Map<String, Object> env = new HashMap<>();
env.put("version", "1.1");
env.put("messageId", UUID.randomUUID().toString()); // MUST be unique per message
env.put("timestamp", Instant.now().toString()); // ISO-8601 UTC
env.put("source", source);
env.put("siteId", siteId);
env.put("payload", payload);
return env;
}
/** Build a signed envelope (required for command.device / command.mode / schedule.*). */
public static Map<String, Object> buildSigned(String source, String siteId,
Object payload, String hmacKeyHex) {
Map<String, Object> env = build(source, siteId, payload);
env.put("signatureAlgo", "HMAC-SHA256");
try {
// Sign canonical JSON of the envelope WITHOUT `signature` (but WITH signatureAlgo).
String canonical = CANONICAL.writeValueAsString(env);
byte[] keyBytes = HexFormat.of().parseHex(hmacKeyHex); // hex -> raw bytes
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(keyBytes, "HmacSHA256"));
byte[] sig = mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8));
env.put("signature", Base64.getUrlEncoder().withoutPadding().encodeToString(sig));
return env;
} catch (Exception e) {
throw new IllegalStateException("Failed to sign VCP envelope", e);
}
}
private VcpEnvelope() {}
}RabbitMQ config
The queue and exchange already exist in your vhost, so we only wire a JSON converter.
package com.obzor.voke;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RabbitConfig {
@Bean
Jackson2JsonMessageConverter jsonConverter() { return new Jackson2JsonMessageConverter(); }
@Bean
RabbitTemplate rabbitTemplate(ConnectionFactory cf, Jackson2JsonMessageConverter conv) {
RabbitTemplate t = new RabbitTemplate(cf);
t.setMessageConverter(conv);
return t;
}
}Consume realtime telemetry
Realtime snapshots (~10 s) and the 1-minute meter readings arrive on the same queue — switch on
the routing key. The key ends with the site UUID (…telemetry.realtime.{siteId}), so match the
type segment with contains, not endsWith — the trailing siteId also gives you the site
without parsing the envelope.
package com.obzor.voke;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
@Component
public class TelemetryListener {
// Parse the raw JSON body directly. (Do NOT use
// Jackson2JsonMessageConverter.fromMessage(msg, JsonNode.class) — it returns a
// LinkedHashMap, so casting to JsonNode throws ClassCastException.)
private final ObjectMapper mapper = new ObjectMapper();
@RabbitListener(queues = "vcp.${voke.org-slug}.event.telemetry")
public void onTelemetry(Message message) throws Exception {
JsonNode env = mapper.readTree(message.getBody());
String rk = message.getMessageProperties().getReceivedRoutingKey();
JsonNode p = env.get("payload");
String siteId = env.path("siteId").asText();
if (rk.contains(".telemetry.realtime")) {
// Instantaneous snapshot. gridPowerKw > 0 = import, < 0 = export (přetok).
// Routing key is …telemetry.realtime.{siteId} — siteId also in the envelope.
System.out.printf("[realtime] site=%s grid=%s kW fve=%s kW soc=%s%% mode=%s%n",
siteId, p.path("gridPowerKw").asText("null"),
p.path("fvePowerKw").asText("null"),
p.path("socPercent").asText("null"),
p.path("currentOperatingMode").asText());
} else if (rk.contains(".telemetry.meter")) {
// 1-min cumulative meter registers. Field is `deviceId` (was meterId);
// payload now also carries eanCons / eanProd.
System.out.printf("[meter] site=%s eanCons=%s%n",
siteId, p.path("eanCons").asText("null"));
for (JsonNode m : p.path("meters")) {
System.out.printf(" %s (%s) import=%s export=%s kWh%n",
m.path("deviceId").asText(), m.path("role").asText(),
m.path("importRegisterKwh").asText("-"),
m.path("exportRegisterKwh").asText("-"));
}
}
}
}Publish commands
package com.obzor.voke;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@Service
public class CommandSender {
private static final String EXCHANGE = "vcp";
private final RabbitTemplate rabbit;
@Value("${voke.org-slug}") String slug;
@Value("${voke.source}") String source;
@Value("${voke.hmac-key-hex:}") String hmacKeyHex;
public CommandSender(RabbitTemplate rabbit) { this.rabbit = rabbit; }
/** Site setpoint — NO signature required. Example: cap export to 50 kW. */
public void sendSetpoint(String siteId, double targetKw, String direction) {
Map<String, Object> payload = Map.of(
"type", "POWER",
"targetValueKw", targetKw,
"direction", direction, // "IMPORT" | "EXPORT"
"includeConsumption", false,
"priority", "NORMAL",
"validFrom", Instant.now().toString()
);
Map<String, Object> env = VcpEnvelope.build(source, siteId, payload);
rabbit.convertAndSend(EXCHANGE, slug + ".command.site-setpoint", env);
}
/** Battery charge/discharge — SIGNED (command.device requires HMAC). */
public void sendBatteryCommand(String siteId, String deviceId, String command) {
Map<String, Object> payload = Map.of(
"commands", List.of(Map.of(
"deviceId", deviceId, // e.g. "BAT1"
"assetType", "BESS",
"command", command // "BESS_CHARGE" | "BESS_DISCHARGE" | "BESS_STOP"
))
);
Map<String, Object> env = VcpEnvelope.buildSigned(source, siteId, payload, hmacKeyHex);
rabbit.convertAndSend(EXCHANGE, slug + ".command.device", env);
}
}Acks and execution results
Voke replies on two queues — listen to them the same way as telemetry and correlate by
correlationId:
vcp.{slug}.event.status— command ACK / NACK (accepted, rejected, partial).vcp.{slug}.event.execution— per-device execution outcome once the PLC has acted.
// private final ObjectMapper mapper = new ObjectMapper();
@RabbitListener(queues = "vcp.${voke.org-slug}.event.status")
public void onAck(Message message) throws Exception {
JsonNode env = mapper.readTree(message.getBody());
System.out.printf("[ack] correlationId=%s status=%s%n",
env.path("correlationId").asText("-"),
env.path("payload").path("status").asText());
}Notes
- What changed recently on AMQP: only the meter-reading payload —
MeterEntry.meterIdwas renamed todeviceId, andeanCons/eanProdwere added. Realtime telemetry and the command contract are unchanged. - Number formatting in signed payloads: prefer integer values in command payloads. The canonical serializer must produce byte-identical output on both ends; integers are unambiguous, exotic floating-point formatting is not.
- TLS (read this — it's the #1 connection failure): port 5671 is AMQPS and the broker is
TLS 1.3-only.
spring.rabbitmq.ssl.enabled: trueon its own negotiates TLS 1.2, which the broker rejects — you getSSLHandshakeException: Received fatal alert: protocol_versionand the app never connects. You must also setspring.rabbitmq.ssl.algorithm: TLSv1.3(as in the config above). No client certificate is required — the API key is the credential, and the system truststore already trusts the broker's public Let's Encrypt cert. If you hand-roll aConnectionFactory, set the protocol to TLS 1.3 explicitly; the no-arguseSslProtocol()of the RabbitMQ Java client pins TLS 1.2 and is rejected.
Rychlý start — Voke ESM (CZ)
Kompletní průchod integrací proti živé Voke organizaci. Odhadovaný čas: 15 minut.
Spring Boot příklad (CZ)
Kompletní Spring Boot (Java) integrace ke zkopírování — příjem realtime telemetrie přes AMQPS a odesílání příkazů zpět do Voke, včetně HMAC podpisu pro citlivé příkazy.