Realtime patterns
Three things every realtime client needs: matching command results to the commands you sent, ignoring repeated status updates, and catching up after a disconnect. And one thing Parklio doesn't have: webhooks.
Correlate results by event_id
POST /v2/products/{id}/state and
POST /v2/products/{id}/settings return an
event_id. The result arrives later as a lot_change (success) or gateway_error
(failure) with the same event_id. See
Asynchronous operations.
The helper below handles the cases that trip clients up:
- the result can arrive before the HTTP response, so unmatched results are kept for a while;
- your connection also gets results for operations you didn't send, which are ignored;
- a result may never come, so each wait has a timeout.
- Node.js
- Python
import type { Socket } from 'socket.io-client';
export interface OperationEvent {
event_id?: string;
type?: number | 'Timeout';
message?: string;
data: Record<string, any>;
}
export type OperationResult =
| { ok: true; event: OperationEvent }
| { ok: false; event: OperationEvent }
| { ok: false; timedOut: true };
// Matches realtime results to the event_ids your commands returned.
export class PendingOperations {
#waiting = new Map<string, { resolve: (r: OperationResult) => void; timer: NodeJS.Timeout }>();
#early = new Map<string, OperationResult>(); // results that arrived before we asked
constructor(socket: Socket, { keepEarlyMs = 60_000 } = {}) {
const settle = (ok: boolean) => (raw: string) => {
const event: OperationEvent = JSON.parse(raw);
if (!event.event_id) return; // a status update, not a command result
const eventId = event.event_id;
const result: OperationResult = ok ? { ok: true, event } : { ok: false, event };
const waiter = this.#waiting.get(eventId);
if (waiter) {
clearTimeout(waiter.timer);
this.#waiting.delete(eventId);
waiter.resolve(result);
} else {
this.#early.set(eventId, result);
setTimeout(() => this.#early.delete(eventId), keepEarlyMs).unref();
}
};
socket.on('lot_change', settle(true));
socket.on('gateway_error', settle(false));
}
// Resolves with { ok, event }, or { ok: false, timedOut: true }.
wait(eventId: string, timeoutMs: number): Promise<OperationResult> {
const early = this.#early.get(eventId);
if (early) {
this.#early.delete(eventId);
return Promise.resolve(early);
}
return new Promise((resolve) => {
const timer = setTimeout(() => {
this.#waiting.delete(eventId);
resolve({ ok: false, timedOut: true });
}, timeoutMs);
this.#waiting.set(eventId, { resolve, timer });
});
}
}
const pending = new PendingOperations(socket); // create it right after connecting
const res = await fetch(`https://api.parklio.com/v2/products/${productId}/state`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ operation: 'open' }),
});
const { data } = await res.json();
const result = await pending.wait(data.event_id, 120_000);
import json
import threading
import time
class PendingOperations:
"""Matches realtime results to the event_ids your commands returned."""
def __init__(self, sio, namespace="/v2/realtime", keep_early_s=60):
self._lock = threading.Lock()
self._waiting = {} # event_id -> [threading.Event, result]
self._early = {} # event_id -> (result, arrived_at)
self._keep_early_s = keep_early_s
sio.on("lot_change", self._settle(True), namespace=namespace)
sio.on("gateway_error", self._settle(False), namespace=namespace)
def _settle(self, ok):
def handler(raw):
event = json.loads(raw)
event_id = event.get("event_id")
if not event_id:
return # a status update, not a command result
result = {"ok": ok, "event": event}
with self._lock:
slot = self._waiting.get(event_id)
if slot:
slot[1] = result
slot[0].set()
else:
now = time.monotonic()
self._early = {
k: v for k, v in self._early.items() if now - v[1] < self._keep_early_s
}
self._early[event_id] = (result, now)
return handler
def wait(self, event_id, timeout_s):
"""Returns {"ok", "event"}, or {"ok": False, "timed_out": True}."""
with self._lock:
early = self._early.pop(event_id, None)
if early:
return early[0]
slot = self._waiting[event_id] = [threading.Event(), None]
slot[0].wait(timeout_s)
with self._lock:
self._waiting.pop(event_id, None)
return slot[1] or {"ok": False, "timed_out": True}
pending = PendingOperations(sio) # create it before connecting
res = httpx.post(
f"https://api.parklio.com/v2/products/{product_id}/state",
headers={"Authorization": f"Bearer {token}"},
json={"operation": "open"},
)
result = pending.wait(res.json()["data"]["event_id"], timeout_s=120)
When a wait times out, the operation may still have run. Re-read the product with
GET /v2/products/{id} before deciding what to do.
Deduplicate status updates
lot_change events without an event_id come from Gateways reporting what they see
over Bluetooth. Two things to know:
- Each one is sent twice, with identical payloads.
- They repeat even when nothing changed: a Gateway's report re-sends every product it sees.
So treat them as "current state of product X", not as "product X changed". Keep the last state per product, and act only when something you care about differs:
const lastState = new Map<number, string>(); // product id -> fingerprint
socket.on('lot_change', (raw: string) => {
const { event_id, data } = JSON.parse(raw);
if (event_id) return; // command results are handled by PendingOperations
const block = data.barrier ?? data.gate ?? data.chain ?? data.bollard ?? data.brain;
const fingerprint = JSON.stringify([data.status, block?.state, data.product_error]);
if (lastState.get(data.id) === fingerprint) return;
lastState.set(data.id, fingerprint);
onProductChanged(data); // your code
});
Pick the fields for the fingerprint from what you act on; battery_status and rssi
change often and are rarely worth an alert.
Resync after a reconnect
There's no replay: anything sent while you were disconnected is gone. So every time you connect, re-read the state you track over REST, page by page. That covers the first connect too, where events sent in the first moment can be missed (see Authentication).
socket.on('connect', () => resyncLot(lotId)); // fires on the first connect and after each reconnect
async function resyncLot(lotId: number) {
for (let page = 1, last = 1; page <= last; page++) {
const url = `https://api.parklio.com/v2/products?filter=lot_id:eq:${lotId}&page=${page}&size=100`;
const body = await (await fetch(url, { headers: { Authorization: `Bearer ${token}` } })).json();
for (const product of body.data) {
lastState.delete(product.id);
onProductChanged(product); // note: REST and realtime product shapes differ slightly
}
last = body.meta.last_page;
}
}
Also treat every command that was pending during the disconnect as unknown: re-read those products too.
In Python, python-socketio calls your connect handler on the first connect and after
each reconnect too; start the resync from there.
No webhooks
Parklio doesn't call your URLs. Realtime events are delivered only over this Socket.IO connection, to clients that are connected at the time. If other systems need the events, run one small service that holds the connection and forwards what you need (to a queue, a database or your own webhooks), and resyncs after reconnects as above.
Next
- Events: every event and its payload.
- Asynchronous operations