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.
Přehled
Tato stránka je kompletní Spring Boot příklad pro dva nejčastější partnerské scénáře:
- Příjem realtime telemetrie z vaší AMQPS fronty.
- Odesílání příkazů (commands) zpět do Voke (setpointy, nabíjení/vybíjení baterie, …).
Používá Spring AMQP (spring-boot-starter-amqp) a přesně odpovídá VCP 1.1 kontraktu, včetně
canonical-JSON HMAC podpisu vyžadovaného u citlivých příkazů.
Tento příklad jako spustitelný projekt: voke-partner-examples/java/spring-boot
— naklonujte, zkopírujte .env.example na .env, mvn spring-boot:run. (TypeScript verze:
typescript/node-amqp.)
Fronty (queues) i topic exchange vcp ve vašem per-key vhostu už existují — vaše aplikace pouze
konzumuje a publikuje; topologii nikdy nedeklaruje.
AMQP topologie
| Detail | |
|---|---|
| Připojení | amqps://{orgSlug}:{apiKey}@amqp.voke.turena.cz:5671/partner-{keyId} (TLS; vhost je per API klíč) |
| Příjem realtime telemetrie | queue vcp.{slug}.event.telemetry, routing key …telemetry.realtime.{siteId}. Minutové odečty elektroměru chodí na stejné frontě s routing key …telemetry.meter.{siteId}. Výchozí fronta je navázaná přes #, takže přijímá všechny lokality — porovnávejte prefix routing key, ne přesný suffix (viz níže). Pro stream jediného PLC si navažte vlastní frontu na lokalitu. |
| Odesílání příkazů | topic exchange vcp, routing key {slug}.command.site-setpoint (dále .device, .mode, .emergency, a {slug}.schedule.create / .cancel) |
| Potvrzení / výsledky | queues vcp.{slug}.event.status (ACK/NACK příkazu) a vcp.{slug}.event.execution, párujte přes correlationId |
Každá zpráva je VCP envelope:
{
"version": "1.1",
"messageId": "<uuid v4 — musí být unikátní pro každou zprávu>",
"timestamp": "2026-06-13T09:00:00.000Z",
"source": "obzor-energy-flems",
"siteId": "<UUID lokality z GET /vcp/sites>",
"payload": { "...": "..." },
"signatureAlgo": "HMAC-SHA256",
"signature": "<base64url — pouze u podepsaných příkazů>"
}HMAC podpis je povinný u command.device, command.mode a schedule.*.
command.site-setpoint a command.emergency podpis nepotřebují.
messageId musí být unikátní — Voke odmítá replaye.
Závislosti
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- Nutné pro JSON listenery níže. `spring-boot-starter-amqp` NEpřináší
jackson-databind tranzitivně (spring-amqp ho deklaruje jako optional),
takže telemetry-only aplikace ho musí přidat explicitně. -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- java.time + crypto jsou součástí JDK -->Konfigurace
# application.yml
spring:
rabbitmq:
host: amqp.voke.turena.cz
port: 5671
username: obzor-energy # váš org slug (AMQP user)
password: ${VOKE_API_KEY} # API klíč z bundlu (AMQP password)
virtual-host: partner-<your-key-id>
ssl:
enabled: true # AMQPS / TLS na 5671
algorithm: TLSv1.3 # POVINNÉ — viz TLS poznámka níže. Samotné `enabled: true`
# vyjedná TLS 1.2 a broker (pouze TLS 1.3) ho odmítne
# chybou `protocol_version`.
voke:
org-slug: obzor-energy
source: obzor-energy-flems # volný identifikátor odesílatele, jde do envelope.source
hmac-key-hex: ${VOKE_HMAC_KEY} # signing key (hex) — jen pro device/mode/scheduleEnvelope + HMAC podpis
Envelope sestavujeme jako Map, abychom měli plnou kontrolu nad tím, která pole obsahuje. Signature je
HMAC-SHA256 nad canonical JSON (klíče seřazené lexikograficky, bez whitespace) envelope
bez pole signature, ale s polem signatureAlgo, zakódovaný base64url bez paddingu.
Signing key je hex string — před použitím jako HMAC klíč ho dekódujte z hexu na raw bytes
(ne UTF-8 bajty hex stringu). Tohle je nejčastější příčina 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.*;
/** Sestaví a (volitelně) podepíše VCP 1.1 envelope. */
public final class VcpEnvelope {
// Canonical mapper: seřadí klíče každého objektu, kompaktní výstup (bez whitespace).
private static final ObjectMapper CANONICAL = JsonMapper.builder()
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true)
.build();
/** Sestaví nepodepsaný envelope (pro 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()); // MUSÍ být unikátní pro každou zprávu
env.put("timestamp", Instant.now().toString()); // ISO-8601 UTC
env.put("source", source);
env.put("siteId", siteId);
env.put("payload", payload);
return env;
}
/** Sestaví podepsaný envelope (povinné pro 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 {
// Podepisujeme canonical JSON envelope BEZ `signature` (ale SE 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 konfigurace
Queue i exchange už ve vašem vhostu existují, takže napojujeme jen 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;
}
}Příjem realtime telemetrie
Realtime snapshoty (~10 s) i minutové odečty elektroměru chodí na stejné frontě — rozlišujte
podle routing key. Klíč končí UUID lokality (…telemetry.realtime.{siteId}), takže typový segment
porovnávejte pomocí contains, ne endsWith — koncový siteId vám zároveň dá lokalitu bez
parsování obálky.
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 {
// Parsujeme raw JSON body přímo. (NEpoužívejte
// Jackson2JsonMessageConverter.fromMessage(msg, JsonNode.class) — vrací
// LinkedHashMap, takže přetypování na JsonNode skončí 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")) {
// Okamžitý snapshot. gridPowerKw > 0 = odběr, < 0 = přetok (export).
// Routing key je …telemetry.realtime.{siteId} — siteId je i v obálce.
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")) {
// Minutové kumulativní registry elektroměru. Pole je `deviceId` (dříve meterId);
// payload nově nese i 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("-"));
}
}
}
}Odesílání příkazů
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 — BEZ podpisu. Příklad: omezit přetok na 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);
}
/** Nabíjení/vybíjení baterie — PODEPSANÉ (command.device vyžaduje HMAC). */
public void sendBatteryCommand(String siteId, String deviceId, String command) {
Map<String, Object> payload = Map.of(
"commands", List.of(Map.of(
"deviceId", deviceId, // např. "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);
}
}Potvrzení a výsledky vykonání
Voke odpovídá na dvou frontách — poslouchejte je stejně jako telemetrii a párujte přes
correlationId:
vcp.{slug}.event.status— ACK / NACK příkazu (accepted, rejected, partial).vcp.{slug}.event.execution— výsledek vykonání na jednotlivém zařízení poté, co PLC zareagovalo.
// 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());
}Poznámky
- Co se na AMQP nedávno změnilo: pouze payload odečtů elektroměru —
MeterEntry.meterIdbyl přejmenován nadeviceIda přibyla poleeanCons/eanProd. Realtime telemetrie a kontrakt příkazů zůstávají beze změny. - Formát čísel v podepsaných payloadech: v command payloadech preferujte celá čísla. Canonical serializer musí na obou stranách produkovat byte-identický výstup; integery jsou jednoznačné, exotické formátování floatů ne.
- TLS (přečtěte si to — je to nejčastější příčina selhání připojení): port 5671 je AMQPS a broker
je pouze TLS 1.3. Samotné
spring.rabbitmq.ssl.enabled: truevyjedná TLS 1.2, který broker odmítne — dostaneteSSLHandshakeException: Received fatal alert: protocol_versiona aplikace se nikdy nepřipojí. Musíte navíc nastavitspring.rabbitmq.ssl.algorithm: TLSv1.3(jako v konfiguraci výše). Klientský certifikát není potřeba — credential je API klíč a systémový truststore už veřejnému Let's Encrypt certifikátu brokeru důvěřuje. Pokud siConnectionFactoryskládáte ručně, nastavte protokol explicitně na TLS 1.3; bezparametrovéuseSslProtocol()RabbitMQ Java klienta připne TLS 1.2 a broker ho odmítne.
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.
REST Reference
Interactive reference for the Voke Partner REST API. Authenticate with your `vcp:read` API key and try requests live.