You are viewing the 3.0 documentation (Beta). Go to the current 2.0.x documentation.

Zequent Edge SDK (Java) — Adapters on 3.0 (preview)

Preview. This page describes the 3.0 adapter API of the Java Edge SDK. 3.0 is not released yet: the API below is available as a preview build. Names may still change before the release. For the current 2.0 line, see Edge Adapter (Java) and the Java Quickstart.

On 3.0 an adapter writes each command once: a dotted command id, a JSON Schema for its params, a JSON Schema for its result and a handler. Everything else is derived from that registration: what the asset advertises (GetCapabilities, v2 and v3), the v3 ExecuteCommand the platform calls, and the v2 typed RPCs a 2.x platform still calls. An adapter built this way works against 2.x and 3.0 platforms alike.

Python: Adapters on 3.0 (Python) · Go: Adapters on 3.0 (Go) · Platform: Upgrading the platform to 3.0.

Step 1: Add the preview dependency

The 3.0 Java SDKs live in one repository, zequent/zqnt-sdk-java; the Edge SDK is the artifact com.zqnt:zqnt-sdk-java-edge (it replaces com.zqnt.sdk:edge-java-sdk of 2.x). The preview is published to GitHub Packages as a -SNAPSHOT build; every pull request into the 3.0 branch publishes a new version 3.0.0-PR<n>-<sha>-SNAPSHOT. At the time of writing:

<dependency>
    <groupId>com.zqnt</groupId>
    <artifactId>zqnt-sdk-java-edge</artifactId>
    <!-- preview, not a release -->
    <version>3.0.0-PR13-f96c80a-SNAPSHOT</version>
</dependency>

The SDK's generated types and shared classes now come from com.zqnt:zqnt-api (repository zequent/zqnt-api, public, Apache 2.0), which replaces zqnt-utils-java; the SDK pulls it in. The Java packages are unchanged (com.zqnt.utils.*, com.zqnt.protos.*), so no import changes. Both are on GitHub Packages; your ~/.m2/settings.xml needs a token with package read access for:

<repository>
    <id>zqnt-sdk-java</id>
    <url>https://maven.pkg.github.com/Zequent/zqnt-sdk-java</url>
</repository>
<repository>
    <id>zqnt-api</id>
    <url>https://maven.pkg.github.com/Zequent/zqnt-api</url>
</repository>

Move to the released 3.0.0 once it is out; do not run a preview in production. Configuration and authentication (ZQNT_EDGE_TOKEN, ZQNT_PLATFORM_PUBLIC_KEY, ZQNT_EDGE_AUTH_DISABLED) are unchanged from 2.0 — see Edge SDK Configuration.

Step 2: Declare the commands

Extend RegistryEdgeAdapter and register every command in the constructor:

import com.zqnt.protos.capability.v3.CompletionMode;
import com.zqnt.protos.capability.v3.TelemetryField;
import com.zqnt.protos.capability.v3.TelemetryValueType;
import com.zqnt.sdk.edge.adapter.application.RegistryEdgeAdapter;
import com.zqnt.sdk.edge.adapter.domains.Capability;
import com.zqnt.sdk.edge.adapter.domains.CommandResult;
import com.zqnt.utils.common.proto.AssetTypeEnum;

import java.util.Map;
import java.util.concurrent.CompletableFuture;

public class AcmeDrone extends RegistryEdgeAdapter {

    public AcmeDrone(AcmeLink link) {
        super(AssetTypeEnum.ASSET_TYPE_AIRCRAFT);

        Capability takeoff = registerCommand("flight.takeoff",
                Map.of("type", "object", "properties", Map.of(
                        "altitude", Map.of("type", "number", "minimum", 2, "maximum", 120))),
                Map.of(),
                request -> link.takeOff(request.params())
                        .thenApply(flightId -> CommandResult.accepted("climbing", flightId, request.sn())));
        takeoff.setCompletion(CompletionMode.COMPLETION_MODE_ASYNCHRONOUS);
        takeoff.setCompletionEvent("flight.takeoff.completed");

        registerCommand("vendor.acme.spray",
                Map.of("type", "object", "required", java.util.List.of("seconds"),
                        "properties", Map.of("seconds", Map.of("type", "integer", "minimum", 1))),
                Map.of(),
                request -> link.spray((Integer) request.params().get("seconds"))
                        .thenApply(done -> CommandResult.success("sprayed", request.sn())));

        commands().declareTelemetryField(TelemetryField.newBuilder().setKey("vendor.acme.tank_level")
                .setType(TelemetryValueType.TELEMETRY_VALUE_TYPE_NUMBER).setUnit("%").build());
    }
}
  • Ids. Reuse the platform's ids for shared actions (flight.takeoff, navigation.go_to, dock.open_cover, … — the command catalog, proto/v3/COMMAND_CATALOG.md in the public zequent/zqnt-api repository). A device-specific command goes under vendor.<vendor>.<name>.
  • Handler. A CommandHandler gets a CommandRequest: sn(), targetRef() (sub-asset, payload or component; null for the asset itself), commandId(), params() and commandExecutionId() (the platform's id of this run on v3, null when the command came over v2).
  • Validation. Params are validated against the input schema before the handler runs; numbers become Integer/Long where the schema says integer. Invalid params are REJECTED with error code command.invalid_params (CommandRegistry.INVALID_PARAMS) and a readable message; an id that is not registered is REJECTED with command.not_supported. A schema the SDK does not understand fails registerCommand with an IllegalArgumentException.
  • Result. CommandResult.success(...) (finished, optionally with an output map), CommandResult.accepted(message, externalExecutionId, sn) (still running, the outcome follows as a command event — Step 4), CommandResult.rejected(errorCode, message, sn), CommandResult.error(...).
  • Completion. setCompletion(COMPLETION_MODE_ON_REPLY) says the reply is the outcome; COMPLETION_MODE_ASYNCHRONOUS with setCompletionEvent(...) says a later event is. Unset, the platform goes by the result: an accepted result waits, any other success is done.
  • Changing what the asset can do. registerCommand again replaces an id, commands().unregister(id) withdraws it; after changing a returned Capability later (its state, e.g. CAPABILITY_STATE_TEMPORARILY_UNAVAILABLE while a payload is detached), call commands().changed() so the platform learns about it.

The typed methods of EdgeAdapterService (takeOff, openCover, …) are deprecated. In a RegistryEdgeAdapter they are final and route onto the registered handler of the matching id (takeOff → flight.takeoff); omitted coordinates (NaN) become absent params. Adapters that still implement the typed methods keep working.

Command risk (CommandSafety)

The 3.0 contract rates every command with a CommandSafety (COMMAND_RISK_OBSERVE, _ADJUST, _MOVE, _CRITICAL, plus reversibility and an undo command). A standard id is advertised with the command catalog's rating (CommandCatalog.safety(id): flight.takeoff MOVE, flight.land CRITICAL, camera.take_photo OBSERVE, ...). Set your own on the capability the registration returns:

registerCommand("vendor.acme.spray", sprayInput, Map.of(), this::spray)
        .setSafety(CommandSafety.newBuilder().setRisk(CommandRisk.COMMAND_RISK_CRITICAL).build());

A vendor.* command that moves or affects the asset must declare its own risk. An id outside the catalog that declares none counts as critical (a client must give a reason to run it).

Payloads

A device mounted on the asset (camera, gimbal, speaker, spotlight, sprayer, sensor) is reported with the capabilities. Set the asset's payloads on the registry, and target a payload's commands at its ref:

commands().setPayloads("SN-1", List.of(Payload.newBuilder()
        .setRef("speaker-1").setKind("speaker").setVendor("Acme").setModel("S2").setSlotIndex(0)
        .build()));

Capability play = registerCommand("speaker.audio.play", playInput, Map.of(), this::play);
play.setTargetType(CapabilityTargetType.CAPABILITY_TARGET_TYPE_PAYLOAD);
play.setTargetRef("speaker-1");
commands().changed();

setPayloads reports again when the snapshot changed. An adapter that implements getCapabilities itself sets CurrentCapabilities.setPayloads(...). A report is a full snapshot: a payload missing from the next report is detached. The platform keeps the payloads on the asset (the console's payload list) and stores one mounted on a sub-asset (a drone of a dock) on that sub-asset. kind is free text (camera, speaker, spotlight, sprayer, sensor, ...); metadata carries anything else, library_version is stored as such. The conformance kit flags a payload without ref, two with the same one, and a command targeting a payload that is not reported.

Step 3: Serve both contracts

Serve EdgeAdapterGrpcServiceImpl (v2) and EdgeAdapterV3GrpcServiceImpl (v3) from the same adapter, behind the PlatformAuthServerInterceptor that only accepts commands signed by the platform. In Quarkus, as @GrpcService subclasses; with plain grpc-java:

import com.zqnt.sdk.edge.adapter.grpc.EdgeAdapterGrpcServiceImpl;
import com.zqnt.sdk.edge.adapter.grpc.EdgeAdapterV3GrpcServiceImpl;
import com.zqnt.sdk.edge.application.ProtoJsonMapper;
import com.zqnt.sdk.edge.auth.EdgeAuthConfig;
import com.zqnt.sdk.edge.auth.PlatformAuthServerInterceptor;
import io.grpc.ServerBuilder;
import io.grpc.ServerInterceptors;

AcmeDrone adapter = new AcmeDrone(link);
PlatformAuthServerInterceptor auth = new PlatformAuthServerInterceptor(EdgeAuthConfig.fromEnv());

ServerBuilder.forPort(9090)
        .addService(ServerInterceptors.intercept(new EdgeAdapterGrpcServiceImpl(adapter, new ProtoJsonMapper()), auth))
        .addService(ServerInterceptors.intercept(new EdgeAdapterV3GrpcServiceImpl(adapter), auth))
        .build()
        .start();

A 3.0 platform calls v3 ExecuteCommand; a 2.x platform calls the v2 RPCs. Both run the same handler.

Step 4: Talk to the platform

Calls into the platform go over two channels, both carrying the edge credential (EdgeCredentialsClientInterceptor): the remote-control channel (EdgeGatewayService: capability reports, command events) and the live-data channel (telemetry).

import com.zqnt.sdk.edge.gateway.CapabilityReporter;
import com.zqnt.sdk.edge.gateway.EdgeGatewayClient;
import com.zqnt.sdk.edge.livedata.application.impl.LiveDataServiceImpl;
import com.zqnt.sdk.edge.telemetry.TelemetryPublisher;

EdgeGatewayClient gateway = new EdgeGatewayClient(remoteControlChannel);
LiveDataService liveData = new LiveDataServiceImpl(telemetryMapper, detectionMapper, notificationMapper,
        LiveDataServiceGrpc.newStub(liveDataChannel), gateway);
new CapabilityReporter(adapter, gateway).track("SN-1");
TelemetryPublisher telemetry = new TelemetryPublisher(liveDataChannel, liveData);
What3.0 platform2.x platform (answers UNIMPLEMENTED)
CapabilitiesCapabilityReporter.track(sn): ReportCapabilities at start and on every registry change, with the declared telemetry fields; retried until it succeedsv2 ReportAssetRuntime, without telemetry fields
Command progress and outcomethe events you already publish (Step 5) go to PublishCommandEvent under the platform's command_execution_idv2 notification stream, as before
Telemetry, detections, alertsTelemetryPublisher.publish(TelemetrySample / DetectionBatch / Alert): one long-lived stream per kind, reconnected with backoffv2 ProduceTelemetry / ProduceDetection with the shared fields only — see Step 6

Automatic fallback. After an UNIMPLEMENTED answer the SDK stays on v2 for 10 minutes (V3Availability.DEFAULT_RETRY_AFTER), then tries v3 again — an adapter picks up a platform upgrade without a restart. TelemetryPublisher.v3Available() tells which path is in use. An adapter that reports detected payloads in its v2 runtime snapshot passes its own V2CapabilityReport to new EdgeGatewayClient(remoteControlChannel, v2Report), because ReportAssetRuntime replaces the whole snapshot.

CapabilityReporter.track(sn) once per asset the adapter serves (untrack(sn) to stop).

Step 5: Report command events

A command that answered accepted(...) reports its progress and outcome as CommandExecutionEventData, through LiveDataService.produceNotificationData(...) exactly as on 2.0 (see Edge Live Data):

NotificationRequestData done = NotificationRequestData.builder()
    .sn("SN-1")
    .timestamp(LocalDateTime.now())
    .eventType(NotificationEventType.NOTIFICATION_EVENT_COMMAND_EXECUTION)
    .commandExecutionEvent(CommandExecutionEventData.builder()
        .externalExecutionId(flightId)          // the id you returned in CommandResult.accepted(...)
        .commandId("flight.takeoff")
        .assetSn("SN-1")
        .status(CommandExecutionStatus.COMMAND_EXECUTION_STATUS_SUCCEEDED)
        .occurredAt(LocalDateTime.now())
        .build())
    .build();
liveData.produceNotificationData(done);

With an EdgeGatewayClient on the LiveDataServiceImpl, the SDK sends it as v3 PublishCommandEvent and puts the platform's command_execution_id on it, found again from your externalExecutionId (or by asset and command). Every event carries occurred_at — now, when you gave none. Finish every accepted command with exactly one terminal status (SUCCEEDED, FAILED, CANCELLED), or a Skill waiting on it never moves on.

Step 6: Publish telemetry

A 3.0 TelemetrySample has the fields every asset shares; everything device-specific goes into details, under keys the asset declares (Step 2):

import com.google.protobuf.util.Timestamps;
import com.zqnt.protos.common.v3.AssetRef;
import com.zqnt.protos.common.v3.GeoPoint;
import com.zqnt.protos.telemetry.v3.TelemetrySample;
import com.zqnt.sdk.edge.support.Structs;
import com.zqnt.utils.telemetry.TelemetrySampleMapper;

telemetry.publish(TelemetrySample.newBuilder()
        .setAsset(AssetRef.newBuilder().setSn("SN-1"))
        .setObservedAt(Timestamps.now())
        .setPosition(GeoPoint.newBuilder().setLatitude(47.5).setLongitude(9.7).setAltitude(420.0))
        .setRelativeAltitude(40.0)
        .setBatteryPercent(81.0)
        .setDetails(Structs.toStruct(Map.of(
                TelemetrySampleMapper.DRONE_GEAR, 1,
                "vendor.acme.tank_level", 63.0)))
        .build());

observed_at defaults to now. Altitude in position is above sea level, relative_altitude above the takeoff point.

Standard keys. TelemetrySampleMapper (in zqnt-api) holds the platform's telemetry catalog: 64 detail keys with their type, unit and allowed values — the ones listed in The telemetry catalog below. TelemetrySampleMapper.telemetryFields() returns their TelemetryField descriptions; declare the ones your asset sends with commands().declareTelemetryField(...), so their type and unit are the platform's. Use your own vendor.<vendor>.<name> keys for everything else.

On a 2.x platform a sample goes over v2 ProduceTelemetry through TelemetrySampleMapper: catalog keys in details land in their v2 fields, other keys are dropped, and so are all alerts.

The telemetry catalog

AreaKeys
Weatherwind.speed (m/s), wind.direction
Dockdock.environment_temperature (°C), dock.inside_temperature (°C), dock.humidity (%), dock.mode, dock.rainfall, dock.cover_state, dock.debug_mode_open, dock.position_valid, dock.working_voltage (mV), dock.working_current (mA), dock.supply_voltage (V)
Dock air conditioner, manual controldock.air_conditioner.state, dock.air_conditioner.switch_time (s), dock.manual_control.state, dock.manual_control.active_session
Docked aircraftdock.drone.sn, dock.drone.model, dock.drone.paired, dock.drone.online, dock.drone.at_home, dock.drone.charging
Dock positioningdock.gnss.gps_satellites, dock.gnss.rtk_satellites, dock.gnss.quality
Network and linksnetwork.type, network.rate (KB/s), network.quality, link.4g.frequency_band, link.4g.ground_quality, link.4g.link_state, link.4g.quality, link.4g.aircraft_quality, link.dongle_count, link.work_mode, link.sdr.frequency_band (GHz), link.sdr.link_state, link.sdr.quality, sdr.down_quality, sdr.up_quality, sdr.frequency_band (GHz)
Aircraftdrone.mode, drone.gear, drone.height_limit (m), drone.home_distance (m), drone.total_flight_distance (m), drone.total_flight_time (s), drone.country, drone.battery.remaining_time (s), drone.battery.return_to_home_power
Payload and camerapayload.id, payload.name, payload.observed_at (RFC 3339), camera.current_lens, camera.gimbal_pitch (°), camera.gimbal_yaw (°), camera.gimbal_roll (°), camera.zoom_factor
Range finder, sensorrange_finder.target_latitude (°), range_finder.target_longitude (°), range_finder.target_distance (m), range_finder.target_altitude (m), sensor.target_temperature (°C)

Each key's type follows its 2.x field: numbers, booleans and strings. Enumerations (dock.mode, dock.cover_state, drone.mode, …) are strings — the value's 2.x name without its enum prefix — and their TelemetryField lists the allowed_values. The same catalog is in zqnt_api.telemetry (Python) and github.com/zequent/zqnt-api/go/v3/telemetry (Go).

Pairing with a claim code

Pairing is unchanged from 2.0: ConnectorService.ensureAsset(asset, claimCode) binds to an asset the platform already knows and redeems a one-time claim code (ZQNT_CLAIM_CODE, minted in the console) only for an unknown serial; redeemAssetClaim(code, asset) and describeAssetClaim(code) are there for custom flows. See Edge Connector — pairing.

registerAsset is deprecated: it creates an asset without an organization. Removing it, together with the platform's organization-UUID bind, is planned for 3.0; pair with claim codes.

Step 7: Check the adapter with the conformance kit

import com.zqnt.sdk.edge.conformance.EdgeAdapterConformance;

@Test
void conformsToTheEdgeContract() {
    EdgeAdapterConformance.check(new AcmeDrone(fakeLink), "SN-1").assertPassed();
}

check verifies that every advertised id is executable and every executable id is advertised (it probes unregistered ids, so fake the device side), that the schemas parse, and that an ASYNCHRONOUS command declares its completion event. EdgeAdapterConformance.checkCommandEvents(...) and checkCommandExecutionEvents(sn, ...) check recorded events for execution id, asset, state and occurred_at. A ConformanceReport lists problems(); passed() / assertPassed() decide.

Was this page helpful?

© Copyright 2026 Zequent. All rights reserved.