This is a development version of the documentation. Content may change without notice.
Voke Documentation
Partner API

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:

  1. Příjem realtime telemetrie z vaší AMQPS fronty.
  2. 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 telemetriequeue 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ýsledkyqueues 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/schedule

Envelope + 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.meterId byl přejmenován na deviceId a přibyla pole eanCons / 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: true vyjedná TLS 1.2, který broker odmítne — dostanete SSLHandshakeException: Received fatal alert: protocol_version a aplikace se nikdy nepřipojí. Musíte navíc nastavit spring.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 si ConnectionFactory sklá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.

On this page