Client Architecture Principles
This page describes how a client should be structured, not the format of the exchanged messages (see Data Model and the following pages for that).
Principle: a single datastore holds the truth
The client maintains a single data structure representing the current state (modules → properties → elements). All the UI reads this structure; nothing else modifies it except messages received from the server.
Read : Datastore ────────────► UI
Write : UI ─── SV/SA ───► Server ─── update ───► Datastore- No duplication: if a component needs a value, it reads it from the datastore, it does not keep its own copy.
- No direct write: the UI never modifies the datastore itself, not even for an optimistic display.
A user action (changing a value, clicking a button) triggers sending a
message to the server (SV/SA — see
Client → Server). The local
datastore only changes in return, once the server responds with the
confirmed update. The client never assumes the outcome.
Principle: no internet dependency
An OST server is frequently run on an isolated local network — a remote observatory site with no path to the outside internet is a normal deployment, not an edge case. A client has to work correctly there: every resource it needs must be bundled with the client itself, not fetched from a CDN or any other external service at runtime.
This directly affects icon rendering: preicon1/preicon2/posticon1/posticon2/preicon/posticon give a Google Fonts (Material Icons) icon name, not a URL — a client has to ship the icon font/glyphs locally (vendored, self-hosted, or bundled at build time) instead of loading Google’s CDN stylesheet, which silently breaks the moment there is no route to the outside internet.
The same goes for anything else a client might be tempted to pull from a CDN: a JS framework, a charting library, a web font for the UI itself. If the client needs it to run, it ships with the client.
Strong recommendation: support at least a dark and a light theme
OST clients are commonly used at the eyepiece or next to a telescope, at night — a full-brightness, white-background UI is not just a stylistic mismatch there, it actively ruins the operator’s night vision. A client should offer at minimum both a dark and a light theme, not hardcode one or the other.
This is not part of the wire protocol — nothing here is enforced or even visible server-side — but it is a strong recommendation for anything meant to be used for real astrophotography sessions, not just as a demo or a desk-testing tool.
Full dump vs partial updates
On connection, the client sends DU; the server responds with a full dump
(d) containing all modules with their complete metadata (label, type,
min/max, LOV, etc.).
The messages that arrive afterwards (ee, ea) contain only the
value, not the metadata. The client must:
- Keep the metadata from the last full dump.
- On a partial update, patch only the value, never assuming the metadata has changed.
If the metadata itself changes (an element’s min/max/format), the server
signals it explicitly via a distinct message, ev
(see Server → Client) — this is
never silent. The client only has to react to the message type it
receives, without having to guess what changed.
When a property references a global LOV and that LOV changes, the server
sends the update without the value field. The client must never
preserve the old value in this specific case — apply what the server
sends as-is, including the absence of a value. The server is
authoritative; never try to “fill in” a missing field with the previous
local state.
Rendering: scope updates to the property that changed
Some modules can send several ee/ea updates per second (for
example a module continuously polling a device). A client that reacts
to every incoming update by re-rendering its whole UI — or even just
the whole displayed module — becomes unusable under this load: lost
input focus, flicker, and in the worst case the UI stops responding to
input entirely.
This is not an edge case to defend against “just in case”: it is real, current backend behavior, and clients must be built to handle it from the start:
- On each update, only the JS/data object of the property that changed should be replaced/mutated. Every other property’s reference must stay exactly the same as long as it isn’t affected.
- UI rendering must be keyed by property (or module) identity — a list of properties should reuse the same UI element for the same property across renders, never destroy and recreate it, so an unrelated input is never touched no matter what else is happening.
- Limiting the rate of rendering (debounce/throttle) is not enough on its own if the render itself still rebuilds unrelated UI — the fix is in the scope of what gets rebuilt, not just how often.
Rooster (the production frontend) implements this by design: an incoming update only replaces the touched property’s reference in the store, and every list of properties is rendered with a stable per-property key. As a result, unrelated properties’ UI is never disturbed, no matter how fast another property updates elsewhere.
Templates: per-module-type screens with a generic fallback
Each module instance may advertise infos.template (see Data
Model) — a string naming its type
("focus", "guider", "sequencer"…). It is a hint only, and it is
allowed to be absent.
A client that supports per-template custom screens should treat a
missing (or unrecognized) infos.template exactly like the literal
value "default" — not as an error. Rooster does this with a small
registry mapping a lowercased template name to a screen component,
falling back to its generic/default screen when the name is absent or
not in the registry:
template = infos.template?.toLowerCase() || "default"
screen = registry[template] ?? registry["default"]This is what lets a client stay fully functional against a module type it has never seen before — a new or third-party module always renders through the generic screen — while still offering a polished, purpose-built screen for the module types worth investing in.
The "default" screen is not a token minimal fallback: it is the
only screen some module types will ever get, so it has to render
everything a generic property/element can carry — status, hint,
pre/posticons, grids, graphs, LOVs, the lot — not just values. A
custom per-template screen is free to be more curated (hide fields,
reorganize layout), but the default one should not silently drop
information because no one wrote a dedicated screen for that module
yet.
Not mandatory, but strongly worth it: build a single component
responsible for rendering one property (or one element) — handling
directedit, permission, the three LOV scopes, bool-group rules,
autoupdate, grids, graphs — and have every screen use it for
individual properties, including custom per-template screens, not just
the generic fallback.
The alternative — each screen re-implementing its own field rendering —
duplicates every wire-format nuance across the codebase, and a fix or a
new backend capability only reaches the screens someone remembers to
update. Rooster’s custom screens (GuiderComponent, FocusComponent…)
render their own layout, but still delegate each individual property to
the same generic property component the fallback screen uses — a fix
made once there (e.g. correctly handling an exclusive switch group)
applies everywhere automatically, custom screens included.
Property forms: SA, mandatory for directedit: false
Beyond field-by-field SV, an editable property needs a form path: a screen listing all its editable elements at once, submitted together via SA.
directedit: false elementSV is rejected by the backend for any element with directedit: false (see Data model). SA is the only way to ever update such an element — a client with no form path simply cannot write to that element at all, no matter how the rest of its UI is built.
Only editable elements belong in this form — display-only types (img, video, light, prg, message) are rendered, never submitted; leave them out of the payload entirely rather than sending a meaningless value for them.
The same “submit a set of elements at once” shape is reused by grid line creation (GC) and update (GU) — both take an e map exactly like SA does. It is worth building one reusable form component that serves the plain-property form, the grid-row-create dialog and the grid-row-edit dialog alike, rather than three separate implementations of essentially the same “render editable elements, collect their values” logic. Rooster does this.
Heartbeat
The heartbeat is not imposed by the protocol. It is a mechanism a client sets up on its own, only if its technology requires it, to keep the WebSocket connection alive (some browsers, proxies or platforms close a connection deemed idle). A client that doesn’t need it can skip it entirely.
The mechanism exists at the protocol level for whoever needs it: send
XX every 30 seconds, the server replies xx (no meaningful payload).
→ {"XX": {}}
← {"xx": {}}Reconnection
On any disconnection, the client must entirely clear its datastore — no partial state can be considered reliable after a disconnect.
What the client does next (retry automatically N times, prompt the user to reconnect, give up…) is entirely up to the client: this is not specified by the protocol, each client is free to choose its own strategy.
But once (re)connected, sending DU again to get a fresh full dump is
nearly mandatory: rebuilding a usable state from partial updates alone
(ee/ea/ev) is impossible — they only ever give a fragmentary view,
never a complete state. On the server side, nothing needs to be
preserved between two connections of the same client: this is purely a
client-side constraint.
Minimal example: first exchange
No framework, just the very first exchange (connect → DU → listen) —
taken from test-ws-advanced.js, the script used for the server’s
hourly health check:
const ws = new WebSocket("ws://127.0.0.1:9624");
ws.on("open", () => {
ws.send(JSON.stringify({ "DU": { "language": "en" } }));
});
ws.on("message", (data) => {
console.log("received:", data.toString());
});Example: reading and writing in Rooster
Real excerpts illustrating the read/write separation from the top of this page, once an actual client (here Rooster, in Angular) is built around this principle.
Read — datastore.service.ts, a plain access to the internal state:
getProperty(moduleName: string, propertyName: string): Observable<any> {
return new Observable(subscriber => {
const subscription = this.changed$.subscribe(() => {
const module = this.modules[moduleName];
const prop = (module as any)?.properties?.[propertyName] || null;
subscriber.next(prop);
});
subscriber.next(this.modules[moduleName]?.properties?.[propertyName] || null);
return () => subscription.unsubscribe();
});
}Write — websocket.service.ts, no local mutation, just sending the
message:
setPropertyOneElement(module: string, property: string, elements: { [key: string]: any }): void {
this.send({
SV: { m: { [module]: { p: { [property]: { e: elements } } } } }
});
}