Integration
Sources report readings about their own entities; connectSource routes them to the shadows and twins bound to them.
The Integration layer couples the world to reality. It depends on World; World never imports
it. Its central idea is that a source knows nothing about scenes: it reports readings about
its own entities (entrance-01, vms-12, barrier-1), and the scene decides where they
belong through each object's binding.
binding: { source: "gateway", entity: "entrance-01" }That keeps vendor code and world code apart. You can swap a fixture for a real gateway, or one vendor for another, without touching a single Scene Object.
Sources
An ObservationSource has an id (matched against binding.source) and a start(sink)
method that begins reporting and returns a function that stops it. Three ship today:
| Source | What it does | Use it for |
|---|---|---|
fixtureSource | Generates readings on an interval from functions you give it. | Demos, tests, the quickstart. Not a device. |
pollingSource | Calls your read() on an interval and reports what it returns. | Any system that only offers a pull API. |
fixtureDevice | A simulated device: reports state and accepts commands. | Demonstrating a Digital Twin's two way loop. |
Writing your own is one object with two members, so a push source (a webhook receiver, a message queue consumer) is a few lines.
Connecting
connectSource(scene, source) starts the source and routes every reading to every shadow or
twin bound to that source and entity. It returns a Connection whose stats() counts what
happened, which is the first thing to look at when data does not show up:
| Counter | Meaning |
|---|---|
received | Readings the source reported. |
latest, late, duplicate | What the scene did with them (see Time). |
unbound | No object is bound to that source and entity. Usually a typo in a binding. |
rejected | The scene refused the reading, for example an invalid value. |
import { createScene } from "@klorad/api/world";
import { connectSource, fixtureSource, pollingSource } from "@klorad/api/integration";
const scene = createScene({ coordinateSystem: { origin: { lat: 40.62637, lon: 22.94838 } } });
scene.add({
id: "entrance",
name: "Entrance counter",
position: { east: 16, north: -10 },
correspondence: "shadow",
binding: { source: "gateway", entity: "entrance-01" },
});
// A fixture: generated readings once a second. A demo of the data path, not a device.
const fixture = fixtureSource({
id: "gateway",
channels: [{ entity: "entrance-01", quantity: "occupancy", unit: "people", value: (tick) => 20 + (tick % 7) }],
});
const connection = connectSource(scene, fixture);
// A real system that only offers a pull API, for example a connector's getStatus.
interface SignStatus {
message: string;
updatedAt: string;
}
declare const signs: { getStatus(ids: string[]): Promise<Record<string, SignStatus>> };
const vms = pollingSource({
id: "atms",
intervalMs: 15_000,
read: async () => {
const status = await signs.getStatus(["vms-12", "vms-14"]);
return Object.entries(status).map(([entity, s]) => ({
entity,
quantity: "message",
value: s.message,
observedAt: Date.parse(s.updatedAt),
}));
},
});
export { connection, vms };Outside data is not trusted
Validate every vendor payload at the boundary, inside read() or your push handler, before it
becomes a reading. Connect a polling API shows the pattern with
Zod. Credentials for vendor APIs belong on a server, never in browser code.
Devices and commands
A Digital Twin's commands leave through an Actuator: an object with a source (matched against the twin's binding.source) and a
send(command) method that returns whether the device accepted it. fixtureDevice is both a
source and an actuator, so it closes the loop the way a real device does: it accepts a command,
and after a latency reports the new state as a reading. See
Interaction and actuation.
Something wrong or unclear? Tell your contact at Prieston Technologies.