Java Control Client
The artifact com.friendly.qa:ft-oneiot-emulator-client is a typed Java client for the control REST API described in REST API. A test engineer calls it from a JUnit test or from the rig instead of writing HTTP requests and parsing JSON by hand.
No version has been published yet. The first planned version is 0.1.0.
What It Is and What It Is Not
The client is a one-to-one typed map of 50 control operations. Each operation is one method. A method sends one request and returns a record, a list, a map, or nothing.
The client has:
-
no waits: it does not poll for a state, you do;
-
no retries: a failed call fails once;
-
no fleet logic: creating and tracking many devices is the job of the test kit.
The client does not log. The caller owns logging.
Coordinates and Resolution
<dependency>
<groupId>com.friendly.qa</groupId>
<artifactId>ft-oneiot-emulator-client</artifactId>
<version>X.Y.Z</version>
</dependency>
The artifact is published to GitHub Packages of the emulator repository:
https://maven.pkg.github.com/Friendly-Technologies/oneiot_emulator
Reading it needs a token with the read:packages scope. Declare the repository in your POM, and a <server> with the same id and the token in your Maven settings.xml.
The artifact needs Java 25 and uses Jackson 3 (tools.jackson.core:jackson-databind). No Jackson type appears in a public signature. The client is built and scanned against Jackson 3.2.3, and its tests also run on the 3.0 line (3.0.4) in CI. If your dependency management holds Jackson at 3.0.x, the client works there, but that line has known vulnerabilities that are fixed only in later lines.
Bind the groupId
com.friendly.qa is the one groupId of every qa-program library. A consumer must bind it to its repositories with Maven’s remote repository filter, so that Maven Central can never serve it. The binding has three parts. One part alone protects nothing.
-
Switch the filter on in
.mvn/maven.config. Without these two lines the files of steps 2 and 3 are not read:-Daether.remoteRepositoryFilter.groupId=true -Daether.remoteRepositoryFilter.groupId.basedir=${maven.multiModuleProjectDirectory}/.mvn/rrf -
Create
.mvn/rrf/groupId-<repositoryId>.txtwith the single linecom.friendly.qa.<repositoryId>is the id you gave the GitHub Packages URL above. This file says what that repository may serve. -
Create
.mvn/rrf/groupId-central.txtas an allow-list for Maven Central that does not containcom.friendly.qa. This file stops Central from serving the groupId.
If the same groupId is bound to several repositories, Maven asks each of them in turn and you see a 404 line from the ones that do not have the artifact. That is expected.
Creating a Client
EmulatorClient emulator = EmulatorClient.create(URI.create("http://localhost:8556"));
create(URI) uses a connect timeout of 10 seconds and a call deadline of 60 seconds. The base URL must have the scheme http or https and a host, and no query, fragment or user-info. A trailing slash is dropped. A host name with an underscore, such as a compose service name in http://oneiot_emulator:8556, is refused too, because java.net.URI does not read it as a host: use an address, or a name of letters, digits, - and .. Two more authorities java.net.URI cannot parse are refused with the same message: a host label that starts or ends with - (http://-host:8556) and a port that is not a number (http://localhost:85a6). Anything else is an IllegalArgumentException.
The three-argument form takes your own HttpClient and a call deadline:
HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
EmulatorClient a = EmulatorClient.create(URI.create("http://emulator-a:8556"), http, Duration.ofSeconds(120));
EmulatorClient b = EmulatorClient.create(URI.create("http://emulator-b:8556"), http, Duration.ofSeconds(120));
It throws IllegalArgumentException when the HttpClient has no connect timeout, when its redirect policy is not HttpClient.Redirect.NEVER, or when the deadline is zero, negative or longer than Long.MAX_VALUE nanoseconds (about 292 years). A followed redirect would send the request again, with any secret in its body, to another address. Pass your own HttpClient when you need a proxy or a custom SSLContext, and give it a connect timeout and Redirect.NEVER. Redirects are not followed and there is no trust-all option.
-
One client per emulator. An
EmulatorClientis immutable and safe to share between threads. -
One
HttpClientfor many emulators.create(URI)builds its ownHttpClient. When you drive many emulators, create oneHttpClientand pass it to every client. -
Not closeable. The JDK releases the
HttpClientwhen it is no longer reachable.
Deadlines
One deadline covers a whole call: connect, headers and body. When it passes, the exchange is cancelled and the call throws UncheckedIOException. A response body larger than 64 MiB is refused while it is read, with the same exception.
A deadline that passes on the client does not undo what the server already did. Two calls are long:
-
devices().reboot(clientId)blocks for the whole reboot cycle of the device; -
devices().createBatch(templates)creates its devices synchronously.
For slow devices or large batches, pass a longer deadline to the three-argument create, or make the batch smaller.
The API by Class
EmulatorClient gives six API objects: session(), configFiles(), devices(), storedDevices(), lwm2m() and est(). Every method takes a method and a path from one constant of EmulatorEndpointEnum, which has 50 constants. The tables below list every method with its HTTP method and path.
A path variable (clientId, paramName, protocolName, mtpType) is sent as one percent-encoded path segment, so paramName /3/0/0 travels as %2F3%2F0%2F0. A variable that is null, empty, . or .. is an IllegalArgumentException before any request is sent.
A method that returns nothing accepts any 2xx answer.
A record’s List or Map component is the collection that Jackson built while it read the answer. The records are not deeply immutable: copy a collection before you change it.
SessionApi
Protocol, connection config, transport, lifecycle and logs. 19 operations.
| Method | HTTP | Path | Returns |
|---|---|---|---|
|
GET |
|
|
|
GET |
|
|
|
GET |
|
|
|
POST |
|
nothing |
|
GET |
|
|
|
POST |
|
nothing |
|
POST |
|
nothing |
|
GET |
|
|
|
GET |
|
|
|
POST |
|
|
|
GET |
|
|
|
POST |
|
nothing |
|
POST |
|
nothing |
|
POST |
|
nothing |
|
POST |
|
nothing |
|
POST |
|
nothing |
|
GET |
|
|
|
POST |
|
|
|
GET |
|
|
ConfigFileApi
The device-tree files (.xml) of the configuration directory, and the selection among them. 3 operations, 4 methods: select and clearSelection are two methods over the same endpoint.
| Method | HTTP | Path | Returns |
|---|---|---|---|
|
GET |
|
|
|
GET |
|
|
|
POST |
|
nothing |
|
POST |
|
nothing (sends |
select throws IllegalArgumentException before any request for a null or blank name, or a name with / or \. The server drops a directory part and would select another file than the one you named.
While the emulator is initialized or started, the server refuses both select and clearSelection with 400 EMULATOR_NOT_STOPPED. A name that does not end in .xml answers 400 BAD_REQUEST; a file that does not exist answers 400 CONFIG_FILE_NOT_FOUND.
DeviceApi
The devices of the running emulator. 18 operations.
| Method | HTTP | Path | Returns |
|---|---|---|---|
|
GET |
|
|
|
GET |
|
|
|
POST |
|
|
|
POST |
|
|
|
POST |
|
|
|
POST |
|
nothing |
|
POST |
|
nothing |
|
DELETE |
|
nothing |
|
PUT |
|
nothing |
|
POST |
|
nothing |
|
POST |
|
nothing |
|
POST |
|
nothing |
|
POST |
|
nothing |
|
GET |
|
|
|
PUT |
|
nothing |
|
GET |
|
|
|
GET |
|
|
|
POST |
|
nothing |
startNew creates and starts a stand-alone device. create, createBatch and startNew all store their devices, see create, createBatch and startNew store their devices. startNew and createBatch throw IllegalArgumentException before any request for a template that holds the mask *** in one of the four fixed secrets or as any protocolOptions value, whatever its key, see Secrets read back as ***.
StoredDeviceApi
The devices the emulator stores on disk. 4 operations.
| Method | HTTP | Path | Returns |
|---|---|---|---|
|
GET |
|
|
|
POST |
|
|
|
DELETE |
|
nothing |
|
DELETE |
|
nothing |
Lwm2mApi
LwM2M fleet operations. 2 operations, 3 methods: deregister and deregisterWithDetails are two methods over the same endpoint.
| Method | HTTP | Path | Returns |
|---|---|---|---|
|
POST |
|
nothing |
|
POST |
|
|
|
POST |
|
nothing |
deregister and update answer 409 when there is nothing to act on: NO_RUNNING_LWM2M_DEVICE.
EstApi
Certificate enrolment (EST) of one device. 4 operations.
| Method | HTTP | Path | Returns |
|---|---|---|---|
|
GET |
|
|
|
POST |
|
|
|
POST |
|
|
|
POST |
|
nothing |
On an emulator without EST configured, enroll and reenroll answer 409 with a plain-text body, and options answers 400 for an option the emulator does not know. These answers have no JSON code, so EmulatorApiException.code() is null for them.
Worked Example: Start a Session
This is the start sequence a test runs: select the protocol, set options and config, initialize, wait until the emulator reports initialized, then start. The client has no wait, so the example uses Awaitility; the client does not depend on it.
import com.friendly.qa.emulator.oneiot.api.EmulatorClient;
import com.friendly.qa.emulator.oneiot.error.EmulatorApiException;
import com.friendly.qa.emulator.oneiot.model.ConnectionConfig;
import com.friendly.qa.emulator.oneiot.model.Device;
import com.friendly.qa.emulator.oneiot.model.DeviceTemplate;
import java.net.URI;
import java.time.Duration;
import java.util.Map;
import static org.awaitility.Awaitility.await;
EmulatorClient emulator = EmulatorClient.create(URI.create("http://localhost:8556"));
// 1. Start from a clean state. stop() answers 409 NOT_INITIALIZED when nothing is initialized.
try {
emulator.session().stop();
} catch (EmulatorApiException e) {
if (e.status() != 409 || !"NOT_INITIALIZED".equals(e.code())) {
throw e;
}
}
// 2. Protocol and protocol options. false = do not keep the options set so far.
emulator.session().selectProtocol("LWM2M", false);
emulator.session().updateProtocolOptions(Map.of("bootstrap", "false"));
// 3. Config: change what you need and keep the rest (see "Partial config updates").
ConnectionConfig config = emulator.session().config().toBuilder()
.serverIp("127.0.0.1")
.serverPort(5683)
.clientsCount(1)
.build();
emulator.session().updateConfig(config);
// 4. Initialize and wait for the state.
emulator.session().init();
await().atMost(Duration.ofSeconds(30))
.until(() -> Boolean.TRUE.equals(emulator.session().status().initialized()));
// 5. Start, then read the device.
emulator.session().start();
Device device = emulator.devices().list().get(0);
System.out.println(device.clientId());
A stand-alone device is created from a template. DeviceTemplate has a builder:
Device standalone = emulator.devices().startNew(DeviceTemplate.builder()
.clientId("meter-001")
.serverIp("127.0.0.1")
.serverPort(5683)
.build());
Errors
A call that the emulator answers, but not with what you asked for, throws EmulatorApiException. That means a status outside 2xx, or a 2xx answer whose declared body is absent or unreadable. It is an unchecked exception with five accessors:
| Accessor | Value |
|---|---|
|
The HTTP method sent, for example |
|
The path sent, percent-encoded, for example |
|
The HTTP status, for example |
|
The server’s error code, for example |
|
The server’s free text, at most 2000 characters, or null when the body carried none |
The message has exactly this format, and the bracketed part is left out when code() is null:
<METHOD> <path> answered <status> [<code>]
For example:
POST /api/start answered 409 [NOT_INITIALIZED]
The server’s text is not in the message, and neither is the request body. An emulator error body can echo the request, a config body carries secrets, and exception messages end up in test reports that can leave the company. Read serverMessage() when you need the text.
The emulator answers errors in several shapes: a JSON error body, a plain-text body (the EST operations), an empty body (reboot, bootstrap, some EST operations) and Spring’s default error object for a path no handler owns. The client reads a JSON object as the error body and a text/plain body as serverMessage(). In every other case code() and serverMessage() are null; status() is always set.
Other failures:
-
UncheckedIOExceptionwhen no answer arrives: the connection is refused, the deadline passes, the body is too large or an I/O error occurs. The JDK cause is attached. When an I/O error ends the call, the message is<METHOD> <path> failed, with the path as sent (percent-encoded, so a line break in a path variable cannot forge a line of a log), and the originalIOExceptionis the cause. An interrupt restores the thread’s interrupt flag and is thrown as anUncheckedIOExceptionthat wrapsInterruptedIOException. -
IllegalArgumentException, before any request, for a bad path variable (null, empty,.or..), a bad base URL, anHttpClientor a deadline thatcreaterefuses (see Creating a Client), a bad file name given toConfigFileApi.select, or a template that holds the mask (see Secrets read back as***). -
NullPointerException, before any request, when an operation that takes a body is given null. The message isbody.
Server Behaviour a Caller Must Know
stop and reset need an initialized emulator
start(), stop() and reset() answer 409 NOT_INITIALIZED when nothing is initialized. The typed call throws; a call that ignored the status would not have noticed. stop() on a started session also un-initializes it, so status().initialized() is false afterwards.
Partial config updates
updateConfig leaves a null component unchanged, with one exception: a request that omits delaySec sets the delay to 0, because the server applies delaySec whenever it reads >= 0 and an omitted number reads 0. Always start from the current config:
emulator.session().updateConfig(
emulator.session().config().toBuilder().serverIp("10.0.0.1").build());
setAutoupdate has the same trap: the booleans autoupdatable, global and randomizeStepType read as false when omitted. Start from autoupdate(…).toBuilder().
Secrets read back as ***
The emulator masks secrets on read: a stored device’s keys and the connection config’s keys and passwords come back as ***. Device parameters are not masked by the emulator at all.
The connection config can be read with config() and sent back unchanged: a masked or omitted secret keeps the stored value.
A stored-device template cannot be sent with the mask in it. The client refuses the mask in these places:
-
one of the four fixed secrets:
pskKey,authKeyStorePass,trustStorePass,aliasPass; -
any
protocolOptionsvalue equal to the mask, whatever its key.
devices().startNew(template) and devices().createBatch(templates) throw IllegalArgumentException before any request is sent, and a batch is refused whole. The message names the component, and in a batch the index of the template; it holds neither the mask nor the client id. A null element of a batch is not checked: it is sent as JSON null and the server’s answer is what you get.
Why the client refuses: the mask is what the server returns instead of a secret, and the client cannot tell a real value *** from a mask that was read back.
What the server would do with the mask differs by place:
| Where the mask sits | What the server does |
|---|---|
One of the four fixed secrets |
Drops the value. The device has no key of its own and runs on the session’s, and its stored entry loses the key. |
A |
Drops the value, as for a fixed secret. |
A |
Stores the literal |
The client is deliberately stricter than the server for protocolOptions: it refuses the mask under any key, because the server decides which option keys are secret from its own per-protocol properties, which the client cannot evaluate. A real option value that is the literal *** is therefore refused as well.
To restart a template taken from storedDevices().list(), set its secrets again with toBuilder():
DeviceTemplate listed = emulator.storedDevices().list().get(0);
emulator.devices().startNew(listed.toBuilder().pskKey(pskKey).build());
DeviceTemplate.maskedSecret() returns the name of the component that holds the mask, or an empty Optional when the template can be sent as it is.
A second deregister sends nothing
lwm2m().deregister() deregisters every LwM2M device in the emulator, including one already deregistered. It answers 409 NO_RUNNING_LWM2M_DEVICE only when the emulator holds no LwM2M device. After a first deregister the devices stay in the emulator, so a second call answers 204 and sends nothing to the server.
A stored device starts only after a restart
Inside one emulator process a stored device is also a running one. storedDevices().start(clientId) therefore throws 400 DEVICE_START_FAILED while the device exists in that process, whether running or stopped. It succeeds after the emulator restarts over the same configuration directory.
create, createBatch and startNew store their devices
devices().create(…), devices().createBatch(…) and devices().startNew(…) store the devices they create. session().reset() keeps the stored devices, and session().init() creates every stored device again. A test that creates devices therefore leaves them for the next session. storedDevices().clear() is the clean-up between sessions; devices().delete(clientId) removes one device and its stored entry, see Deleting a device also deletes its stored entry.
Deleting a device also deletes its stored entry
devices().delete(clientId) removes the device from the store too. storedDevices().delete(clientId) and clear() remove stored entries only; running devices stay.
A config-file selection survives a protocol switch, not a restart
The selection made with configFiles().select(…) stays when you call selectProtocol, also across protocols. It is kept in memory only, so a restart of the emulator clears it. In active(), the property for the protocol’s default file is defaultFile.
Secrets in Test Output
A failed assertion prints the objects it compares, and that text goes into a test report. The model records mask their secrets in toString():
-
A component whose lower-cased name contains
pass,psw,secret,keyortokenprints as***. This also masksauthKeyStorePath; the rule errs on the side of masking. -
DeviceTemplate.protocolOptionsprints its keys only. -
Parameter.value,ParameterHistory.Entry.value,LogEntry.payload,LogEntry.rawMessage,LogEntry.messageOptions, and the server’smessageanddetailsin an error body print as***. A device parameter can hold a private key, and the emulator does not mask parameters. -
The Lombok builders print their type name only.
One thing toString() cannot stop: AssertJ’s recursive comparison prints the component values directly, without calling toString(). Compare extracted non-secret fields instead:
assertThat(device.clientId()).isEqualTo("meter-001");
assertThat(device.standalone()).isTrue();
Operations Not Covered
The emulator has 60 control operations; the client covers 50. These 10 are left out on purpose.
| Method and path | Reason |
|---|---|
GET |
Dropdown content for the Web GUI. |
GET |
Dropdown content for the Web GUI. |
GET |
The JavaScript rule the emulator runs on every parameter update. A rule is script text that the emulator persists to |
POST |
Sets the rule. Same reason as |
GET |
GUI help text of the rule editor. |
POST |
Reads an ACS database with credentials in the request. It is not a test-control call. |
POST |
GUI session snapshot. |
POST |
GUI session snapshot. |
POST |
Changes the files of the configuration directory (multipart upload). An image ships its device trees, and a test selects among them with |
POST |
Changes the files of the configuration directory (delete). Same reason as the upload. |
A rule posted by one test is saved to rules.js and stays after the session ends. That is the reason the client does not offer the rule operations.
|
Versioning and Release
The client has its own semantic version, starting at 0.1.0. A release is a tag oneiot-emulator-client-vX.Y.Z on a commit of the dev branch.
A client version is verified against the emulator commit its tag points at. The emulator image built from that commit is its matching pair. Use that pair together.
How the contract is pinned
Three test classes in the gui test tree (com.friendly.emulator.gui.api.contract) tie the client to the controllers. They run in the emulator build, with no mock of the server.
-
ControlClientEndpointInventoryTestchecks that every/apihandler of the controllers is either a constant ofEmulatorEndpointEnumor on the not-covered list above, and that nothing else is. -
ControlClientModelParityTestchecks that every client record has the same JSON property names and types as the server class it mirrors. -
ControlClientLiveContractTeststarts the emulator as a child process and calls every one of the 50 operations through the client, checking the raw answers against the records and the effect of the calls on a local LwM2M registrar.
A developer who adds, renames or removes a controller path, or changes a field of a DTO the client uses, sees one of these classes fail in the gui build. The fix is to change the client in the same commit, or, for a new operation the client does not need, to add it to the not-covered list with a reason.