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.
Writes: always through the server

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.).

Partial updates only carry the value

The messages that arrive afterwards (ee, ea) contain only the value, not the metadata. The client must:

  1. Keep the metadata from the last full dump.
  2. 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.

Known pitfall: GlobalLOV updates

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

Mandatory: never re-render more broadly than the changed property

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:

  1. 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.
  2. 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.
  3. 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.

Absent template = the default screen

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.

Recommended: one single property-rendering component, reused everywhere

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.

Not optional for a directedit: false element

SV 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

Optional

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

Always start again from a full dump

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 } } } } }
  });
}