Act on a twin
Define an Action that sends a command to a device, check who may take it, and watch the twin follow the device.
This guide makes an entrance barrier operable: operators may open it, visitors may not, and the twin shows the barrier open only once the device says it is.
import { allowEveryone, createAccessPolicy } from "@klorad/access";
import { connectSource, fixtureDevice } from "@klorad/api/integration";
import { createInteraction, createScene } from "@klorad/api/world";
const scene = createScene({ coordinateSystem: { origin: { lat: 40.62637, lon: 22.94838 } } });
scene.add({ id: "lamp", name: "Street lamp", position: { east: -8, north: 4 }, properties: { level: 100 } });
scene.add({
id: "barrier",
name: "Entrance barrier",
position: { east: 6, north: -2 },
correspondence: "twin",
binding: { source: "plc", entity: "barrier-1" },
});
// The physical side: a simulated PLC that reports the barrier's state and accepts commands.
const plc = fixtureDevice({
id: "plc",
entities: { "barrier-1": { state: "closed" } },
respond: (command, now) => (now.state === command.name ? `already ${now.state}` : { state: command.name }),
});
connectSource(scene, plc);
const policy = createAccessPolicy({
scene,
roles: { operator: "*", visitor: [] },
worlds: [{ id: "site", name: "Site", scope: { all: true }, capabilities: { actions: "*" }, principals: [{ kind: "role", id: "operator" }] }],
});
const interaction = createInteraction(scene, {
entitlement: policy,
actuators: [plc],
behaviours: [
{ id: "dim", run: ({ update, animate }) => (update({ properties: { level: 30 } }), animate({ kind: "pulse" })) },
{ id: "requested", run: ({ update, now }) => update({ properties: { requestedAt: now } }) },
],
actions: [
{ id: "dim", label: "Dim", appliesTo: (o) => o.id === "lamp", behaviour: "dim" },
{ id: "open", label: "Open", appliesTo: (o) => o.correspondence === "twin", behaviour: "requested", command: () => ({ name: "open" }) },
],
});
const operator = { id: "eleni", roles: ["operator"] };
const visitor = { id: "guest", roles: ["visitor"] };
async function demo() {
await interaction.perform({ action: "dim", objectId: "lamp", actor: visitor, world: "site" }); // { outcome: "denied", reason: ... }
await interaction.perform({ action: "dim", objectId: "lamp", actor: operator, world: "site" }); // { outcome: "done" }
await interaction.perform({ action: "open", objectId: "barrier", actor: operator, world: "site" }); // { outcome: "sent", command }
// About 600 ms later the PLC reports state "open": scene.latest("barrier", "state")?.value === "open"
return interaction.available("lamp", operator, "site").map((a) => a.id); // ["dim"]
}
// For a demo or a single-user tool, allowEveryone says so in its name.
const playground = createInteraction(createScene({ coordinateSystem: { origin: { lat: 0, lon: 0 } } }), { entitlement: allowEveryone });
export { demo, playground };Step by step
- The barrier is a twin.
correspondence: "twin"with a binding to the PLC that controls it. A shadow could not be commanded. - The device is an actuator and a source.
fixtureDevicesimulates the PLC: it reportsstateand accepts commands. In production this is your ownActuator, sending the command to the real controller, plus the source that reports its state. - The policy decides. Operators have every Action in the
siteWorld; visitors have none. Swap in your own roles and Worlds; see Give a team its own view. - The Action carries a command.
openapplies to twins and sends{ name: "open" }. Its Behaviour only records that the opening was requested. - The reading closes the loop. About 600 ms later the device reports
state: "open", andscene.latest("barrier", "state")follows.
Handling outcomes
Show the outcome to the person who acted. denied and failed carry a reason worth
displaying; sent means "the device accepted it", not "it happened". If the device never
reports back, the twin keeps showing the old state, which is the truth as far as anyone knows.
Something wrong or unclear? Tell your contact at Prieston Technologies.