Compare commits

...

28 Commits

Author SHA1 Message Date
Pascal Bourque
d9d6fc2861 Merge branch 'main' into 180-rapid-succession-of-aws_error_mqtt_unexpected_hangup-errors 2025-11-28 09:19:03 -05:00
dependabot[bot]
85c123d2aa chore(deps): Bump @aws-sdk/credential-providers from 3.927.0 to 3.936.0 (#189) 2025-11-28 14:16:33 +00:00
dependabot[bot]
0524bcea73 chore(deps-dev): Bump pino-pretty from 13.0.0 to 13.1.2 (#190) 2025-11-28 14:15:48 +00:00
dependabot[bot]
bb10ba4616 chore(deps-dev): Bump pino from 9.13.0 to 10.1.0 (#191) 2025-11-28 14:13:00 +00:00
dependabot[bot]
5366ea6fc9 chore(deps): Bump @aws-sdk/client-iot from 3.920.0 to 3.936.0 (#192) 2025-11-28 14:12:56 +00:00
dependabot[bot]
baa7941cfc chore(deps): Bump amazon-cognito-identity-js from 6.3.15 to 6.3.16 (#193) 2025-11-28 14:12:49 +00:00
Pascal Bourque
ef60db37d5 fix: Unable to automatically reconnect when credentials have expired (#194) 2025-11-28 09:06:54 -05:00
Pascal Bourque
1b680b693f Example: process devices serially instead of in parallel 2025-11-28 08:43:23 -05:00
Pascal Bourque
181c9238da Merge branch 'main' into 180-rapid-succession-of-aws_error_mqtt_unexpected_hangup-errors 2025-11-23 10:35:14 -05:00
dependabot[bot]
f1525cd1f1 chore(deps-dev): Bump the dev-dependencies group across 1 directory with 7 updates (#188) 2025-11-23 15:34:54 +00:00
Pascal Bourque
6c24d59760 Added option to enable AWS CRT debug logging 2025-11-23 10:29:41 -05:00
dependabot[bot]
3b2a020ac7 chore(deps): Bump aws-iot-device-sdk-v2 from 1.22.0 to 1.23.1 (#182) 2025-11-23 14:59:39 +00:00
dependabot[bot]
cbac285b1e chore(deps-dev): Bump @eslint/js from 9.38.0 to 9.39.1 (#183) 2025-11-23 14:57:01 +00:00
dependabot[bot]
ca127483c1 chore(deps): Bump @aws-sdk/credential-providers from 3.922.0 to 3.927.0 (#184) 2025-11-23 14:56:56 +00:00
dependabot[bot]
e320d658e8 chore(deps): Bump dayjs from 1.11.18 to 1.11.19 (#185) 2025-11-23 14:56:52 +00:00
dependabot[bot]
c8dac38563 chore(deps-dev): Bump js-yaml from 4.1.0 to 4.1.1 (#187) 2025-11-23 14:56:24 +00:00
Pascal Bourque
f7c3dc07b3 Recreate MQTT client on interrupt when credentials have expired 2025-11-23 09:53:45 -05:00
Pascal Bourque
2a2a843534 Increased ping timeout 2025-11-16 10:53:35 -05:00
Pascal Bourque
2d49a4ddb9 Hash username in MQTT client ID generation
Replaces the plain username in the MQTT client ID with a SHA-1 hash for improved privacy and to avoid exposing usernames in client identifiers.
2025-11-09 10:57:45 -05:00
Pascal Bourque
5fce04543a Improve MQTT clientId handling and interrupt recovery
Adds process PID to MQTT clientId to reduce collision risk and enhances logging with clientId context. Implements a guard against re-entrant MQTT resets during interrupt storms, clears connection promise before disconnect, and ensures device subscriptions are re-established after recovery. Also adds handling for new error type and improves error reporting.
2025-11-09 10:52:31 -05:00
Pascal Bourque
94acdede23 fix: Prevent AWS_ERROR_MQTT_UNEXPECTED_HANGUP connection interruptions (#179)
By using a stable, unique per-process client identifier.

Also:

- Configured MQTT auto-reconnect on interruption
- Reset connection on high MQTT connection interruption rate
2025-11-08 15:12:30 -05:00
dependabot[bot]
d007c2d745 chore(deps-dev): Bump typedoc from 0.28.13 to 0.28.14 (#174) 2025-11-03 11:51:43 +00:00
dependabot[bot]
5d9981f9e0 chore(deps): Bump @aws-sdk/credential-providers from 3.901.0 to 3.922.0 (#173) 2025-11-03 11:49:06 +00:00
dependabot[bot]
2f2cdef0ee chore(deps-dev): Bump typedoc-material-theme from 1.4.0 to 1.4.1 (#175) 2025-11-03 11:48:50 +00:00
dependabot[bot]
193f67226b chore(deps-dev): Bump typescript-eslint from 8.41.0 to 8.46.2 (#176) 2025-11-03 11:48:46 +00:00
dependabot[bot]
ef8d787e05 chore(deps-dev): Bump the dev-dependencies group across 1 directory with 2 updates (#177) 2025-11-03 11:48:39 +00:00
Pascal Bourque
0c71ed95ce chore: Changed dependabot schedule from daily to weekly (#171) 2025-11-02 12:10:05 -05:00
Pascal Bourque
d861a50136 fix!: Device and state properties are now optional (#170)
Updated DeviceBase, BrandInfo, and DeviceState interfaces to make most properties optional, improving flexibility for partial objects and better handling of missing data.
2025-11-01 09:33:31 -04:00
8 changed files with 1523 additions and 1106 deletions

View File

@@ -8,7 +8,7 @@ updates:
- package-ecosystem: 'npm' # See documentation for possible values
directory: '/' # Location of package manifests
schedule:
interval: 'daily'
interval: 'weekly'
labels:
- 'dependencies'
commit-message:

View File

@@ -20,6 +20,7 @@ const rootLogger = pino({
/** Main entry point of the example application. */
async function main() {
let session: MysaSession | undefined;
try {
rootLogger.info('Loading session...');
const sessionJson = await readFile('session.json', 'utf8');
@@ -27,7 +28,11 @@ async function main() {
} catch {
rootLogger.info('No valid session file found.');
}
const client = new MysaApiClient(session, { logger: rootLogger.child({ module: 'mysa-js-sdk' }) });
const client = new MysaApiClient(session, {
logger: rootLogger.child({ module: 'mysa-js-sdk' }),
isAwsCrtDebugLoggingEnabled: process.env.AWS_CRT_DEBUG_LOGGING === '1'
});
client.emitter.on('sessionChanged', async (newSession) => {
if (newSession) {
@@ -65,9 +70,10 @@ async function main() {
client.emitter.on('statusChanged', (status) => {
try {
const device = devices.DevicesObj[status.deviceId];
const watts = status.current !== undefined ? status.current * device.Voltage : undefined;
const watts =
status.current !== undefined && device.Voltage !== undefined ? status.current * device.Voltage : undefined;
rootLogger.info(
`[${status.deviceId}] '${device.Name}' status changed: ${status.temperature}°C, ${status.humidity}%, ${watts ?? 'na'}W`
`[${status.deviceId}] '${device.Name ?? 'Unknown'}' status changed: ${status.temperature}°C, ${status.humidity}%, ${watts ?? 'na'}W`
);
} catch (error) {
rootLogger.error(error, `Error processing status update for device '${status.deviceId}'`);
@@ -77,7 +83,9 @@ async function main() {
client.emitter.on('setPointChanged', (change) => {
try {
const device = devices.DevicesObj[change.deviceId];
rootLogger.info(`'${device.Name}' setpoint changed from ${change.previousSetPoint} to ${change.newSetPoint}`);
rootLogger.info(
`'${device.Name ?? 'Unknown'}' setpoint changed from ${change.previousSetPoint} to ${change.newSetPoint}`
);
} catch (error) {
rootLogger.error(error, `Error processing setpoint update for device '${change.deviceId}'`);
}
@@ -86,21 +94,19 @@ async function main() {
client.emitter.on('stateChanged', (change) => {
try {
const device = devices.DevicesObj[change.deviceId];
rootLogger.info(change, `'${device.Name}' state changed.`);
rootLogger.info(change, `'${device.Name ?? 'Unknown'}' state changed.`);
} catch (error) {
rootLogger.error(error, `Error processing state update for device '${change.deviceId}'`);
}
});
}
await Promise.all(
Object.entries(devices.DevicesObj).map(async ([deviceId, device]) => {
const serial = await client.getDeviceSerialNumber(deviceId);
rootLogger.info(`Serial number for device '${deviceId}' (${device.Name}): ${serial}`);
for (const [deviceId, device] of Object.entries(devices.DevicesObj)) {
const serial = await client.getDeviceSerialNumber(deviceId);
rootLogger.info(`Serial number for device '${deviceId}' (${device.Name ?? 'Unknown'}): ${serial}`);
await client.startRealtimeUpdates(deviceId);
})
);
await client.startRealtimeUpdates(deviceId);
}
}
main().catch((error) => {

2323
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -50,34 +50,35 @@
"brace-expansion": "^2.0.2"
},
"dependencies": {
"@aws-sdk/client-iot": "3.920.0",
"@aws-sdk/credential-providers": "3.901.0",
"amazon-cognito-identity-js": "6.3.15",
"aws-iot-device-sdk-v2": "1.22.0",
"dayjs": "1.11.18",
"lodash": "4.17.21"
"@aws-sdk/client-iot": "3.936.0",
"@aws-sdk/credential-providers": "3.940.0",
"amazon-cognito-identity-js": "6.3.16",
"aws-iot-device-sdk-v2": "1.23.1",
"dayjs": "1.11.19",
"lodash": "4.17.21",
"nanoid": "5.1.6"
},
"devDependencies": {
"@eslint/js": "9.38.0",
"@semantic-release/npm": "13.1.1",
"@types/lodash": "4.17.20",
"@types/node": "24.9.2",
"@eslint/js": "9.39.1",
"@semantic-release/npm": "13.1.2",
"@types/lodash": "4.17.21",
"@types/node": "24.10.1",
"conventional-changelog-conventionalcommits": "9.1.0",
"dotenv": "17.2.3",
"eslint": "9.38.0",
"eslint-plugin-jsdoc": "61.1.11",
"eslint-plugin-tsdoc": "0.4.0",
"pino": "9.13.0",
"pino-pretty": "13.0.0",
"eslint": "9.39.1",
"eslint-plugin-jsdoc": "61.4.1",
"eslint-plugin-tsdoc": "0.5.0",
"pino": "10.1.0",
"pino-pretty": "13.1.2",
"prettier": "3.6.2",
"prettier-plugin-jsdoc": "1.5.0",
"prettier-plugin-organize-imports": "4.3.0",
"semantic-release": "25.0.1",
"semantic-release": "25.0.2",
"tsup": "8.5.0",
"tsx": "4.20.6",
"typedoc": "0.28.13",
"typedoc-material-theme": "1.4.0",
"typedoc": "0.28.14",
"typedoc-material-theme": "1.4.1",
"typescript": "5.9.3",
"typescript-eslint": "8.41.0"
"typescript-eslint": "8.46.2"
}
}

View File

@@ -18,9 +18,11 @@ import {
CognitoUserPool,
CognitoUserSession
} from 'amazon-cognito-identity-js';
import { iot, mqtt } from 'aws-iot-device-sdk-v2';
import dayjs from 'dayjs';
import { io, iot, mqtt } from 'aws-iot-device-sdk-v2';
import { hash } from 'crypto';
import dayjs, { Dayjs } from 'dayjs';
import duration from 'dayjs/plugin/duration.js';
import { customAlphabet } from 'nanoid';
import { MqttPublishError, MysaApiError, UnauthenticatedError } from './Errors';
import { Logger, VoidLogger } from './Logger';
import { MysaApiClientEventTypes } from './MysaApiClientEventTypes';
@@ -29,6 +31,8 @@ import { MysaDeviceMode, MysaFanSpeedMode } from './MysaDeviceMode';
dayjs.extend(duration);
const getRandomClientId = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz', 8);
/** Options for MQTT publish operations. */
export interface MqttPublishOptions {
/** Maximum number of publish attempts before failing (default: 5). */
@@ -86,6 +90,18 @@ export class MysaApiClient {
/** A promise that resolves to the MQTT connection used for real-time updates. */
private _mqttConnectionPromise?: Promise<mqtt.MqttClientConnection>;
/** Stable per-process MQTT client id (prevents collisions between multiple processes). */
private _mqttClientId?: string;
/** Expiration time of the credentials currently in use by the MQTT client. */
private _mqttCredentialsExpiration?: Dayjs;
/** Interrupt timestamps for storm / collision detection. */
private _mqttInterrupts: number[] = [];
/** Whether a forced MQTT reset is currently in progress (guards against re-entrancy). */
private _mqttResetInProgress = false;
/** The device IDs that are currently being updated in real-time, mapped to their respective timeouts. */
private _realtimeDeviceIds: Map<string, NodeJS.Timeout> = new Map();
@@ -136,6 +152,10 @@ export class MysaApiClient {
this._logger = options?.logger || new VoidLogger();
this._fetcher = options?.fetcher || fetch;
if (options?.isAwsCrtDebugLoggingEnabled) {
io.enable_logging(io.LogLevel.DEBUG);
}
if (session) {
this._cognitoUser = new CognitoUser({
Username: session.username,
@@ -173,6 +193,9 @@ export class MysaApiClient {
async login(emailAddress: string, password: string): Promise<void> {
this._cognitoUser = undefined;
this._cognitoUserSession = undefined;
this._mqttClientId = undefined;
this._mqttInterrupts = [];
this.emitter.emit('sessionChanged', this.session);
return new Promise((resolve, reject) => {
@@ -481,13 +504,15 @@ export class MysaApiClient {
const timer = setInterval(async () => {
this._logger.debug(`Sending request to keep-alive publishing device status for '${deviceId}'...`);
const connection = await this._getMqttConnection();
const payload = serializeMqttPayload<StartPublishingDeviceStatus>({
Device: deviceId,
MsgType: InMessageType.START_PUBLISHING_DEVICE_STATUS,
Timestamp: dayjs().unix(),
Timeout: RealtimeKeepAliveInterval.asSeconds()
});
await this._publishWithRetry(mqttConnection, `/v1/dev/${deviceId}/in`, payload, mqtt.QoS.AtLeastOnce);
await this._publishWithRetry(connection, `/v1/dev/${deviceId}/in`, payload, mqtt.QoS.AtLeastOnce);
}, RealtimeKeepAliveInterval.subtract(10, 'seconds').asMilliseconds());
this._realtimeDeviceIds.set(deviceId, timer);
@@ -601,6 +626,9 @@ export class MysaApiClient {
const transientMarkers = [
'AWS_ERROR_MQTT_TIMEOUT',
'AWS_ERROR_MQTT_NO_CONNECTION',
'AWS_ERROR_MQTT_UNEXPECTED_HANGUP',
'UNEXPECTED_HANGUP',
'AWS_ERROR_MQTT_CONNECTION_DESTROYED',
'Time limit between request and response',
'timeout'
];
@@ -691,40 +719,119 @@ export class MysaApiClient {
});
const credentials = await credentialsProvider();
// Stable client id + persistent session to retain QoS1 queue & subscriptions across reconnects.
const stableClientId = `mysa-js-sdk-${this.session?.username ?? ''}`;
if (!credentials.expiration) {
throw new Error('MQTT credentials do not have an expiration time.');
}
this._mqttCredentialsExpiration = dayjs(credentials.expiration);
this._logger.debug(`MQTT credentials expiration: ${this._mqttCredentialsExpiration.format()}`);
if (!this._mqttCredentialsExpiration.isAfter(dayjs())) {
this._mqttCredentialsExpiration = undefined;
throw new Error('MQTT credentials are already expired.');
}
// Per-process stable client id. Random suffix avoids collisions with other running processes.
if (!this._mqttClientId) {
const username = this.session?.username ?? 'anon';
const usernameHash = hash('sha1', username);
this._mqttClientId = `mysa-js-sdk-${usernameHash}-${process.pid}-${getRandomClientId()}`;
}
const builder = iot.AwsIotMqttConnectionConfigBuilder.new_with_websockets()
.with_credentials(AwsRegion, credentials.accessKeyId, credentials.secretAccessKey, credentials.sessionToken)
.with_endpoint(MqttEndpoint)
.with_client_id(stableClientId)
.with_client_id(this._mqttClientId)
.with_clean_session(false)
.with_keep_alive_seconds(30)
.with_ping_timeout_ms(3000)
.with_protocol_operation_timeout_ms(60000);
.with_ping_timeout_ms(10000)
.with_protocol_operation_timeout_ms(60000)
.with_reconnect_min_sec(1)
.with_reconnect_max_sec(30);
const config = builder.build();
const client = new mqtt.MqttClient();
const connection = client.new_connection(config);
connection.on('connect', () => {
this._logger.debug('MQTT connect');
this._logger.debug(`MQTT connect (clientId=${this._mqttClientId})`);
});
connection.on('connection_success', () => {
this._logger.debug('MQTT connection_success');
this._logger.debug(`MQTT connection_success (clientId=${this._mqttClientId})`);
});
connection.on('connection_failure', (e) => {
this._logger.error('MQTT connection_failure', e);
this._logger.error(`MQTT connection_failure (clientId=${this._mqttClientId})`, e);
});
connection.on('interrupt', (e) => {
this._logger.warn('MQTT interrupt', e);
connection.on('interrupt', async (e) => {
this._logger.warn(`MQTT interrupt (clientId=${this._mqttClientId})`, e);
// Track recent interrupts
const now = Date.now();
// Keep only last 60s
this._mqttInterrupts = this._mqttInterrupts.filter((t) => now - t < 60000);
this._mqttInterrupts.push(now);
const areCredentialsExpired = !(this._mqttCredentialsExpiration?.isAfter(dayjs()) ?? false);
if ((this._mqttInterrupts.length > 5 || areCredentialsExpired) && !this._mqttResetInProgress) {
this._mqttResetInProgress = true;
if (this._mqttInterrupts.length > 5) {
this._logger.warn(
`High interrupt rate (${this._mqttInterrupts.length}/60s). Possible clientId collision. Regenerating clientId and resetting connection...`
);
} else {
this._logger.warn(`Credentials expired. Regenerating clientId and resetting connection...`);
}
// Force new client id to escape collision; close current connection
this._mqttClientId = undefined;
this._mqttCredentialsExpiration = undefined;
// Clear interrupts
this._mqttInterrupts = [];
// Explicitly clear promise first to prevent reuse while disconnecting
// (publishers calling _getMqttConnection() will create a new one)
this._mqttConnectionPromise = undefined;
try {
await connection.disconnect();
try {
this._logger.debug('Old MQTT connection disconnected; establishing new connection...');
const newConnection = await this._getMqttConnection();
for (const deviceId of Array.from(this._realtimeDeviceIds.keys())) {
const topic = `/v1/dev/${deviceId}/out`;
this._logger.debug(`Re-subscribing to ${topic}`);
await newConnection.subscribe(topic, mqtt.QoS.AtLeastOnce, (_topic, payload) => {
this._processMqttMessage(payload);
});
}
this._logger.info('MQTT connection rebuilt successfully after interrupt storm or credentials expiration');
} catch (err) {
this._logger.error('Failed to re-subscribe after interrupt storm or credentials expiration', err);
}
} catch (error) {
this._logger.error('Error during MQTT reset', error);
} finally {
this._mqttResetInProgress = false;
}
}
});
connection.on('resume', async (returnCode, sessionPresent) => {
this._logger.info(`MQTT resume returnCode=${returnCode} sessionPresent=${sessionPresent}`);
this._logger.info(
`MQTT resume returnCode=${returnCode} sessionPresent=${sessionPresent} clientId=${this._mqttClientId}`
);
if (!sessionPresent) {
this._logger.info('No session present, re-subscribing each device');
@@ -743,12 +850,13 @@ export class MysaApiClient {
});
connection.on('error', (e) => {
this._logger.error('MQTT error', e);
this._logger.error(`MQTT error (clientId=${this._mqttClientId})`, e);
});
connection.on('closed', () => {
this._logger.info('MQTT connection closed');
this._mqttConnectionPromise = undefined;
this._mqttCredentialsExpiration = undefined;
});
await connection.connect();

View File

@@ -15,4 +15,11 @@ export interface MysaApiClientOptions {
* @defaultValue The global `fetch` function.
*/
fetcher?: typeof fetch;
/**
* Whether to enable debug logging for AWS CRT.
*
* @defaultValue `false`
*/
isAwsCrtDebugLoggingEnabled?: boolean;
}

View File

@@ -10,9 +10,9 @@ export interface BrandInfo {
/** Unique identifier for the brand */
Id: number;
/** Remote control model number for the AC device */
remoteModelNumber: string;
remoteModelNumber?: string;
/** Original Equipment Manufacturer brand name */
OEMBrand: string;
OEMBrand?: string;
}
/**
@@ -56,55 +56,55 @@ export interface ModeObj {
*/
export interface DeviceBase {
/** Button digital input configuration value */
ButtonDI: number;
ButtonDI?: number;
/** Maximum current rating as a string value */
MaxCurrent: string;
MaxCurrent?: string;
/** Device model identifier string */
Model: string;
/** Button average value configuration */
ButtonAVE: number;
ButtonAVE?: number;
/** Operating voltage of the device */
Voltage: number;
Voltage?: number;
/** Button polling interval configuration */
ButtonPolling: number;
ButtonPolling?: number;
/** Minimum brightness level (0-100) */
MinBrightness: number;
MinBrightness?: number;
/** User-assigned device name */
Name: string;
Name?: string;
/** Button low power mode configuration */
ButtonLowPower: number;
ButtonLowPower?: number;
/** Type of heater controlled by the device */
HeaterType: string;
HeaterType?: string;
/** Button repeat delay configuration in milliseconds */
ButtonRepeatDelay: number;
ButtonRepeatDelay?: number;
/** Button repeat start delay configuration in milliseconds */
ButtonRepeatStart: number;
ButtonRepeatStart?: number;
/** Display animation style setting */
Animation: string;
Animation?: string;
/** Maximum brightness level (0-100) */
MaxBrightness: number;
MaxBrightness?: number;
/** Array of user IDs allowed to control this device */
AllowedUsers: string[];
AllowedUsers?: string[];
/** Current button state indicator */
ButtonState: string;
ButtonState?: string;
/** Home identifier that this device belongs to */
Home: string;
Home?: string;
/** Button sensitivity threshold configuration */
ButtonThreshold: number;
ButtonThreshold?: number;
/** Data format version used by the device */
Format: string;
Format?: string;
/** Time zone setting for the device */
TimeZone: string;
TimeZone?: string;
/** Unix timestamp of when device was last paired */
LastPaired: number;
LastPaired?: number;
/** Minimum temperature setpoint allowed */
MinSetpoint: number;
MinSetpoint?: number;
/** Current operating mode of the device */
Mode: ModeObj;
Mode?: ModeObj;
/** User ID of the device owner */
Owner: string;
Owner?: string;
/** Maximum temperature setpoint allowed */
MaxSetpoint: number;
MaxSetpoint?: number;
/** Unique device identifier */
Id: string;
/** Optional zone assignment for the device */

View File

@@ -13,43 +13,43 @@ export interface DeviceState {
/** Overall timestamp for the device state */
Timestamp: number;
/** Time the device has been on */
OnTime: TimestampedValue<number>;
OnTime?: TimestampedValue<number>;
/** Temperature set point */
SetPoint: TimestampedValue<number>;
SetPoint?: TimestampedValue<number>;
/** Display brightness level */
Brightness: TimestampedValue<number>;
Brightness?: TimestampedValue<number>;
/** Schedule mode setting */
ScheduleMode: TimestampedValue<number>;
ScheduleMode?: TimestampedValue<number>;
/** Hold time setting */
HoldTime: TimestampedValue<number>;
HoldTime?: TimestampedValue<number>;
/** Wi-Fi signal strength */
Rssi: TimestampedValue<number>;
Rssi?: TimestampedValue<number>;
/** Thermostat mode */
TstatMode: TimestampedValue<number>;
TstatMode?: TimestampedValue<number>;
/** Available heap memory */
FreeHeap: TimestampedValue<number>;
FreeHeap?: TimestampedValue<number>;
/** Sensor temperature reading */
SensorTemp: TimestampedValue<number>;
SensorTemp?: TimestampedValue<number>;
/** Current mode */
Mode: TimestampedValue<number>;
Mode?: TimestampedValue<number>;
/** Voltage measurement */
Voltage: TimestampedValue<number>;
Voltage?: TimestampedValue<number>;
/** Temperature corrected for calibration */
CorrectedTemp: TimestampedValue<number>;
CorrectedTemp?: TimestampedValue<number>;
/** Duty cycle percentage */
Duty: TimestampedValue<number>;
Duty?: TimestampedValue<number>;
/** Heat sink temperature */
HeatSink: TimestampedValue<number>;
HeatSink?: TimestampedValue<number>;
/** Time the device has been off */
OffTime: TimestampedValue<number>;
OffTime?: TimestampedValue<number>;
/** Connection status */
Connected: TimestampedValue<boolean>;
Connected?: TimestampedValue<boolean>;
/** Current consumption */
Current: TimestampedValue<number>;
Current?: TimestampedValue<number>;
/** Humidity reading */
Humidity: TimestampedValue<number>;
Humidity?: TimestampedValue<number>;
/** Lock status */
Lock: TimestampedValue<number>;
Lock?: TimestampedValue<number>;
/** Fan speed */
FanSpeed?: TimestampedValue<number>;
}