Connect a polling API
Read a vendor endpoint on an interval, validate what comes back, and route it to your shadows.
Many systems you will meet, from traffic management to building management, offer only a pull
API: you ask for the current state, they answer. pollingSource turns such an API into a
source of readings.
import { z } from "zod";
import { connectSource, pollingSource } from "@klorad/api/integration";
import { createScene } from "@klorad/api/world";
const scene = createScene({ coordinateSystem: { origin: { lat: 40.62637, lon: 22.94838 } } });
scene.add({
id: "vms-12",
name: "Sign VMS-12",
position: { east: 120, north: 40 },
correspondence: "shadow",
binding: { source: "atms", entity: "vms-12" },
});
// What the vendor promises to send. Anything else is refused before it reaches the scene.
const SignsResponse = z.object({
signs: z.array(z.object({ id: z.string(), message: z.string(), updatedAt: z.string().datetime() })),
});
const atms = pollingSource({
id: "atms",
intervalMs: 15_000,
read: async () => {
const response = await fetch("https://atms.example.com/api/signs");
const body = SignsResponse.parse(await response.json());
return body.signs.map((s) => ({ entity: s.id, quantity: "message", value: s.message, observedAt: Date.parse(s.updatedAt) }));
},
onError: (error) => console.warn("ATMS poll failed", error),
});
const connection = connectSource(scene, atms);
// Counts every reading: received, latest, late, duplicate, unbound and rejected.
const { received, unbound } = connection.stats();
export { connection, received, unbound };What matters
Bind by the vendor's own ids. The source reports entity: s.id exactly as the vendor
names it, and each shadow's binding.entity uses the same id. Mapping happens in bindings,
not in the source.
Use the vendor's timestamp. observedAt must be when the vendor observed the state, not
when you polled. A poll that returns an unchanged sign with an unchanged updatedAt then
becomes a duplicate and costs nothing, and an out of date answer cannot overwrite a newer one.
Validate at the boundary. The schema runs before anything reaches the scene. If the vendor
changes its payload, parse throws, the poll fails, onError hears about it, and the twin
keeps its last good state instead of filling with nonsense.
Keep credentials on the server. If the API needs a key, run the polling source in server code (a route handler, a worker) and forward readings to browsers; never ship the key to the client.
When something does not show up
Look at connection.stats(). A growing unbound count means readings arrive for entities no
object is bound to: compare the vendor's ids with your bindings. A growing duplicate count
is normal for polling.
Something wrong or unclear? Tell your contact at Prieston Technologies.