Skip to content

UNS API

This is the API reference for the unified namespace: the gg.uns() topic builder/validator, the gg.instance(id) handle, the MessageIdentity type, the normative validation rules, the reserved-class publish guard, and the data()/events()/app() class-publish facades — the sanctioned way to publish the three app-usable UNS classes. For the concept — the grammar, the classes, the consumer wildcard pattern — read the Unified Namespace guide first; for a task-oriented walkthrough of publishing telemetry/events, see Publishing telemetry and events in that guide.

All four languages build byte-identical topics and structurally identical identity elements; the shared uns-test-vectors/ conformance suite (topic vectors + golden envelopes) pins this, and the cross-language interop harness asserts it over a real broker.

hierarchy, identity, and topic are read from the merged effective config. In hierarchical-config deployments, inherited hierarchy layers can define non-device identity values such as site and line once; the runtime device still comes from the thing name, and identity.device is rejected.

Handle Java Python Rust TypeScript
Topic builder (component-level, no instance token) gg.getUns() gg.uns() gg.uns() gg.uns()
Instance-scoped handle gg.instance(id) gg.instance(id) gg.instance(id)Result gg.instance(id)

gg.instance(id) validates the instance token (charset rule below) and caches the handle per id. It does not require the id to appear in component.instances[] — instances may be created dynamically (an unknown id logs a DEBUG diagnostic).

Eight classes; state/metric/cfg/log are reserved (library-owned publish), state/cfg are leaf (channel forbidden), every other class requires ≥ 1 channel token. The log publisher is exposed as getLogs() in Java and logs() in Python, Rust, and TypeScript; it emits edgecommons.log.v1 records on log/{level} without exposing the reserved publish seam.

com.mbreissi.edgecommons.uns.UnsClass
public enum UnsClass {
STATE, METRIC, CFG, LOG, DATA, EVT, CMD, APP;
public final String token; // the wire token ("state", "metric", ...)
public final boolean leaf; // true => channel forbidden; false => channel REQUIRED
public static final Set<UnsClass> RESERVED; // STATE, METRIC, CFG, LOG
}

Bound to a MessageIdentity (yours via gg.uns(), an instance’s via gg.instance(id).uns()) and to the component’s effective topic.includeRoot setting.

com.mbreissi.edgecommons.uns.Uns
public final class Uns {
public static final String ROOT = "ecv1";
public static final int MAX_TOPIC_SLASHES = 7; // AWS IoT Core depth budget
public static final int MAX_TOPIC_UTF8_BYTES = 256; // IoT Core publish limit
public Uns(MessageIdentity identity, boolean includeRoot);
public MessageIdentity identity(); // the bound identity
public String topic(UnsClass cls); // leaf classes (STATE, CFG)
public String topic(UnsClass cls, String channel); // channeled classes
public String topicFor(MessageIdentity target, UnsClass cls, String channel);
public String filter(UnsClass cls, UnsScope scope); // '/#' appended for channeled classes
public void validate(String topic); // throws UnsValidationException
public static void checkToken(String token, String what);
}

topicFor takes a MessageIdentity — typically a received message’s identity — which is how you address a peer’s cmd inbox without ever parsing a topic.

Identical in all four languages (pinned by uns-test-vectors/topics.json):

  1. Token rule — a token is non-empty and contains no /, +, #, \, no control characters (including the C1 range U+0080–U+009F), and no .. substring. Dots are legal (a full reverse-DNS name fits in one level). This is exactly the config template sanitizer’s blacklist, so any sanitized identity value is guaranteed to build a publishable topic.
  2. Depth guard — total / count ≤ 7 (AWS IoT Core’s 8-level limit): channel ≤ 3 tokens without the root, ≤ 2 with topic.includeRoot: true. Enforced at build time.
  3. Length — total topic ≤ 256 UTF-8 bytes.
  4. Class rulesstate/cfg are leaf (channel forbidden); all other classes require ≥ 1 channel token.
  5. validate(topic) accepts only concrete topics (rejects +/# — use filter() for subscriptions, whose output is correct by construction).
  6. Roottopic.includeRoot inserts the first hierarchy value after ecv1 only when the hierarchy has ≥ 2 levels; with a single-level hierarchy it is a no-op with a config WARN.

Failures carry a machine-readable code, identical across languages:

EMPTY_TOKEN | BAD_CHAR | TRAVERSAL | DEPTH_EXCEEDED | LENGTH_EXCEEDED |
CHANNEL_ON_LEAF | CHANNEL_REQUIRED | BAD_ROOT | BAD_CLASS | WILDCARD_IN_TOPIC
Language Error type
Java UnsValidationException extends IllegalArgumentException (getCode())
Python UnsValidationError(ValueError) (.code)
Rust EdgeCommonsError::UnsValidation { code: UnsValidationCode, detail }
TypeScript UnsValidationError extends Error (.code)

An absent field renders as +. The site position participates only when the builder’s effective root mode is on. Channeled classes get a trailing /#; leaf classes end at the class token.

public record UnsScope(String site, String device, String component, String instance) {
public static UnsScope all();
public static UnsScope device(String device);
public static UnsScope component(String device, String component);
public static UnsScope instance(String device, String component, String instance);
}
gg.getUns().filter(UnsClass.STATE, UnsScope.all()); // ecv1/+/+/+/state
gg.getUns().filter(UnsClass.DATA, UnsScope.device("gw-01")); // ecv1/gw-01/+/+/data/#

gg.instance() — the instance-scoped handle

Section titled “gg.instance() — the instance-scoped handle”

The handle pre-binds an instance token into (a) the topic builder and (b) the message builder — the messaging client itself stays instance-agnostic.

com.mbreissi.edgecommons.EdgeCommonsInstance
public final class EdgeCommonsInstance {
public String id();
public Uns uns(); // topics minted with this instance token
public MessageBuilder newMessage(String name, String version);
// == MessageBuilder.create(name, version).withConfig(config).withInstance(id())
}
EdgeCommonsInstance kep1 = gg.instance("kep1");
String t = kep1.uns().topic(UnsClass.DATA, "press12/temperature");
gg.getMessaging().publish(t, kep1.newMessage("SignalUpdate", "1.0").withPayload(p).build());

The wire object and the component’s resolved identity — one type. On the wire it is the envelope’s top-level identity element (hier, path, component, instance); see the Unified Namespace guide for the JSON shape and resolution rules.

com.mbreissi.edgecommons.messaging.MessageIdentity
public final class MessageIdentity {
public static final String DEFAULT_INSTANCE = "main";
public record HierEntry(String level, String value) { }
public MessageIdentity(List<HierEntry> hier, String component, String instance);
public List<HierEntry> getHier(); // ordered; last entry = the device
public String getPath(); // precomputed '/'-join of the hier values
public String getComponent();
public String getInstance();
public String getDevice(); // computed accessor — NOT a wire field
public MessageIdentity withInstance(String instance); // copy with another instance token
public JsonObject toDict();
public static MessageIdentity fromDict(JsonObject src); // lenient: malformed -> null + WARN
}

The component’s resolved identity is configManager.getComponentIdentity() (component-level, no instance token).

Deserialize leniency is deliberate: a malformed inbound identity yields a null/absent identity with a warning — the message still delivers. identity is optional on the wire: a message built without a config-bound builder (the CONFIG_COMPONENT bootstrap request, raw bridging of external systems) legally omits it.

Class-publish facades: data(), events(), app()

Section titled “Class-publish facades: data(), events(), app()”

The three app-usable UNS classes — data, evt, app — are non-reserved: any component may publish them. data()/events()/app() are the sanctioned publish path: they go through the ordinary, guarded messaging().publish(...) — publishing data/evt/app is not a privileged operation, so raw uns() + messaging() publishing (as shown above) is fully supported — but they construct and validate the body, fill in the documented defaults, and mint the topic consistently. Use them; reach for the raw path only when a body is genuinely exotic.

Same convention as uns()/instance() above: a component-level convenience (no instance token) and an instance-scoped form.

Handle Java Python Rust TypeScript
data() (component-level) gg.getData() gg.data() gg.data() gg.data()
events() (component-level) gg.getEvents() gg.events() gg.events() gg.events()
app() (component-level) gg.getApp() gg.app() gg.app() gg.app()
any of the three, instance-scoped gg.instance(id).data() / .events() / .app() same same (each returns the facade directly; gg.instance(id) itself returns Result) same

data() — the telemetry / signal data plane

Section titled “data() — the telemetry / signal data plane”

Publishes class = data, header.name = "SouthboundSignalUpdate". It constructs the body (device?, signal{id, name?, address?}, samples[]) instead of you hand-assembling it, and sanitizes each /-separated token of the signal path into the data/{channel} tail — the stable signal.id still rides untouched in the body.

// com.mbreissi.edgecommons.facades.DataFacade — via gg.getData() / gg.instance(id).data()
public final class DataFacade {
public SignalUpdate.Builder signal(String id); // fluent body builder
public void publish(SignalUpdate update); // full form
public void publish(String signalPath, Object value); // shorthand: quality=GOOD, serverTs=now
public void publish(String signalPath, Object value, Quality quality);
public void publishBody(String signalPath, JsonObject body); // raw escape hatch — no defaulting
public void publishBody(String signalPath, JsonObject body, Channel via);
public JsonObject buildBody(SignalUpdate update); // inspect the constructed body
public Channel resolveChannel(Channel via); // per-call ▸ config ▸ LOCAL
}
gg.instance("kep1").data().signal("press12/temperature")
.name("Line1 Temp")
.device("opcua", "kep1", "opc.tcp://kep-server:49320")
.addSample(21.5) // quality -> GOOD, serverTs -> now
.publish();
// -> ecv1/gw-01/opcua-adapter/kep1/data/press12/temperature

events() — operator events & alarms (evt)

Section titled “events() — operator events & alarms (evt)”

Publishes class = evt, header.name = "evt". The channel is derived from the body: evt/{severity}/{sanitize(type)} — so the topic and the body can never disagree. timestamp defaults to now; type is the only required field (it is a channel token). raiseAlarm/clearAlarm set alarm: true plus active: true|false and default severity to critical, so a raise and its clear ride the same channel — a console watching evt/critical/# sees both halves of the alarm lifecycle.

// com.mbreissi.edgecommons.facades.EventsFacade — via gg.getEvents() / gg.instance(id).events()
public final class EventsFacade {
public EventsFacade via(Channel channel); // LOCAL (default) or NORTHBOUND only
public void emit(Severity severity, String type, String message, JsonObject context);
public void emit(Severity severity, String type, String message);
public void emit(String type, String message); // severity defaults to INFO
public void raiseAlarm(String type, String message, JsonObject context); // severity -> CRITICAL
public void raiseAlarm(Severity severity, String type, String message, JsonObject context);
public void clearAlarm(String type, JsonObject context); // severity -> CRITICAL
public void clearAlarm(Severity severity, String type, JsonObject context);
public JsonObject buildBody(Severity severity, String type, String message,
JsonObject context, Boolean alarm, Boolean active);
public String channelFor(Severity severity, String type);
}
gg.getEvents().raiseAlarm("connection-lost", "Modbus link down", ctx); // -> evt/critical/connection-lost
gg.getEvents().clearAlarm("connection-lost", ctx); // -> the same channel, active=false

app() — free-form inter-component pub/sub

Section titled “app() — free-form inter-component pub/sub”

Publishes class = app with a developer-chosen header name and body — there is no body contract to enforce (that is the point of app), so the facade’s only job is minting the app/{channel} topic correctly and stamping identity, replacing the three-line raw ritual.

// com.mbreissi.edgecommons.facades.AppFacade — via gg.getApp() / gg.instance(id).app()
public final class AppFacade {
public void publish(String name, String channel, JsonObject body);
public void publish(String name, String channel, JsonObject body, Channel routing);
}
gg.getApp().publish("OrderReceived", "order/received", body); // -> app/order/received

Value types — Quality, Severity, Channel

Section titled “Value types — Quality, Severity, Channel”
public enum Quality { GOOD, BAD, UNCERTAIN;
public String wire(); // the UPPERCASE wire token
public static Quality fromWire(String token); // null when outside the closed set
}
public enum Severity { CRITICAL, WARNING, INFO, DEBUG;
public String wire(); // the lowercase wire token, the evt channel's 1st segment
public static Severity fromWire(String token);
}
public final class Channel {
public static final Channel LOCAL, NORTHBOUND;
public static Channel stream(String name); // data() only
public enum Kind { LOCAL, NORTHBOUND, STREAM }
public Kind kind();
public String streamName(); // non-null only for STREAM
public static Channel fromConfig(String value); // parses "local"/"northbound"/"stream:<name>"
}

All three facades resolve their effective channel the same way: per-call override (via(...) / .via(channel) / the builder’s via) ▸ the config publish.channel (instance-level, then global) LOCAL. Only data() accepts a stream:<name> route — events()/app() reject one at build time, since alarms and app messages are low-rate control-plane, not bulk telemetry.

  • local — the default; publishes on the local bus (messaging().publish).
  • northbound — publishes straight to AWS IoT Core (messaging().publishNorthbound); a northbound outage is caught, logged, and never flips local readiness.
  • stream:<name> (data() only) — serializes the same envelope and appends it to streams().stream(name) (partition key = signal.id, timestamp = the sample’s serverTs) instead of the bus. If no stream is configured, the facade falls back to a local publish (with a once-only warning) rather than dropping the message or failing the call.

A config-driven default (no code change needed to move a signal from the bus to a stream) is set under the instance’s or the global component.* config as publish: { channel: "..." } — see "local" / "northbound" / "stream:<name>" above.

Where data()/events()/app() publish the outbound app classes, commands() is the inbound side of the cmd class: a library-owned command inbox every component runs at component scope. It subscribes the component’s own command wildcard ecv1/{device}/{component}/cmd/# on the PRIMARY (local/IPC) connection and dispatches each incoming cmd envelope to a handler by verb — the topic channel after cmd/ (/-namespaced verbs included), which the envelope’s header.name must equal. It is available in all four languages, pinned by uns-test-vectors/commands.json.

Handle Java Python Rust TypeScript
Command inbox gg.getCommands() gg.get_commands() gg.commands() gg.commands()

Register a custom verb handler with register(verb, handler) in every language; the verb is one or more /-separated cmd channel tokens, each validated against the token rule. A verb cannot shadow a built-in or an already-registered verb.

Three verbs are registered by the library and cannot be shadowed or unregistered:

Verb Reply result
ping { "status": "RUNNING", "uptimeSecs": n } — liveness/echo (the state keepalive’s RUNNING body shape)
reload-config { "reloaded": true } — re-fetch + re-apply config from the active source
get-configuration { "config": <redacted effective config> } — the component’s own redacted effective config (Flow B)

A request carrying header.reply_to gets a structured reply on that topic (stamped with the request’s correlation id and the responder’s identity); a cmd without reply_to is fire-and-forget. The reply body is one of:

{ "ok": true, "result": { /* verb-specific object */ } }
{ "ok": false, "error": { "code": "<CODE>", "message": "<text>" } }

An unknown verb replies with error.code = "UNKNOWN_VERB"; a malformed or foreign payload (a missing header, or a header.name that does not equal the topic’s verb) is ignored at DEBUG — never replied to, never a crash.

Every publish path that takes a client-chosen topic — publish, publishRaw, publishNorthbound, publishNorthboundRaw, request, requestNorthbound, and reply/replyNorthbound (a hostile reply_to must not turn a responder into a forger) — rejects a topic whose class position is a reserved class (state | metric | cfg | log) under the ecv1 root. subscribe* is never guarded (consumers must read reserved classes), and non-ecv1 topics (reply topics, cloudwatch/metric/put, legacy/foreign MQTT) pass untouched.

Use the dedicated library surfaces instead: heartbeat owns state, metric emission owns metric, the effective-config publisher owns cfg, and the log bus publisher owns log.

Language Error type
Java ReservedTopicException extends IllegalArgumentException
Python ReservedTopicError(ValueError)
Rust EdgeCommonsError::ReservedTopic(String)
TypeScript ReservedTopicError extends Error