Skip to content

Frontend State Model and Data Access Patterns

This chapter shows how to build a robust frontend state model on top of the Coldwave backend. It focuses on how to organize data in your application, how to fetch it efficiently, and how to keep it in sync with WebSocket events.

The examples are written in TypeScript-flavoured pseudocode, but the concepts apply to any framework (React, Vue, Angular, Svelte, native mobile, etc.).

Goals of a good state model

A state model for Coldwave-based apps should:

  • Represent devices, services and properties in a clear, consistent way.
  • Separate static or slowly changing data (schemas, meta) from live data (property values).
  • Make it easy to:
    • Render dashboards and detail pages.
    • Find properties by device/service/property name.
    • React to incoming events without fragile, ad-hoc logic.
  • Avoid unnecessary duplication and recomputation.

A good mental model is:

The backend stores the truth, your frontend keeps a cached, normalized copy of that truth and updates it via WebSocket events.

Core entities in frontend state

The Coldwave backend exposes a small set of core concepts. In a UI, a normalized state model for these concepts might look like this (simplified):

ts
type DeviceId = string;          // deviceIdentifier from the API
type ServiceId = string;         // serviceIdentifier (UUID v4)
type PropertyId = number;        // numeric property identifier (e.g. 0x2000 -> 8192)
type PropertyKey = string;       // key as used in parsed responses, e.g. "temperature" or "0x2000.UINT8"

interface PropertyValue {
  propId: PropertyId;
  key: PropertyKey;              // schema name or 0x0000.TYPE fallback
  type: string;                  // FLOAT, UINT8, STRING, ...
  value: unknown;
  modifiers: {
    isReadOnly: boolean;
    isActionable: boolean;
    isVolatile: boolean;
    isError: boolean;
    isNull: boolean;
    isMeta: boolean;
  };
  // optional timestamps / meta, depending on your use case
  updatedAt?: number;            // unix timestamp
}

interface ServiceInstance {
  serviceIdentifier: ServiceId;
  deviceIdentifier: DeviceId;
  propertiesById: Record<PropertyId, PropertyValue>;
  propertiesByKey: Record<PropertyKey, PropertyValue>;
}

interface Device {
  deviceIdentifier: DeviceId;
  services: ServiceId[];
  // Meta information (names, location, tags, etc.) merged in from the meta module
  meta?: DeviceMeta;
}

interface DeviceMeta {
  name?: string;
  location?: string;
  // free-form metadata depending on your deployment
  [key: string]: unknown;
}

// Schema information
interface PropertySchema {
  propId: PropertyId;
  name: string;                  // "temperature"
  description?: string;
  type: string;                  // "float", "uint8", ...
  unit?: string;                 // "°C", "%", ...
  readonly?: boolean;
  actionable?: boolean;
  enum?: Record<string, { name: string; description?: string }>;
  group?: string;
  hint?: string;
}

interface ServiceSchema {
  serviceIdentifier: ServiceId;
  name: string;
  description?: string;
  properties: Record<PropertyId, PropertySchema>;
}

interface AlarmStatus {
  // shape based on the alarm module, simplified here
  watcherIdentifier: string;
  active: boolean;
  severity: number;
  deviceIdentifier: DeviceId;
  serviceIdentifier: ServiceId;
  updatedAt: number;
}

interface Note {
  noteIdentifier: string;
  deviceIdentifier: DeviceId;
  serviceIdentifier?: ServiceId;
  createdAt: number;
  updatedAt?: number;
  author: string;
  text: string;
  data?: Record<string, unknown>;
}

At application runtime you keep maps instead of flat arrays, so that you can look up entities efficiently:

ts
interface AppState {
  devices: Record<DeviceId, Device>;
  services: Record<string, ServiceInstance>; // key: `${deviceId}:${serviceId}`
  schemas: Record<ServiceId, ServiceSchema>;

  alarmsByDevice: Record<DeviceId, AlarmStatus[]>;
  notesByDevice: Record<DeviceId, Note[]>;
}

This shape keeps relational information (which service belongs to which device) explicitly and lets you add more modules (e.g. OTA, Streams) incrementally.

Bootstrapping: which endpoints to call

A typical initialization sequence for a UI looks like this:

  1. Authenticate and obtain a token via the IAM module.
  2. Fetch devices & services with a suitable depth:
    • GET /api/v1/devices?depth=2&raw=false
    • or GET /api/v1/services?depth=2&raw=false
  3. Fetch schemas:
    • GET /api/v1/schemas?depth=1
  4. Fetch meta data:
    • GET /api/v1/meta?depth=1
  5. Optionally: fetch alarms, notes, and other module data that should be visible at startup.
  6. Open a WebSocket ticket and establish the connection.

A few recommendations:

  • Prefer raw=false for UI-facing views so that property maps already use schema names where available.
  • Fetch schemas with a high enough depth so that you get the full property definitions in one call.
  • Use the same language (lang parameter) for schemas and for any other translatable resources if you rely on translations.

Mapping service responses into your state

The service module lets you get properties in two main flavours:

  • Raw format (raw=true): properties are keyed by numeric IDs and you get type and value but no schema names.
  • Parsed format (raw=false): properties are keyed by schema names. If a property has no schema entry, the key will be 0x0000.TYPE (e.g. 0x2000.UINT8).

A good strategy is to always build both ID-based and name-based indices:

ts
function applyServicePayloadToState(state: AppState, payload: any) {
  const deviceId: DeviceId = payload.deviceIdentifier;
  const serviceId: ServiceId = payload.serviceIdentifier;
  const serviceKey = `${deviceId}:${serviceId}`;

  const serviceInstance: ServiceInstance = state.services[serviceKey] ?? {
    deviceIdentifier: deviceId,
    serviceIdentifier: serviceId,
    propertiesById: {},
    propertiesByKey: {},
  };

  const properties = payload.properties ?? {};

  for (const [key, prop] of Object.entries<any>(properties)) {
    // key is either schema property name or "0x0000.TYPE"
    const propId: PropertyId = prop.propId ?? parsePropIdFromKey(key);

    const value: PropertyValue = {
      propId,
      key,
      type: prop.type,
      value: prop.value,
      modifiers: {
        isReadOnly: !!prop.modifier?.isReadOnly,
        isActionable: !!prop.modifier?.isActionable,
        isVolatile: !!prop.modifier?.isVolatile,
        isError: !!prop.modifier?.isError,
        isNull: !!prop.modifier?.isNull,
        isMeta: !!prop.modifier?.isMeta,
      },
      updatedAt: Date.now(),
    };

    serviceInstance.propertiesById[propId] = value;
    serviceInstance.propertiesByKey[key] = value;
  }

  state.services[serviceKey] = serviceInstance;

  // ensure device exists and references this service
  if (!state.devices[deviceId]) {
    state.devices[deviceId] = { deviceIdentifier: deviceId, services: [] };
  }
  const dev = state.devices[deviceId];
  if (!dev.services.includes(serviceId)) {
    dev.services.push(serviceId);
  }
}

The helper parsePropIdFromKey can handle the common fallback case:

ts
function parsePropIdFromKey(key: string): PropertyId {
  // "temperature" -> NaN (no direct ID, will be filled via schema later)
  // "0x2000.UINT8" -> 0x2000
  const hexMatch = /^0x([0-9a-fA-F]+)\./.exec(key);
  if (!hexMatch) return NaN as any;
  return parseInt(hexMatch[1], 16);
}

Later, when schemas are available, you can backfill missing propIds based on the schema's "0x0000" keys.

Combining properties with schema information

Schema data gives your UI the context it needs to render inputs, labels, units and enums correctly.

After loading schemas, create a mapping from (serviceIdentifier, propId) to PropertySchema:

ts
function applySchemasToState(state: AppState, response: any[]) {
  for (const entry of response) {
    const serviceId: ServiceId = entry.serviceIdentifier;
    const schemaObj = entry.schema;
    if (!schemaObj) continue;

    const properties: Record<PropertyId, PropertySchema> = {};

    for (const [hexKey, prop] of Object.entries<any>(schemaObj.properties ?? {})) {
      const propId = parseInt(hexKey, 16);
      properties[propId] = {
        propId,
        name: prop.name,
        description: prop.description,
        type: prop.type,
        unit: prop.unit,
        readonly: prop.readonly,
        actionable: prop.actionable,
        enum: prop.enum,
        group: prop.group,
        hint: prop.hint,
      };
    }

    state.schemas[serviceId] = {
      serviceIdentifier: serviceId,
      name: schemaObj.name,
      description: schemaObj.description,
      properties,
    };
  }
}

With this mapping you can derive view-models for your components:

ts
interface UiProperty {
  id: PropertyId;
  key: PropertyKey;
  label: string;
  description?: string;
  unit?: string;
  type: string;
  value: unknown;
  readOnly: boolean;
  enumOptions?: { value: string; label: string }[];
}

function buildUiProperties(
  state: AppState,
  deviceId: DeviceId,
  serviceId: ServiceId
): UiProperty[] {
  const serviceKey = `${deviceId}:${serviceId}`;
  const service = state.services[serviceKey];
  if (!service) return [];

  const schema = state.schemas[serviceId];
  const result: UiProperty[] = [];

  for (const prop of Object.values(service.propertiesById)) {
    const schemaProp = schema?.properties[prop.propId];

    const label = schemaProp?.name ?? prop.key;
    const description = schemaProp?.description;
    const unit = schemaProp?.unit;
    const readOnly = schemaProp?.readonly ?? prop.modifiers.isReadOnly;

    const enumOptions = schemaProp?.enum
      ? Object.entries(schemaProp.enum).map(([value, def]) => ({
          value,
          label: def.name,
        }))
      : undefined;

    result.push({
      id: prop.propId,
      key: prop.key,
      label,
      description,
      unit,
      type: schemaProp?.type ?? prop.type,
      value: prop.value,
      readOnly,
      enumOptions,
    });
  }

  return result;
}

This pattern keeps your rendering logic simple: components operate on UiProperty objects and do not have to know about prop IDs, backend modifiers or schema details.

Using schema names in URLs and API calls

For many endpoints you can use schema names instead of numeric identifiers. Typical examples include:

  • Open property endpoints:
    • GET /api/v1/devices/:deviceIdentifier/services/:serviceIdentifier/openProperty/:property
    • PUT /api/v1/devices/:deviceIdentifier/services/:serviceIdentifier/openProperty/:property
    • where :property can be either 0x0000.TYPE or the schema property name.
  • Alarm expressions and configuration can reference properties by name (e.g. "ride-speed") or by 0x0000.TYPE notation.

Recommendations:

  • In UI code and user-facing URLs, using names is convenient and makes debugging easier.
  • In long-lived integrations or external APIs, prefer numeric IDs and UUIDs to avoid breakage when schema names are changed.
  • If you use names in URLs, treat them like public API surface: avoid renaming without a migration strategy.

A common pattern is to:

  • Use schema names as keys inside your own UI (component props, form fields, etc.).
  • Internally always keep track of the numeric propId as the stable identifier.
  • For cross-system integration, document the numeric IDs alongside the names.

Keeping state in sync with WebSocket events

Once the initial state is fetched, WebSocket events keep it up-to-date. From a state-model perspective, most events fall into a few categories:

  • Property updates (values changed, properties added or removed).
  • Schema updates (names, units, enums changed).
  • Meta updates (device name/location changed).
  • Module-specific events:
    • Alarms: configuration added/updated, status changed.
    • Notes: note created/updated/deleted.
    • OTA: firmware update started/finished/failed.

The exact event types and payloads are documented per module. In your client, you can implement a single WebSocket dispatcher that routes events to the correct reducer:

ts
function handleWebSocketEvent(state: AppState, event: any) {
  switch (event.type) {
    case "SERVICE_PROPERTY_UPDATE":
      // pseudo name; use the actual type from the service module
      return applyServicePayloadToState(state, event);

    case "SCHEMA_ADD":
    case "SCHEMA_UPDATE":
      return applySchemasToState(state, [event]);

    case "SCHEMA_DELETE":
      delete state.schemas[event.serviceIdentifier];
      return;

    case "META_UPDATE":
      // update device meta information
      return applyMetaUpdate(state, event);

    case "ALARM_STATUS_ACTIVE":
    case "ALARM_STATUS_INACTIVE":
      return applyAlarmStatusUpdate(state, event);

    case "NOTE_ADD":
    case "NOTE_UPDATE":
    case "NOTE_DELETE":
      return applyNoteEvent(state, event);

    default:
      // ignore events you don't care about
      return;
  }
}

Each apply* function encapsulates the logic for translating an event into a state change in your normalized store.

Caching, pagination and performance considerations

Depending on the size of your deployment, you may have:

  • Hundreds or thousands of devices.
  • Many services per device.
  • Schemas with dozens of properties.

A few practical guidelines:

  • Paginate lists of devices, alarms, notes where the API supports it.
  • Cache schemas aggressively: they change rarely. You can even preload them in a separate background request and keep them in local storage or indexedDB.
  • Avoid cloning large objects on every update. In state-management libraries that support it (e.g. Redux Toolkit with Immer), update only the affected branches.
  • Store timestamps (e.g. updatedAt) per property or per service to be able to debounce UI updates or highlight “recently changed” values.
  • Normalize module-specific data (alarms, notes, OTA jobs) similarly to the core entities, using IDs as keys and indices by deviceIdentifier or serviceIdentifier.

Summary

  • Represent devices, services, properties, schemas and module data (alarms, notes, etc.) in a normalized store keyed by identifiers.
  • Use service responses to populate live property values, and schemas to enrich them with names, units and enums.
  • Keep track of both numeric property IDs and schema names to combine stability and readability.
  • Process WebSocket events through a single dispatcher that delegates to small, focused reducer functions.

With this foundation, your frontend can grow from a simple dashboard to a complex application (multi-tenant portals, configuration tools, maintenance consoles) without needing to rewrite core data-handling logic.