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, 1–64 chars, unique within template
label: string; // human-readable label, 1–120 chars
kind: 'BINARY';
bit: number; // integer 0–63; 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, 1–64 chars, unique within template
label: string; // human-readable label, 1–120 chars
kind: 'NUMERIC';
field: string; // key in the snapshot values map, 1–64 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:
- Decode the bit value from the
rawinteger. - Look up the cached previous value.
- If the value is the same as last time: skip — write nothing.
- 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 tier | Window | Query range |
|---|---|---|
| Raw hypertable | Per-tick, queried as 1-minute buckets | ≤ 2 hours |
telemetry_5m | 5-minute | > 2 hours and ≤ 24 hours |
telemetry_1h | 1-hour | > 24 hours and ≤ 7 days |
telemetry_1d | 1-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.
The featured flag
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
featuredstatus.
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
- PLC / Snapshot envelope — the wire format that carries
rawandvaluesper sub-device entry - Data retention — CAGG tiers, refresh intervals, and retention windows
- Commands & alerts — the
actions[]counterpart on templates; howalertOntriggers alerts - Device templates — the template that owns the signals array
Device templates
A device template is a global platform catalogue entry that tells Voke how to decode device wire data into named signals and actions.
Commands & alerts
Commands flow from Voke (or an ESM partner) down to a plant over MQTT and return an ACK. Alerts are Voke-generated events produced when telemetry breaks a threshold rule. Alarms are a separate, PLC-originated mechanism.