This is a development version of the documentation. Content may change without notice.
Voke Documentation
Concepts

Signals

Signals are the named telemetry values decoded from device payloads. Two kinds exist — binary (bitmask bits) and numeric (named fields) — with different storage strategies.

Two kinds of signals

A device template contains a signals array of up to 256 signal definitions. Each signal is either binary or numeric, identified by the kind discriminant. A single template may mix both kinds freely.

Binary signals

Binary signals decode a single bit from a raw bitmask integer in the snapshot envelope. They are used for cabinet-type sub-devices where state is represented as a bitmask word (e.g. bit 0 = main contactor closed, bit 1 = alarm active, bit 7 = door open).

// BinarySignalSchema — packages/shared/src/schemas/sub-device.ts (lines 29–41)
{
  key:      string;   // snake_case, 164 chars, unique within template
  label:    string;   // human-readable label, 1120 chars
  kind:     'BINARY';
  bit:      number;   // integer 063; bit position within the raw bitmask
  alertOn?: 'ON' | 'OFF' | null;   // trigger an alert when bit enters this state
  store?:   'STATE_CHANGE' | 'TICK'; // default: STATE_CHANGE
  featured?: boolean;
}

Numeric signals

Numeric signals decode a named field from the values map in the snapshot envelope. They are used for meter, inverter, and battery sub-devices where measurements are named (e.g. activePowerKw, gridFrequencyHz, batteryStateOfCharge).

// NumericSignalSchema — packages/shared/src/schemas/sub-device.ts (lines 43–56)
{
  key:        string;   // snake_case, 164 chars, unique within template
  label:      string;   // human-readable label, 1120 chars
  kind:       'NUMERIC';
  field:      string;   // key in the snapshot values map, 164 chars
  unit?:      string | null;  // display unit (e.g. "kW", "Hz", "%"), max 16 chars
  aggregate?: 'SUM' | 'AVG' | 'MIN' | 'MAX' | 'LAST' | null;
  store?:     'STATE_CHANGE' | 'TICK'; // default: TICK
  featured?:  boolean;
  chartGroup?: string;                  // Live tab chart panel group
  chartAxis?:  'left' | 'right';         // default: left
}

Storage model

The store field controls when a signal value is written to the TimescaleDB hypertable.

Binary signals — state-change storage

Binary signals default to store: STATE_CHANGE. SubDeviceTelemetryService maintains an in-process binaryState cache keyed by ${subDeviceId}:${signalKey}. On each snapshot ingest:

  1. Decode the bit value from the raw integer.
  2. Look up the cached previous value.
  3. If the value is the same as last time: skip — write nothing.
  4. If the value has changed (or is not yet in cache): update cache, write one row.

This produces a clean event timeline — the hypertable records only state transitions, not a constant heartbeat. It significantly reduces write volume for stable systems where most bits hold their value across ticks.

Cold-start caveat: The binaryState cache starts empty on every API restart. The first snapshot after restart emits one row per currently-set bit (none are in cache yet, so every bit appears to have "changed"). Subsequent snapshots return to normal state-change-only writes.

Single-instance note: The cache is in-process memory. In a multi-instance deployment, each instance maintains its own cache independently, which can cause duplicate writes around startup or failover. The current implementation is scoped to single-instance operation; a Redis-backed cache would be required for strong deduplication guarantees.

Numeric signals — tick storage

Numeric signals default to store: TICK. A row is written on every snapshot that includes the signal's field, regardless of whether the value has changed. Downsampling is handled at query time via TimescaleDB continuous aggregates:

CAGG tierWindowQuery range
Raw hypertablePer-tick, queried as 1-minute buckets≤ 2 hours
telemetry_5m5-minute> 2 hours and ≤ 24 hours
telemetry_1h1-hour> 24 hours and ≤ 7 days
telemetry_1d1-day> 7 days

The query layer (TelemetryService.querySubDevice) selects the appropriate tier automatically based on the requested time window. See Data retention for retention policies and CAGG refresh intervals.


featured: boolean (optional, default false) marks a signal for surfacing in the default admin UI. It controls display only:

  • Featured signals appear in the compact Devices row strip without requiring the operator to open the full detail dialog.
  • If a template has no featured signals, the Devices row falls back to the first five numeric signals so the row is not empty.
  • Non-featured signals are still decoded and persisted on every tick — the flag has no effect on storage.
  • The flag has no effect on ESM forwarding. All decoded signals are available for export regardless of featured status.

Live chart grouping

Numeric signals can optionally include:

{
  chartGroup?: string;          // 1-64 chars
  chartAxis?: 'left' | 'right'; // default: left
}

The plant Live tab iterates each sub-device's assigned template and renders one chart panel per chartGroup. Signals without chartGroup are still chartable; the UI groups them by unit, then falls back to Other for unitless signals. Binary signals are not charted.

Use chartAxis: 'right' when a group combines units with very different ranges, for example voltage on the left axis and current on the right axis.


Full signal schema

Both variants in one place, as they appear in packages/shared/src/schemas/sub-device.ts:

import { z } from 'zod';
import {
  SubDeviceSignalKind,
  SubDeviceSignalStore,
  SubDeviceAggregate,
  SubDeviceAlertOn,
} from '@cpi/shared/types/enums';

const BinarySignalSchema = z.object({
  key:      z.string().min(1).max(64).regex(/^[a-z0-9_]+$/),
  label:    z.string().min(1).max(120),
  kind:     z.literal(SubDeviceSignalKind.BINARY),
  bit:      z.number().int().min(0).max(63),
  alertOn:  z.nativeEnum(SubDeviceAlertOn).nullable().optional(),
  store:    z.nativeEnum(SubDeviceSignalStore).optional()
              .default(SubDeviceSignalStore.STATE_CHANGE),
  featured: z.boolean().optional(),
});

const NumericSignalSchema = z.object({
  key:       z.string().min(1).max(64).regex(/^[a-z0-9_]+$/),
  label:     z.string().min(1).max(120),
  kind:      z.literal(SubDeviceSignalKind.NUMERIC),
  field:     z.string().min(1).max(64),
  unit:      z.string().max(16).nullable().optional(),
  aggregate: z.nativeEnum(SubDeviceAggregate).nullable().optional(),
  store:     z.nativeEnum(SubDeviceSignalStore).optional()
               .default(SubDeviceSignalStore.TICK),
  featured:  z.boolean().optional(),
  chartGroup: z.string().min(1).max(64).optional(),
  chartAxis:  z.enum(['left', 'right']).optional(),
});

export const SubDeviceSignalSchema = z.discriminatedUnion('kind', [
  BinarySignalSchema,
  NumericSignalSchema,
]);

The SubDeviceSignalsSchema wraps the array with cross-field uniqueness checks: duplicate key values across any two signals in the same template are rejected, and duplicate bit positions across binary signals in the same template are rejected.


Example: cabinet and meter templates

A cabinet template might define six binary signals and no numeric signals. A meter template defines only numeric signals. An inverter template might define both — binary alarm flags alongside numeric power and frequency readings.

{
  "name": "Main Cabinet v1",
  "type": "CABINET",
  "level": "SUB_DEVICE",
  "signals": [
    { "kind": "BINARY", "key": "main_contactor", "label": "Main contactor",  "bit": 0, "featured": true  },
    { "kind": "BINARY", "key": "alarm_active",   "label": "Alarm active",    "bit": 1, "alertOn": "ON", "featured": true  },
    { "kind": "BINARY", "key": "door_open",      "label": "Cabinet door",    "bit": 7 }
  ],
  "actions": [
    { "key": "open_contactor",  "label": "Open main contactor",  "params": [] },
    { "key": "close_contactor", "label": "Close main contactor", "params": [] }
  ]
}
{
  "name": "Energy Meter v1",
  "type": "METER",
  "level": "SUB_DEVICE",
  "signals": [
    { "kind": "NUMERIC", "key": "active_power_kw",    "label": "Active power",    "field": "activePowerKw",    "unit": "kW",  "featured": true, "chartGroup": "Power" },
    { "kind": "NUMERIC", "key": "reactive_power_kvar", "label": "Reactive power",  "field": "reactivePowerKvar","unit": "kVAr" },
    { "kind": "NUMERIC", "key": "grid_frequency_hz",  "label": "Grid frequency",   "field": "gridFrequencyHz",  "unit": "Hz",  "aggregate": "AVG", "chartGroup": "Grid quality", "chartAxis": "right" }
  ],
  "actions": []
}

See also

On this page