From b5ec00a9771abe3ff737c8fcaa880c7a545ecdb6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 08:39:32 +0000 Subject: [PATCH 1/9] Initial plan From cc6f4cf7c4b37965142a3886b4b511b2b01d20c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 08:45:39 +0000 Subject: [PATCH 2/9] fix: decode special log records in ReadRange without status flags Special log records (log-interrupted, buffer-purged, log-disabled) use the log-status choice which does NOT include status flags. The previous code unconditionally consumed the status flags bytes, causing it to read into subsequent records and lose sync. This fix: - Validates closing tag 1 before consuming it - Checks for context tag 2 (status flags) presence before decoding - Adds support for log-status choice (context tag 0) - Adds isLogStatus and logStatus fields to differentiate special records - Adds comprehensive test for mixed normal and special log records Co-authored-by: robertsLando <11502495+robertsLando@users.noreply.github.com> --- src/lib/asn1.ts | 98 +++++++++++++++++++----- src/lib/services/WriteProperty.ts | 8 +- test/unit/service-read-range.spec.ts | 109 +++++++++++++++++++++++++++ 3 files changed, 194 insertions(+), 21 deletions(-) diff --git a/src/lib/asn1.ts b/src/lib/asn1.ts index eb50119e..49bb4f50 100644 --- a/src/lib/asn1.ts +++ b/src/lib/asn1.ts @@ -2475,10 +2475,30 @@ export const decodeRange = ( len += 2 // value payload (context specific) + // This is the log-datum CHOICE with options: + // [0] log-status (BACnetLogStatus bitstring - special records) + // [1] boolean-value + // [2] real-value + // [3] enum-value + // [4] unsigned-value + // ... and more per ASHRAE 135 tag = decodeTagNumberAndValue(buffer, offset + len) len += tag.len let value: ApplicationData | undefined - if (tag.tagNumber === 2 && tag.value === 4) { + let isLogStatus = false + if (tag.tagNumber === 0) { + // log-status choice: BACnetLogStatus bitstring + // These are special log records (log-disabled, buffer-purged, log-interrupted) + // They do NOT have status flags after the log-datum + value = bacappDecodeData( + buffer, + offset + len, + maxOffset, + ApplicationTag.BIT_STRING, + tag.value, + ) + isLogStatus = true + } else if (tag.tagNumber === 2 && tag.value === 4) { value = bacappDecodeData( buffer, offset + len, @@ -2506,26 +2526,34 @@ export const decodeRange = ( if (!value) return undefined len += value.len - // closing tag 1 + opening tag 2 - tag = decodeTagNumberAndValue(buffer, offset + len) - len += tag.len + // closing tag 1 is required + if (!decodeIsClosingTagNumber(buffer, offset + len, 1)) { + return undefined + } tag = decodeTagNumberAndValue(buffer, offset + len) len += tag.len - // status flags - const status = bacappDecodeData( - buffer, - offset + len, - maxOffset, - ApplicationTag.BIT_STRING, - tag.value, - ) - if (!status) return undefined - len += status.len + // context tag 2 (status flags) is optional for regular log records. + // Special log records (log-status choice) do NOT have status flags. + let status: ApplicationData | undefined + if (!isLogStatus && decodeIsContextTag(buffer, offset + len, 2)) { + tag = decodeTagNumberAndValue(buffer, offset + len) + len += tag.len + + // status flags bitstring + status = bacappDecodeData( + buffer, + offset + len, + maxOffset, + ApplicationTag.BIT_STRING, + tag.value, + ) + if (!status) return undefined + len += status.len + } const d = date.value as Date const t = time.value as Date - const statusBits = status.value as { value: number[] } const timestamp = new Date( d.getFullYear(), d.getMonth(), @@ -2535,16 +2563,48 @@ export const decodeRange = ( t.getSeconds(), t.getMilliseconds(), ) - result.push({ + + const record: { + timestamp: Date + value: unknown + isLogStatus?: boolean + logStatus?: { + log_disabled: boolean + buffer_purged: boolean + log_interrupted: boolean + } + status?: { + out_of_service: boolean + overridden: boolean + fault: boolean + in_alarm: boolean + } + } = { timestamp, value: value.value, - status: { + } + + if (isLogStatus) { + record.isLogStatus = true + const logStatusBits = value.value as { value: number[] } + record.logStatus = { + log_disabled: ((logStatusBits.value[0] >> 0) & 1) !== 0, + buffer_purged: ((logStatusBits.value[0] >> 1) & 1) !== 0, + log_interrupted: ((logStatusBits.value[0] >> 2) & 1) !== 0, + } + } + + if (status) { + const statusBits = status.value as { value: number[] } + record.status = { out_of_service: ((statusBits.value[0] >> 0) & 1) !== 0, overridden: ((statusBits.value[0] >> 1) & 1) !== 0, fault: ((statusBits.value[0] >> 2) & 1) !== 0, in_alarm: ((statusBits.value[0] >> 3) & 1) !== 0, - }, - }) + } + } + + result.push(record) } if (offset + len >= maxOffset) return undefined diff --git a/src/lib/services/WriteProperty.ts b/src/lib/services/WriteProperty.ts index e6c1dd2d..8f94d08d 100644 --- a/src/lib/services/WriteProperty.ts +++ b/src/lib/services/WriteProperty.ts @@ -108,7 +108,10 @@ export default class WriteProperty extends BacnetService { ) } - private static writeDateBytes(buffer: EncodeBuffer, value: BACNetDateValue) { + private static writeDateBytes( + buffer: EncodeBuffer, + value: BACNetDateValue, + ) { if (WriteProperty.isRawDate(value)) { WriteProperty.validateRawDateByte('year', value.year, 0, 255) if (value.month !== 0xff) { @@ -170,7 +173,8 @@ export default class WriteProperty extends BacnetService { if (timeValue == null) { throw new Error(`${errorPrefix} time is required`) } - const normalized = timeValue instanceof Date ? timeValue : new Date(timeValue) + const normalized = + timeValue instanceof Date ? timeValue : new Date(timeValue) if (Number.isNaN(normalized.getTime())) { throw new Error(`${errorPrefix} time is invalid`) } diff --git a/test/unit/service-read-range.spec.ts b/test/unit/service-read-range.spec.ts index 0255246a..803d115c 100644 --- a/test/unit/service-read-range.spec.ts +++ b/test/unit/service-read-range.spec.ts @@ -198,6 +198,115 @@ test.describe('ReadRangeAcknowledge', () => { ) }) + test('should decode special log-status records without status flags', () => { + // Build a log record with log-status choice (tag 0) instead of normal value + // Per ASHRAE 135, log-status records do NOT have status flags (context tag 2) + const applicationData = utils.getBuffer() + + // First record: normal record with status flags + baAsn1.encodeOpeningTag(applicationData, 0) + baAsn1.bacappEncodeApplicationData(applicationData, { + type: ApplicationTag.DATE, + value: new Date(2024, 1, 3), + }) + baAsn1.bacappEncodeApplicationData(applicationData, { + type: ApplicationTag.TIME, + value: new Date(2024, 1, 3, 12, 15, 30, 0), + }) + baAsn1.encodeClosingTag(applicationData, 0) + baAsn1.encodeOpeningTag(applicationData, 1) + baAsn1.encodeTag(applicationData, 2, true, 4) + applicationData.buffer.writeFloatBE(42.5, applicationData.offset) + applicationData.offset += 4 + baAsn1.encodeClosingTag(applicationData, 1) + baAsn1.encodeContextBitstring(applicationData, 2, { + bitsUsed: 4, + value: [0b0000], + }) + + // Second record: log-status (log-interrupted) without status flags + baAsn1.encodeOpeningTag(applicationData, 0) + baAsn1.bacappEncodeApplicationData(applicationData, { + type: ApplicationTag.DATE, + value: new Date(2024, 1, 3), + }) + baAsn1.bacappEncodeApplicationData(applicationData, { + type: ApplicationTag.TIME, + value: new Date(2024, 1, 3, 12, 30, 0, 0), + }) + baAsn1.encodeClosingTag(applicationData, 0) + baAsn1.encodeOpeningTag(applicationData, 1) + // log-status choice: context tag 0 with bitstring + // LOG_INTERRUPTED = bit 2 = 0b0100 = 4 + baAsn1.encodeContextBitstring(applicationData, 0, { + bitsUsed: 3, + value: [0b0100], + }) + baAsn1.encodeClosingTag(applicationData, 1) + // NO status flags for log-status records! + + // Third record: another normal record with status flags + baAsn1.encodeOpeningTag(applicationData, 0) + baAsn1.bacappEncodeApplicationData(applicationData, { + type: ApplicationTag.DATE, + value: new Date(2024, 1, 3), + }) + baAsn1.bacappEncodeApplicationData(applicationData, { + type: ApplicationTag.TIME, + value: new Date(2024, 1, 3, 12, 45, 0, 0), + }) + baAsn1.encodeClosingTag(applicationData, 0) + baAsn1.encodeOpeningTag(applicationData, 1) + baAsn1.encodeTag(applicationData, 2, true, 4) + applicationData.buffer.writeFloatBE(99.9, applicationData.offset) + applicationData.offset += 4 + baAsn1.encodeClosingTag(applicationData, 1) + baAsn1.encodeContextBitstring(applicationData, 2, { + bitsUsed: 4, + value: [0b0000], + }) + + const buffer = utils.getBuffer() + ReadRange.encodeAcknowledge( + buffer, + { type: 20, instance: 0 }, + 131, + 0xffffffff, + { bitsUsed: 3, value: [0] }, + 3, + applicationData.buffer.slice(0, applicationData.offset), + ReadRangeType.BY_POSITION, + 0, + ) + + const result = ReadRange.decodeAcknowledge( + buffer.buffer, + 0, + buffer.offset, + ) + assert.ok(result) + assert.ok(result.values) + assert.equal(result.values?.length, 3) + + // First record: normal with value 42.5 + assert.equal(result.values?.[0].value, 42.5) + assert.equal(result.values?.[0].isLogStatus, undefined) + assert.ok(result.values?.[0].status) + + // Second record: log-status (log-interrupted) + assert.equal(result.values?.[1].isLogStatus, true) + assert.ok(result.values?.[1].logStatus) + assert.equal(result.values?.[1].logStatus?.log_interrupted, true) + assert.equal(result.values?.[1].logStatus?.log_disabled, false) + assert.equal(result.values?.[1].logStatus?.buffer_purged, false) + assert.equal(result.values?.[1].status, undefined) + + // Third record: normal with value 99.9 (approximately) + assert.ok(Math.abs((result.values?.[2].value as number) - 99.9) < 0.01) + assert.equal(result.values?.[2].isLogStatus, undefined) + assert.ok(result.values?.[2].status) + }) + test('should slice fallback rangeBuffer correctly with non-zero offset', () => { const ackBuffer = utils.getBuffer() ReadRange.encodeAcknowledge( From 79a8503780ea807a48bc5afa57180e318104f5c8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 08:48:24 +0000 Subject: [PATCH 3/9] refactor: extract LogRecord interface and improve test assertions - Extract LogRecord, LogStatusFlags, LogRecordStatusFlags interfaces - Update ReadRangeAcknowledge.values to use LogRecord[] instead of any[] - Update decodeRange to return Decode - Use assert.strictEqual for undefined checks in tests Co-authored-by: robertsLando <11502495+robertsLando@users.noreply.github.com> --- src/lib/asn1.ts | 22 +++---------- src/lib/types.ts | 49 +++++++++++++++++++++++++++- test/unit/service-read-range.spec.ts | 6 ++-- 3 files changed, 55 insertions(+), 22 deletions(-) diff --git a/src/lib/asn1.ts b/src/lib/asn1.ts index 49bb4f50..567cd8bc 100644 --- a/src/lib/asn1.ts +++ b/src/lib/asn1.ts @@ -31,6 +31,7 @@ import { BACNetDateValue, BACNetEncodableAppData, BACNetRawDate, + LogRecord, } from './types' import { CharacterStringEncoding, @@ -2425,11 +2426,11 @@ export const decodeRange = ( buffer: Buffer, offset: number, maxOffset: number, -): Decode | undefined => { +): Decode | undefined => { // The payload for readRange ACK is expected to start with opening tag 0. if (!decodeIsOpeningTagNumber(buffer, offset, 0)) return undefined let len = 0 - const result: any[] = [] + const result: LogRecord[] = [] while ( offset + len < maxOffset && @@ -2564,22 +2565,7 @@ export const decodeRange = ( t.getMilliseconds(), ) - const record: { - timestamp: Date - value: unknown - isLogStatus?: boolean - logStatus?: { - log_disabled: boolean - buffer_purged: boolean - log_interrupted: boolean - } - status?: { - out_of_service: boolean - overridden: boolean - fault: boolean - in_alarm: boolean - } - } = { + const record: LogRecord = { timestamp, value: value.value, } diff --git a/src/lib/types.ts b/src/lib/types.ts index 67042c9e..a04fc2d4 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -742,13 +742,60 @@ export interface ReadRangePayload extends BasicServicePayload { values: BACNetAppData[] } +/** + * Represents the log-status choice for special log records. + * These are status-only records without actual data values. + */ +export interface LogStatusFlags { + log_disabled: boolean + buffer_purged: boolean + log_interrupted: boolean +} + +/** + * Represents the status flags for a normal log record. + */ +export interface LogRecordStatusFlags { + out_of_service: boolean + overridden: boolean + fault: boolean + in_alarm: boolean +} + +/** + * Represents a single log record from a TREND_LOG. + * Can be either a normal data record or a special log-status record. + */ +export interface LogRecord { + /** Timestamp when the record was logged */ + timestamp: Date + /** + * The logged value. For normal records, this is the actual data (number, boolean, etc.). + * For log-status records, this is the raw bitstring value. + */ + value: unknown + /** + * True if this is a special log-status record (log-disabled, buffer-purged, log-interrupted). + * Undefined for normal data records. + */ + isLogStatus?: boolean + /** + * Present only for log-status records. Indicates which special status applies. + */ + logStatus?: LogStatusFlags + /** + * Present only for normal data records. Contains the BACnet status flags. + */ + status?: LogRecordStatusFlags +} + export interface ReadRangeAcknowledge { objectId: BACNetObjectID property: BACNetPropertyID resultFlag: BACNetBitString itemCount: number rangeBuffer: Buffer - values?: any[] + values?: LogRecord[] len: number } diff --git a/test/unit/service-read-range.spec.ts b/test/unit/service-read-range.spec.ts index 803d115c..37a59d22 100644 --- a/test/unit/service-read-range.spec.ts +++ b/test/unit/service-read-range.spec.ts @@ -290,7 +290,7 @@ test.describe('ReadRangeAcknowledge', () => { // First record: normal with value 42.5 assert.equal(result.values?.[0].value, 42.5) - assert.equal(result.values?.[0].isLogStatus, undefined) + assert.strictEqual(result.values?.[0].isLogStatus, undefined) assert.ok(result.values?.[0].status) // Second record: log-status (log-interrupted) @@ -299,11 +299,11 @@ test.describe('ReadRangeAcknowledge', () => { assert.equal(result.values?.[1].logStatus?.log_interrupted, true) assert.equal(result.values?.[1].logStatus?.log_disabled, false) assert.equal(result.values?.[1].logStatus?.buffer_purged, false) - assert.equal(result.values?.[1].status, undefined) + assert.strictEqual(result.values?.[1].status, undefined) // Third record: normal with value 99.9 (approximately) assert.ok(Math.abs((result.values?.[2].value as number) - 99.9) < 0.01) - assert.equal(result.values?.[2].isLogStatus, undefined) + assert.strictEqual(result.values?.[2].isLogStatus, undefined) assert.ok(result.values?.[2].status) }) From 56096123468361dc32c589c467f66491951d850e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 09:37:08 +0000 Subject: [PATCH 4/9] =?UTF-8?q?docs:=20add=20ASHRAE=20135=20=C2=A712.25=20?= =?UTF-8?q?standard=20references=20to=20LogRecord=20decoding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reference ASHRAE 135 Standard Section 12.25 for LogRecord structure in JSDoc comments for decodeRange function and LogRecord types. Co-authored-by: robertsLando <11502495+robertsLando@users.noreply.github.com> --- src/lib/asn1.ts | 19 +++++++++++++------ src/lib/types.ts | 3 +++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/lib/asn1.ts b/src/lib/asn1.ts index 567cd8bc..187cdcb2 100644 --- a/src/lib/asn1.ts +++ b/src/lib/asn1.ts @@ -2422,6 +2422,14 @@ export const decodeCalendarDatelist = ( return { len, value: result } } +/** + * Decodes LogRecord sequence from ReadRange ACK payload. + * Per ASHRAE 135 §12.25, LogRecord ::= SEQUENCE { + * timestamp [0] BACnetDateTime, + * logDatum [1] CHOICE { log-status [0], boolean-value [1], real-value [2], ... }, + * statusFlags [2] BACnetStatusFlags OPTIONAL -- NOT present for log-status choice + * } + */ export const decodeRange = ( buffer: Buffer, offset: number, @@ -2475,22 +2483,21 @@ export const decodeRange = ( } len += 2 - // value payload (context specific) - // This is the log-datum CHOICE with options: + // log-datum [1] CHOICE per ASHRAE 135 §12.25: // [0] log-status (BACnetLogStatus bitstring - special records) // [1] boolean-value // [2] real-value // [3] enum-value // [4] unsigned-value - // ... and more per ASHRAE 135 + // ... and more tag = decodeTagNumberAndValue(buffer, offset + len) len += tag.len let value: ApplicationData | undefined let isLogStatus = false if (tag.tagNumber === 0) { - // log-status choice: BACnetLogStatus bitstring - // These are special log records (log-disabled, buffer-purged, log-interrupted) - // They do NOT have status flags after the log-datum + // log-status choice: BACnetLogStatus bitstring per ASHRAE 135 §12.25 + // Special log records (log-disabled, buffer-purged, log-interrupted) + // do NOT have statusFlags [2] after the log-datum value = bacappDecodeData( buffer, offset + len, diff --git a/src/lib/types.ts b/src/lib/types.ts index a04fc2d4..cf8feb35 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -743,6 +743,7 @@ export interface ReadRangePayload extends BasicServicePayload { } /** + * BACnetLogStatus bitstring per ASHRAE 135 §12.25. * Represents the log-status choice for special log records. * These are status-only records without actual data values. */ @@ -753,6 +754,7 @@ export interface LogStatusFlags { } /** + * BACnetStatusFlags per ASHRAE 135 §12.25. * Represents the status flags for a normal log record. */ export interface LogRecordStatusFlags { @@ -763,6 +765,7 @@ export interface LogRecordStatusFlags { } /** + * LogRecord per ASHRAE 135 §12.25. * Represents a single log record from a TREND_LOG. * Can be either a normal data record or a special log-status record. */ From a33fbbb6359d2bf7d088f07350c9f0974a4d6014 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 09:44:47 +0000 Subject: [PATCH 5/9] refactor: use BACNetBitString for casts and add LogRecordValue type Address code review comments: - Use BACNetBitString for bitstring casts instead of { value: number[] } - Add LogRecordValue union type to narrow LogRecord.value from unknown Co-authored-by: robertsLando <11502495+robertsLando@users.noreply.github.com> --- src/lib/asn1.ts | 7 ++++--- src/lib/types.ts | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/lib/asn1.ts b/src/lib/asn1.ts index 187cdcb2..5ab576a8 100644 --- a/src/lib/asn1.ts +++ b/src/lib/asn1.ts @@ -32,6 +32,7 @@ import { BACNetEncodableAppData, BACNetRawDate, LogRecord, + LogRecordValue, } from './types' import { CharacterStringEncoding, @@ -2574,12 +2575,12 @@ export const decodeRange = ( const record: LogRecord = { timestamp, - value: value.value, + value: value.value as LogRecordValue, } if (isLogStatus) { record.isLogStatus = true - const logStatusBits = value.value as { value: number[] } + const logStatusBits = value.value as BACNetBitString record.logStatus = { log_disabled: ((logStatusBits.value[0] >> 0) & 1) !== 0, buffer_purged: ((logStatusBits.value[0] >> 1) & 1) !== 0, @@ -2588,7 +2589,7 @@ export const decodeRange = ( } if (status) { - const statusBits = status.value as { value: number[] } + const statusBits = status.value as BACNetBitString record.status = { out_of_service: ((statusBits.value[0] >> 0) & 1) !== 0, overridden: ((statusBits.value[0] >> 1) & 1) !== 0, diff --git a/src/lib/types.ts b/src/lib/types.ts index cf8feb35..ae0092c0 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -764,6 +764,18 @@ export interface LogRecordStatusFlags { in_alarm: boolean } +/** + * Union type for log record values per ASHRAE 135 §12.25. + * log-datum CHOICE can contain various types depending on the logged property. + */ +export type LogRecordValue = + | number // REAL, ENUMERATED, UNSIGNED_INTEGER, SIGNED_INTEGER + | boolean // BOOLEAN + | BACNetBitString // log-status bitstring + | null // NULL + | string // CHARACTER_STRING + | BACNetObjectID // OBJECTIDENTIFIER + /** * LogRecord per ASHRAE 135 §12.25. * Represents a single log record from a TREND_LOG. @@ -774,9 +786,9 @@ export interface LogRecord { timestamp: Date /** * The logged value. For normal records, this is the actual data (number, boolean, etc.). - * For log-status records, this is the raw bitstring value. + * For log-status records, this is a BACNetBitString. */ - value: unknown + value: LogRecordValue /** * True if this is a special log-status record (log-disabled, buffer-purged, log-interrupted). * Undefined for normal data records. From f5bbc1fa9ed4b95027c27e1e15f131ba7ed45634 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:04:11 +0000 Subject: [PATCH 6/9] refactor: use camelCase for flag names and narrow LogRecordValue type Co-authored-by: robertsLando <11502495+robertsLando@users.noreply.github.com> --- src/lib/asn1.ts | 10 +++++----- src/lib/types.ts | 26 +++++++++++++------------- test/unit/service-read-range.spec.ts | 6 +++--- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/lib/asn1.ts b/src/lib/asn1.ts index 5ab576a8..5bb64750 100644 --- a/src/lib/asn1.ts +++ b/src/lib/asn1.ts @@ -2582,19 +2582,19 @@ export const decodeRange = ( record.isLogStatus = true const logStatusBits = value.value as BACNetBitString record.logStatus = { - log_disabled: ((logStatusBits.value[0] >> 0) & 1) !== 0, - buffer_purged: ((logStatusBits.value[0] >> 1) & 1) !== 0, - log_interrupted: ((logStatusBits.value[0] >> 2) & 1) !== 0, + logDisabled: ((logStatusBits.value[0] >> 0) & 1) !== 0, + bufferPurged: ((logStatusBits.value[0] >> 1) & 1) !== 0, + logInterrupted: ((logStatusBits.value[0] >> 2) & 1) !== 0, } } if (status) { const statusBits = status.value as BACNetBitString record.status = { - out_of_service: ((statusBits.value[0] >> 0) & 1) !== 0, + outOfService: ((statusBits.value[0] >> 0) & 1) !== 0, overridden: ((statusBits.value[0] >> 1) & 1) !== 0, fault: ((statusBits.value[0] >> 2) & 1) !== 0, - in_alarm: ((statusBits.value[0] >> 3) & 1) !== 0, + inAlarm: ((statusBits.value[0] >> 3) & 1) !== 0, } } diff --git a/src/lib/types.ts b/src/lib/types.ts index ae0092c0..78fb49f2 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -748,9 +748,9 @@ export interface ReadRangePayload extends BasicServicePayload { * These are status-only records without actual data values. */ export interface LogStatusFlags { - log_disabled: boolean - buffer_purged: boolean - log_interrupted: boolean + logDisabled: boolean + bufferPurged: boolean + logInterrupted: boolean } /** @@ -758,23 +758,23 @@ export interface LogStatusFlags { * Represents the status flags for a normal log record. */ export interface LogRecordStatusFlags { - out_of_service: boolean + outOfService: boolean overridden: boolean fault: boolean - in_alarm: boolean + inAlarm: boolean } /** * Union type for log record values per ASHRAE 135 §12.25. - * log-datum CHOICE can contain various types depending on the logged property. + * Currently supported log-datum CHOICE types decoded by decodeRange: + * - [0] log-status (BACNetBitString) + * - [2] real-value (number) + * - [3] enumerated-value (number) + * - [4] unsigned-value (number) + * Other CHOICE alternatives exist in the BACnet spec but are not currently decoded; + * for unsupported types, use the raw `rangeBuffer` from ReadRangeAcknowledge. */ -export type LogRecordValue = - | number // REAL, ENUMERATED, UNSIGNED_INTEGER, SIGNED_INTEGER - | boolean // BOOLEAN - | BACNetBitString // log-status bitstring - | null // NULL - | string // CHARACTER_STRING - | BACNetObjectID // OBJECTIDENTIFIER +export type LogRecordValue = number | BACNetBitString /** * LogRecord per ASHRAE 135 §12.25. diff --git a/test/unit/service-read-range.spec.ts b/test/unit/service-read-range.spec.ts index 37a59d22..6835f692 100644 --- a/test/unit/service-read-range.spec.ts +++ b/test/unit/service-read-range.spec.ts @@ -296,9 +296,9 @@ test.describe('ReadRangeAcknowledge', () => { // Second record: log-status (log-interrupted) assert.equal(result.values?.[1].isLogStatus, true) assert.ok(result.values?.[1].logStatus) - assert.equal(result.values?.[1].logStatus?.log_interrupted, true) - assert.equal(result.values?.[1].logStatus?.log_disabled, false) - assert.equal(result.values?.[1].logStatus?.buffer_purged, false) + assert.equal(result.values?.[1].logStatus?.logInterrupted, true) + assert.equal(result.values?.[1].logStatus?.logDisabled, false) + assert.equal(result.values?.[1].logStatus?.bufferPurged, false) assert.strictEqual(result.values?.[1].status, undefined) // Third record: normal with value 99.9 (approximately) From f1f71764977ce3c08f52276cf348210f70e13b0e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:12:49 +0000 Subject: [PATCH 7/9] revert: keep snake_case property names to avoid breaking changes Co-authored-by: robertsLando <11502495+robertsLando@users.noreply.github.com> --- src/lib/asn1.ts | 10 +++++----- src/lib/types.ts | 26 +++++++++++++------------- test/unit/service-read-range.spec.ts | 6 +++--- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/lib/asn1.ts b/src/lib/asn1.ts index 5bb64750..5ab576a8 100644 --- a/src/lib/asn1.ts +++ b/src/lib/asn1.ts @@ -2582,19 +2582,19 @@ export const decodeRange = ( record.isLogStatus = true const logStatusBits = value.value as BACNetBitString record.logStatus = { - logDisabled: ((logStatusBits.value[0] >> 0) & 1) !== 0, - bufferPurged: ((logStatusBits.value[0] >> 1) & 1) !== 0, - logInterrupted: ((logStatusBits.value[0] >> 2) & 1) !== 0, + log_disabled: ((logStatusBits.value[0] >> 0) & 1) !== 0, + buffer_purged: ((logStatusBits.value[0] >> 1) & 1) !== 0, + log_interrupted: ((logStatusBits.value[0] >> 2) & 1) !== 0, } } if (status) { const statusBits = status.value as BACNetBitString record.status = { - outOfService: ((statusBits.value[0] >> 0) & 1) !== 0, + out_of_service: ((statusBits.value[0] >> 0) & 1) !== 0, overridden: ((statusBits.value[0] >> 1) & 1) !== 0, fault: ((statusBits.value[0] >> 2) & 1) !== 0, - inAlarm: ((statusBits.value[0] >> 3) & 1) !== 0, + in_alarm: ((statusBits.value[0] >> 3) & 1) !== 0, } } diff --git a/src/lib/types.ts b/src/lib/types.ts index 78fb49f2..ae0092c0 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -748,9 +748,9 @@ export interface ReadRangePayload extends BasicServicePayload { * These are status-only records without actual data values. */ export interface LogStatusFlags { - logDisabled: boolean - bufferPurged: boolean - logInterrupted: boolean + log_disabled: boolean + buffer_purged: boolean + log_interrupted: boolean } /** @@ -758,23 +758,23 @@ export interface LogStatusFlags { * Represents the status flags for a normal log record. */ export interface LogRecordStatusFlags { - outOfService: boolean + out_of_service: boolean overridden: boolean fault: boolean - inAlarm: boolean + in_alarm: boolean } /** * Union type for log record values per ASHRAE 135 §12.25. - * Currently supported log-datum CHOICE types decoded by decodeRange: - * - [0] log-status (BACNetBitString) - * - [2] real-value (number) - * - [3] enumerated-value (number) - * - [4] unsigned-value (number) - * Other CHOICE alternatives exist in the BACnet spec but are not currently decoded; - * for unsupported types, use the raw `rangeBuffer` from ReadRangeAcknowledge. + * log-datum CHOICE can contain various types depending on the logged property. */ -export type LogRecordValue = number | BACNetBitString +export type LogRecordValue = + | number // REAL, ENUMERATED, UNSIGNED_INTEGER, SIGNED_INTEGER + | boolean // BOOLEAN + | BACNetBitString // log-status bitstring + | null // NULL + | string // CHARACTER_STRING + | BACNetObjectID // OBJECTIDENTIFIER /** * LogRecord per ASHRAE 135 §12.25. diff --git a/test/unit/service-read-range.spec.ts b/test/unit/service-read-range.spec.ts index 6835f692..37a59d22 100644 --- a/test/unit/service-read-range.spec.ts +++ b/test/unit/service-read-range.spec.ts @@ -296,9 +296,9 @@ test.describe('ReadRangeAcknowledge', () => { // Second record: log-status (log-interrupted) assert.equal(result.values?.[1].isLogStatus, true) assert.ok(result.values?.[1].logStatus) - assert.equal(result.values?.[1].logStatus?.logInterrupted, true) - assert.equal(result.values?.[1].logStatus?.logDisabled, false) - assert.equal(result.values?.[1].logStatus?.bufferPurged, false) + assert.equal(result.values?.[1].logStatus?.log_interrupted, true) + assert.equal(result.values?.[1].logStatus?.log_disabled, false) + assert.equal(result.values?.[1].logStatus?.buffer_purged, false) assert.strictEqual(result.values?.[1].status, undefined) // Third record: normal with value 99.9 (approximately) From 2125d039b66e89cbabb28885f84476c939dede5d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:13:44 +0000 Subject: [PATCH 8/9] fix: handle time-change records and make status flags optional for all record types Agent-Logs-Url: https://github.com/bacnet-js/client/sessions/e153eab8-5232-426b-92f5-4cecd29c35b7 Co-authored-by: robertsLando <11502495+robertsLando@users.noreply.github.com> --- src/lib/asn1.ts | 41 ++++++++-- src/lib/types.ts | 15 +++- test/unit/service-read-range.spec.ts | 110 +++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 11 deletions(-) diff --git a/src/lib/asn1.ts b/src/lib/asn1.ts index 5ab576a8..c89dfa10 100644 --- a/src/lib/asn1.ts +++ b/src/lib/asn1.ts @@ -2490,15 +2490,20 @@ export const decodeRange = ( // [2] real-value // [3] enum-value // [4] unsigned-value - // ... and more + // [5] signed-value + // [6] bitstring-value + // [7] null-value + // [8] failure (BACnetError) + // [9] time-change (REAL - seconds delta for clock adjustment) + // [10] any-value (ABSTRACT-SYNTAX.&Type) tag = decodeTagNumberAndValue(buffer, offset + len) len += tag.len let value: ApplicationData | undefined let isLogStatus = false + let isTimeChange = false if (tag.tagNumber === 0) { // log-status choice: BACnetLogStatus bitstring per ASHRAE 135 §12.25 // Special log records (log-disabled, buffer-purged, log-interrupted) - // do NOT have statusFlags [2] after the log-datum value = bacappDecodeData( buffer, offset + len, @@ -2507,7 +2512,8 @@ export const decodeRange = ( tag.value, ) isLogStatus = true - } else if (tag.tagNumber === 2 && tag.value === 4) { + } else if (tag.tagNumber === 2) { + // real-value value = bacappDecodeData( buffer, offset + len, @@ -2515,7 +2521,8 @@ export const decodeRange = ( ApplicationTag.REAL, tag.value, ) - } else if (tag.tagNumber === 4 && tag.value === 1) { + } else if (tag.tagNumber === 3) { + // enum-value value = bacappDecodeData( buffer, offset + len, @@ -2523,7 +2530,8 @@ export const decodeRange = ( ApplicationTag.ENUMERATED, tag.value, ) - } else if (tag.tagNumber === 3 && tag.value === 1) { + } else if (tag.tagNumber === 4) { + // unsigned-value value = bacappDecodeData( buffer, offset + len, @@ -2531,6 +2539,17 @@ export const decodeRange = ( ApplicationTag.ENUMERATED, tag.value, ) + } else if (tag.tagNumber === 9) { + // time-change: REAL value representing seconds the clock changed + // Per ASHRAE 135 §12.25, time-change records do not have status flags + value = bacappDecodeData( + buffer, + offset + len, + maxOffset, + ApplicationTag.REAL, + tag.value, + ) + isTimeChange = true } if (!value) return undefined len += value.len @@ -2542,10 +2561,12 @@ export const decodeRange = ( tag = decodeTagNumberAndValue(buffer, offset + len) len += tag.len - // context tag 2 (status flags) is optional for regular log records. - // Special log records (log-status choice) do NOT have status flags. + // context tag 2 (status flags) is OPTIONAL for ALL log record types. + // Per ASHRAE 135 §12.25 and bacnet-stack, status flags are only encoded + // when the ucStatus byte has bit 7 set. This means any record type + // (log-status, time-change, real-value, etc.) may or may not have status flags. let status: ApplicationData | undefined - if (!isLogStatus && decodeIsContextTag(buffer, offset + len, 2)) { + if (decodeIsContextTag(buffer, offset + len, 2)) { tag = decodeTagNumberAndValue(buffer, offset + len) len += tag.len @@ -2588,6 +2609,10 @@ export const decodeRange = ( } } + if (isTimeChange) { + record.isTimeChange = true + } + if (status) { const statusBits = status.value as BACNetBitString record.status = { diff --git a/src/lib/types.ts b/src/lib/types.ts index ae0092c0..b3c8837d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -779,7 +779,7 @@ export type LogRecordValue = /** * LogRecord per ASHRAE 135 §12.25. * Represents a single log record from a TREND_LOG. - * Can be either a normal data record or a special log-status record. + * Can be either a normal data record, a special log-status record, or a time-change record. */ export interface LogRecord { /** Timestamp when the record was logged */ @@ -787,19 +787,28 @@ export interface LogRecord { /** * The logged value. For normal records, this is the actual data (number, boolean, etc.). * For log-status records, this is a BACNetBitString. + * For time-change records, this is the number of seconds the clock changed (REAL). */ value: LogRecordValue /** * True if this is a special log-status record (log-disabled, buffer-purged, log-interrupted). - * Undefined for normal data records. + * Undefined for normal data records and time-change records. */ isLogStatus?: boolean + /** + * True if this is a time-change record (clock adjustment). + * Per ASHRAE 135 §12.25, time-change records contain a REAL value representing + * the number of seconds the clock changed (positive or negative). + * Undefined for normal data records and log-status records. + */ + isTimeChange?: boolean /** * Present only for log-status records. Indicates which special status applies. */ logStatus?: LogStatusFlags /** - * Present only for normal data records. Contains the BACnet status flags. + * Present only when status flags are encoded. Per ASHRAE 135 §12.25, + * status flags are optional for ALL log record types. */ status?: LogRecordStatusFlags } diff --git a/test/unit/service-read-range.spec.ts b/test/unit/service-read-range.spec.ts index 37a59d22..53fbbecc 100644 --- a/test/unit/service-read-range.spec.ts +++ b/test/unit/service-read-range.spec.ts @@ -307,6 +307,116 @@ test.describe('ReadRangeAcknowledge', () => { assert.ok(result.values?.[2].status) }) + test('should decode time-change records without status flags', () => { + // Build a log record with time-change choice (tag 9) per ASHRAE 135 §12.25 + // Per the BACnet standard, time-change records do NOT have status flags + const applicationData = utils.getBuffer() + + // First record: normal record with status flags + baAsn1.encodeOpeningTag(applicationData, 0) + baAsn1.bacappEncodeApplicationData(applicationData, { + type: ApplicationTag.DATE, + value: new Date(2024, 1, 3), + }) + baAsn1.bacappEncodeApplicationData(applicationData, { + type: ApplicationTag.TIME, + value: new Date(2024, 1, 3, 12, 0, 0, 0), + }) + baAsn1.encodeClosingTag(applicationData, 0) + baAsn1.encodeOpeningTag(applicationData, 1) + baAsn1.encodeTag(applicationData, 2, true, 4) + applicationData.buffer.writeFloatBE(42.5, applicationData.offset) + applicationData.offset += 4 + baAsn1.encodeClosingTag(applicationData, 1) + baAsn1.encodeContextBitstring(applicationData, 2, { + bitsUsed: 4, + value: [0b0000], + }) + + // Second record: time-change (context tag 9) without status flags + // This represents a clock adjustment of 0.075 seconds + baAsn1.encodeOpeningTag(applicationData, 0) + baAsn1.bacappEncodeApplicationData(applicationData, { + type: ApplicationTag.DATE, + value: new Date(2024, 1, 3), + }) + baAsn1.bacappEncodeApplicationData(applicationData, { + type: ApplicationTag.TIME, + value: new Date(2024, 1, 3, 12, 30, 0, 0), + }) + baAsn1.encodeClosingTag(applicationData, 0) + baAsn1.encodeOpeningTag(applicationData, 1) + // time-change choice: context tag 9 with REAL value (seconds delta) + baAsn1.encodeTag(applicationData, 9, true, 4) + applicationData.buffer.writeFloatBE(0.075, applicationData.offset) + applicationData.offset += 4 + baAsn1.encodeClosingTag(applicationData, 1) + // NO status flags for time-change records! + + // Third record: another normal record with status flags + baAsn1.encodeOpeningTag(applicationData, 0) + baAsn1.bacappEncodeApplicationData(applicationData, { + type: ApplicationTag.DATE, + value: new Date(2024, 1, 3), + }) + baAsn1.bacappEncodeApplicationData(applicationData, { + type: ApplicationTag.TIME, + value: new Date(2024, 1, 3, 12, 45, 0, 0), + }) + baAsn1.encodeClosingTag(applicationData, 0) + baAsn1.encodeOpeningTag(applicationData, 1) + baAsn1.encodeTag(applicationData, 2, true, 4) + applicationData.buffer.writeFloatBE(99.9, applicationData.offset) + applicationData.offset += 4 + baAsn1.encodeClosingTag(applicationData, 1) + baAsn1.encodeContextBitstring(applicationData, 2, { + bitsUsed: 4, + value: [0b0000], + }) + + const buffer = utils.getBuffer() + ReadRange.encodeAcknowledge( + buffer, + { type: 20, instance: 0 }, + 131, + 0xffffffff, + { bitsUsed: 3, value: [0] }, + 3, + applicationData.buffer.slice(0, applicationData.offset), + ReadRangeType.BY_POSITION, + 0, + ) + + const result = ReadRange.decodeAcknowledge( + buffer.buffer, + 0, + buffer.offset, + ) + assert.ok(result) + assert.ok(result.values) + assert.equal(result.values?.length, 3) + + // First record: normal with value 42.5 and status flags + assert.equal(result.values?.[0].value, 42.5) + assert.strictEqual(result.values?.[0].isLogStatus, undefined) + assert.strictEqual(result.values?.[0].isTimeChange, undefined) + assert.ok(result.values?.[0].status) + + // Second record: time-change with value 0.075 (seconds delta) + assert.equal(result.values?.[1].isTimeChange, true) + assert.strictEqual(result.values?.[1].isLogStatus, undefined) + assert.ok( + Math.abs((result.values?.[1].value as number) - 0.075) < 0.001, + ) + assert.strictEqual(result.values?.[1].status, undefined) + + // Third record: normal with value 99.9 and status flags + assert.ok(Math.abs((result.values?.[2].value as number) - 99.9) < 0.01) + assert.strictEqual(result.values?.[2].isLogStatus, undefined) + assert.strictEqual(result.values?.[2].isTimeChange, undefined) + assert.ok(result.values?.[2].status) + }) + test('should slice fallback rangeBuffer correctly with non-zero offset', () => { const ackBuffer = utils.getBuffer() ReadRange.encodeAcknowledge( From a648adef3b15d7659086d7127c88012e4297b552 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:16:11 +0000 Subject: [PATCH 9/9] fix: use UNSIGNED_INTEGER for log-datum tag 4 per ASHRAE 135 Agent-Logs-Url: https://github.com/bacnet-js/client/sessions/e153eab8-5232-426b-92f5-4cecd29c35b7 Co-authored-by: robertsLando <11502495+robertsLando@users.noreply.github.com> --- src/lib/asn1.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/asn1.ts b/src/lib/asn1.ts index c89dfa10..dff33e99 100644 --- a/src/lib/asn1.ts +++ b/src/lib/asn1.ts @@ -2536,7 +2536,7 @@ export const decodeRange = ( buffer, offset + len, maxOffset, - ApplicationTag.ENUMERATED, + ApplicationTag.UNSIGNED_INTEGER, tag.value, ) } else if (tag.tagNumber === 9) {