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.
Accessors
Section titled “Accessors”| 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).
UnsClass — the closed class set
Section titled “UnsClass — the closed class set”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.
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}class UnsClass(str, Enum): STATE, METRIC, CFG, LOG, DATA, EVT, CMD, APP = ...
@property def token(self) -> str: ... # the wire token @property def leaf(self) -> bool: ... # True => channel forbidden @staticmethod def from_token(token: str) -> Optional["UnsClass"]: ...// edgecommons::unspub enum UnsClass { State, Metric, Cfg, Log, Data, Evt, Cmd, App }
impl UnsClass { pub const fn token(self) -> &'static str; pub const fn is_leaf(self) -> bool; pub const fn is_reserved(self) -> bool; pub fn from_token(token: &str) -> Option<UnsClass>;}// edgecommons (src/uns.ts)export enum UnsClass { State = "state", Metric = "metric", Cfg = "cfg", Log = "log", Data = "data", Evt = "evt", Cmd = "cmd", App = "app" }
export const RESERVED_CLASSES: ReadonlySet<UnsClass>; // State, Metric, Cfg, Logexport function isLeafClass(cls: UnsClass): boolean; // State, Cfgexport function unsClassFromToken(token: string): UnsClass | undefined;Uns — the topic builder + validator
Section titled “Uns — the topic builder + validator”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.
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);}class Uns: def __init__(self, identity: MessageIdentity, include_root: bool): ...
def identity(self) -> MessageIdentity: ... def topic(self, cls: UnsClass, channel: Optional[str] = None) -> str: ... def topic_for(self, target: MessageIdentity, cls: UnsClass, channel: Optional[str] = None) -> str: ... def filter(self, cls: UnsClass, scope: UnsScope) -> str: ... def validate(self, topic: str) -> None: ... # raises UnsValidationError
@staticmethod def check_token(token: str, what: str) -> None: ...// edgecommons::unspub struct Uns { /* identity + include_root */ }
impl Uns { pub fn new(identity: MessageIdentity, include_root: bool) -> Uns;
pub fn identity(&self) -> &MessageIdentity; pub fn topic(&self, cls: UnsClass) -> Result<String>; // leaf classes pub fn topic_with_channel(&self, cls: UnsClass, channel: &str) -> Result<String>; pub fn topic_for(&self, target: &MessageIdentity, cls: UnsClass, channel: Option<&str>) -> Result<String>; pub fn filter(&self, cls: UnsClass, scope: &UnsScope) -> Result<String>; pub fn validate(&self, topic: &str) -> Result<()>;}
pub fn check_token(token: &str, what: &str) -> Result<()>;pub fn reserved_class_of(topic: &str, include_root: bool) -> Option<UnsClass>;Rust splits the leaf/channeled builders (topic vs topic_with_channel) instead of overloading,
and every builder returns Result (errors are EdgeCommonsError::UnsValidation { code, detail }).
// edgecommons (src/uns.ts)export const UNS_ROOT = "ecv1";export const MAX_TOPIC_SLASHES = 7;export const MAX_TOPIC_UTF8_BYTES = 256;
export class Uns { constructor(identity: MessageIdentity, includeRoot: boolean);
identity(): MessageIdentity; topic(cls: UnsClass, channel?: string): string; topicFor(target: MessageIdentity, cls: UnsClass, channel?: string): string; filter(cls: UnsClass, scope: UnsScope): string; validate(topic: string): void; // throws UnsValidationError}
export function checkToken(token: string | undefined | null, what: string): void;export function reservedClassOf(topic: string | undefined, includeRoot: boolean): UnsClass | undefined;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.
Normative validation rules
Section titled “Normative validation rules”Identical in all four languages (pinned by uns-test-vectors/topics.json):
- 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. - Depth guard — total
/count ≤ 7 (AWS IoT Core’s 8-level limit): channel ≤ 3 tokens without the root, ≤ 2 withtopic.includeRoot: true. Enforced at build time. - Length — total topic ≤ 256 UTF-8 bytes.
- Class rules —
state/cfgare leaf (channel forbidden); all other classes require ≥ 1 channel token. validate(topic)accepts only concrete topics (rejects+/#— usefilter()for subscriptions, whose output is correct by construction).- Root —
topic.includeRootinserts the first hierarchy value afterecv1only 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) |
UnsScope — wildcard scopes for filter()
Section titled “UnsScope — wildcard scopes for filter()”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/+/+/+/stategg.getUns().filter(UnsClass.DATA, UnsScope.device("gw-01")); // ecv1/gw-01/+/+/data/#@dataclass(frozen=True)class UnsScope: site: Optional[str] = None device: Optional[str] = None component: Optional[str] = None instance: Optional[str] = None
@staticmethod def all() -> "UnsScope": ... @staticmethod def for_device(device: str) -> "UnsScope": ... @staticmethod def for_component(device: str, component: str) -> "UnsScope": ... @staticmethod def for_instance(device: str, component: str, instance: str) -> "UnsScope": ...
gg.uns().filter(UnsClass.STATE, UnsScope.all()) # ecv1/+/+/+/stategg.uns().filter(UnsClass.DATA, UnsScope.for_device("gw-01")) # ecv1/gw-01/+/+/data/#Python uses for_device/for_component/for_instance (bare device would shadow the field).
pub struct UnsScope { /* site, device, component, instance: Option<String> */ }
impl UnsScope { pub fn all() -> UnsScope; pub fn device(device: impl Into<String>) -> UnsScope; pub fn component(device: impl Into<String>, component: impl Into<String>) -> UnsScope; pub fn instance(device: impl Into<String>, component: impl Into<String>, instance: impl Into<String>) -> UnsScope; pub fn with_site(self, site: impl Into<String>) -> UnsScope;}
gg.uns().filter(UnsClass::State, &UnsScope::all())?; // ecv1/+/+/+/stategg.uns().filter(UnsClass::Data, &UnsScope::device("gw-01"))?; // ecv1/gw-01/+/+/data/#export interface UnsScope { site?: string; device?: string; component?: string; instance?: string }export const UnsScope: { all(): UnsScope; device(device: string): UnsScope; component(device: string, component: string): UnsScope; instance(device: string, component: string, instance: string): UnsScope;};
gg.uns().filter(UnsClass.State, UnsScope.all()); // ecv1/+/+/+/stategg.uns().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.
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());inst = gg.instance("kep1") # EdgeCommonsInstancet = inst.uns().topic(UnsClass.DATA, "press12/temperature")msg = inst.new_message("SignalUpdate", "1.0").with_payload(p).build()gg.get_messaging().publish(t, msg)let inst = gg.instance("kep1")?; // Result<EdgeCommonsInstance> — validates the tokenlet t = inst.uns().topic_with_channel(UnsClass::Data, "press12/temperature")?;let msg = inst.message("SignalUpdate", "1.0").payload(p).build();gg.messaging()?.publish(&t, &msg).await?;const inst = gg.instance("kep1"); // EdgeCommonsInstanceconst t = inst.uns().topic(UnsClass.Data, "press12/temperature");const msg = inst.newMessage("SignalUpdate", "1.0").withPayload(p).build();await gg.messaging().publish(t, msg);MessageIdentity
Section titled “MessageIdentity”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.
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).
class HierEntry: def __init__(self, level: str, value: str): ...
class MessageIdentity: DEFAULT_INSTANCE = "main"
def __init__(self, hier: List[HierEntry], component: str, instance: Optional[str] = None, path: Optional[str] = None): ...
# read-only properties: hier: List[HierEntry] # ordered; last entry = the device path: str # precomputed '/'-join component: str instance: str device: str # computed — NOT a wire field
def with_instance(self, instance: str) -> "MessageIdentity": ... def to_dict(self) -> dict: ... @staticmethod def from_dict(src) -> Optional["MessageIdentity"]: ... # lenientThe component’s resolved identity is config.get_component_identity().
// edgecommons::messaging::messagepub struct HierEntry { pub level: String, pub value: String }
pub struct MessageIdentity { /* hier, path, component, instance */ }
impl MessageIdentity { pub fn new(hier: Vec<HierEntry>, component: impl Into<String>, instance: Option<String>) -> Result<MessageIdentity>;
pub fn hier(&self) -> &[HierEntry]; pub fn path(&self) -> &str; pub fn component(&self) -> &str; pub fn instance(&self) -> &str; pub fn device(&self) -> &str; // computed — NOT a wire field pub fn with_instance(&self, instance: impl Into<String>) -> Result<MessageIdentity>; pub fn from_wire(src: &Value) -> Option<MessageIdentity>; // lenient}The component’s resolved identity is gg.config().identity().
// edgecommons (src/message.ts)export interface HierLevel { level: string; value: string }
export class MessageIdentity { static readonly DEFAULT_INSTANCE = "main";
readonly hier: readonly HierLevel[]; // ordered; last entry = the device readonly path: string; // precomputed '/'-join readonly component: string; readonly instance: string;
get device(): string; // computed — NOT a wire field withInstance(instance: string): MessageIdentity; toObject(): unknown; static fromObject(src: unknown): MessageIdentity | undefined; // lenient}The component’s resolved identity is gg.config().componentIdentity.
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.
Accessors
Section titled “Accessors”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# edgecommons.facades.data_facade.DataFacade — via gg.data() / gg.instance(id).data()class DataFacade: def signal(self, signal_id: str) -> SignalUpdateBuilder: ... def publish_update(self, update: SignalUpdate) -> None: ... def publish(self, signal_path: str, value: Any, quality: Optional[Quality] = None) -> None: ... def publish_body(self, signal_path: str, body: dict, via: Optional[Channel] = None) -> None: ... # raw escape hatch def build_body(self, update: SignalUpdate) -> dict: ... def resolve_channel(self, via: Optional[Channel]) -> Channel: ...
gg.instance("kep1").data().signal("press12/temperature") \ .name("Line1 Temp") \ .device("opcua", "kep1", "opc.tcp://kep-server:49320") \ .add_sample(21.5) \ .publish()impl DataFacade { pub fn signal(&self, id: impl Into<String>) -> SignalUpdateBuilder; pub async fn publish(&self, update: SignalUpdate) -> Result<()>; pub async fn publish_value(&self, signal_path: impl Into<String>, value: impl Into<Value>) -> Result<()>; pub async fn publish_value_with_quality(&self, signal_path: impl Into<String>, value: impl Into<Value>, quality: Quality) -> Result<()>; pub async fn publish_body(&self, signal_path: &str, body: Value) -> Result<()>; // raw escape hatch pub async fn publish_body_via(&self, signal_path: &str, body: Value, via: Option<Channel>) -> Result<()>; pub fn build_body(&self, update: &SignalUpdate) -> Result<Value>; pub fn resolve_channel(&self, via: Option<Channel>) -> Channel;}
gg.instance("kep1")?.data().publish( SignalUpdate::builder() .signal_id("press12/temperature") .name("Line1 Temp") .sample(Sample::new(21.5)) // quality -> Good, server_ts -> now .build()).await?;class DataFacade { signal(id: string | undefined): SignalUpdateBuilder; publish(update: SignalUpdate): Promise<void>; publish(signalPath: string, value: unknown, quality?: Quality): Promise<void>; publishBody(signalPath: string, body: Record<string, unknown>, via?: Channel): Promise<void>; // raw escape hatch buildBody(update: SignalUpdate): Record<string, unknown>; resolveChannel(via: Channel | undefined): Channel;}
await 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();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-lostgg.getEvents().clearAlarm("connection-lost", ctx); // -> the same channel, active=false# edgecommons.facades.events_facade.EventsFacade — via gg.events() / gg.instance(id).events()class EventsFacade: def via(self, channel: Channel) -> "EventsFacade": ... def emit(self, event_type: str, message: Optional[str] = None, context: Optional[dict] = None, severity: Optional[Severity] = None) -> None: ... def raise_alarm(self, event_type: str, message: Optional[str] = None, context: Optional[dict] = None, severity: Optional[Severity] = None) -> None: ... def clear_alarm(self, event_type: str, context: Optional[dict] = None, severity: Optional[Severity] = None) -> None: ... def build_body(self, severity, event_type, message=None, context=None, alarm=None, active=None) -> dict: ... def channel_for(self, severity: Severity, event_type: str) -> str: ...
gg.events().raise_alarm("connection-lost", "Modbus link down", {"unit": 3}) # severity defaults to CRITICALgg.events().clear_alarm("connection-lost", {"unit": 3})impl EventsFacade { pub fn via(&self, channel: Channel) -> Result<EventsFacade>; // Err on a stream channel pub async fn emit(&self, severity: Severity, event_type: impl Into<String>, message: Option<String>, context: Option<Value>) -> Result<()>; pub async fn emit_message(&self, event_type: impl Into<String>, message: impl Into<String>) -> Result<()>; // severity=Info pub async fn raise_alarm(&self, severity: Severity, event_type: impl Into<String>, message: Option<String>, context: Option<Value>) -> Result<()>; pub async fn raise_alarm_default(&self, event_type: impl Into<String>, message: Option<String>, context: Option<Value>) -> Result<()>; // severity=Critical pub async fn clear_alarm(&self, severity: Severity, event_type: impl Into<String>, context: Option<Value>) -> Result<()>; pub async fn clear_alarm_default(&self, event_type: impl Into<String>, context: Option<Value>) -> Result<()>; // severity=Critical pub fn build_body(&self, severity: Severity, event_type: &str, message: Option<&str>, context: Option<&Value>, active: Option<bool>) -> Result<Value>; pub fn channel_for(severity: Severity, event_type: &str) -> Result<String>;}
gg.events().raise_alarm_default("connection-lost", Some("Modbus link down".into()), None).await?;gg.events().clear_alarm_default("connection-lost", None).await?;class EventsFacade { via(channel: Channel): EventsFacade; // throws on a stream channel emit(severity: Severity, type: string, message?: string, context?: Record<string, unknown>): Promise<void>; emitInfo(type: string, message?: string): Promise<void>; // severity = Severity.Info raiseAlarm(type: string, message?: string, context?: Record<string, unknown>, severity?: Severity): Promise<void>; // severity defaults to Critical clearAlarm(type: string, context?: Record<string, unknown>, severity?: Severity): Promise<void>; buildBody(severity: Severity, type: string, message: string | undefined, context: Record<string, unknown> | undefined, alarm: boolean | undefined, active: boolean | undefined): Record<string, unknown>; channelFor(severity: Severity, type: string): string;}
await gg.events().raiseAlarm("connection-lost", "Modbus link down");await gg.events().clearAlarm("connection-lost");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# edgecommons.facades.app_facade.AppFacade — via gg.app() / gg.instance(id).app()class AppFacade: def publish(self, name: str, channel: str, body: dict, routing: Optional[Channel] = None) -> None: ...
gg.app().publish("OrderReceived", "order/received", {"orderId": "A-42", "qty": 3})impl AppFacade { pub async fn publish(&self, name: impl Into<String>, channel: impl Into<String>, body: Value) -> Result<()>; pub async fn publish_via(&self, name: impl Into<String>, channel: impl Into<String>, body: Value, routing: Option<Channel>) -> Result<()>;}
gg.app().publish("OrderReceived", "order/received", json!({ "orderId": "A-42", "qty": 3 })).await?;class AppFacade { publish(name: string, channel: string, body: Record<string, unknown>, routing?: Channel): Promise<void>;}
await gg.app().publish("OrderReceived", "order/received", { orderId: "A-42", qty: 3 });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>"}class Quality(str, Enum): GOOD = "GOOD"; BAD = "BAD"; UNCERTAIN = "UNCERTAIN" def wire(self) -> str: ... @staticmethod def from_wire(token: Optional[str]) -> Optional["Quality"]: ...
class Severity(str, Enum): CRITICAL = "critical"; WARNING = "warning"; INFO = "info"; DEBUG = "debug" def wire(self) -> str: ... @staticmethod def from_wire(token: Optional[str]) -> Optional["Severity"]: ...
class Channel: class Kind(Enum): LOCAL = "local"; NORTHBOUND = "northbound"; STREAM = "stream" LOCAL: "Channel" # class attribute NORTHBOUND: "Channel" # class attribute @staticmethod def stream(name: str) -> "Channel": ... # data() only @staticmethod def from_config(value: Optional[str]) -> Optional["Channel"]: ...#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]#[serde(rename_all = "UPPERCASE")]pub enum Quality { Good, Bad, Uncertain }impl Quality { pub const fn wire(self) -> &'static str; pub fn from_wire(token: &str) -> Option<Quality>;}
#[serde(rename_all = "lowercase")]pub enum Severity { Critical, Warning, Info, Debug }impl Severity { pub const fn wire(self) -> &'static str; pub fn from_wire(token: &str) -> Option<Severity>;}
pub enum Channel { Local, Northbound, Stream(String) }impl Channel { pub fn stream(name: impl Into<String>) -> Result<Channel>; // data() only pub const fn is_stream(&self) -> bool; pub fn from_config(value: &str) -> Option<Channel>;}export enum Quality { Good = "GOOD", Bad = "BAD", Uncertain = "UNCERTAIN" }export function qualityFromWire(token: string): Quality | undefined;
export enum Severity { Critical = "critical", Warning = "warning", Info = "info", Debug = "debug" }export function severityFromWire(token: string): Severity | undefined;
export type ChannelKind = "local" | "northbound" | "stream";export type Channel = LocalChannel | NorthboundChannel | StreamChannel;export const Channel: { LOCAL: LocalChannel; NORTHBOUND: NorthboundChannel; stream(name: string): StreamChannel; // data() only fromConfig(value: string | undefined | null): Channel | undefined;};Channel routing
Section titled “Channel routing”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 tostreams().stream(name)(partition key =signal.id, timestamp = the sample’sserverTs) 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.
The command inbox: commands()
Section titled “The command inbox: commands()”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.
Accessor and registration
Section titled “Accessor and registration”| 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.
Built-in verbs
Section titled “Built-in verbs”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) |
Reply shape
Section titled “Reply shape”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.
The reserved-class publish guard
Section titled “The reserved-class publish guard”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 |