diff --git a/src/lib/asn1.ts b/src/lib/asn1.ts index eb50119e..dff33e99 100644 --- a/src/lib/asn1.ts +++ b/src/lib/asn1.ts @@ -31,6 +31,8 @@ import { BACNetDateValue, BACNetEncodableAppData, BACNetRawDate, + LogRecord, + LogRecordValue, } from './types' import { CharacterStringEncoding, @@ -2421,15 +2423,23 @@ 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, 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 && @@ -2474,11 +2484,36 @@ export const decodeRange = ( } len += 2 - // value payload (context specific) + // 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 + // [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 - if (tag.tagNumber === 2 && tag.value === 4) { + 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) + value = bacappDecodeData( + buffer, + offset + len, + maxOffset, + ApplicationTag.BIT_STRING, + tag.value, + ) + isLogStatus = true + } else if (tag.tagNumber === 2) { + // real-value value = bacappDecodeData( buffer, offset + len, @@ -2486,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, @@ -2494,38 +2530,60 @@ 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, maxOffset, - ApplicationTag.ENUMERATED, + ApplicationTag.UNSIGNED_INTEGER, + 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 - // 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 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 (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 +2593,37 @@ export const decodeRange = ( t.getSeconds(), t.getMilliseconds(), ) - result.push({ + + const record: LogRecord = { timestamp, - value: value.value, - status: { + value: value.value as LogRecordValue, + } + + if (isLogStatus) { + 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, + } + } + + if (isTimeChange) { + record.isTimeChange = true + } + + if (status) { + const statusBits = status.value as BACNetBitString + 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/src/lib/types.ts b/src/lib/types.ts index 67042c9e..b3c8837d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -742,13 +742,84 @@ export interface ReadRangePayload extends BasicServicePayload { values: BACNetAppData[] } +/** + * 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. + */ +export interface LogStatusFlags { + log_disabled: boolean + buffer_purged: boolean + log_interrupted: boolean +} + +/** + * BACnetStatusFlags per ASHRAE 135 §12.25. + * Represents the status flags for a normal log record. + */ +export interface LogRecordStatusFlags { + out_of_service: boolean + overridden: boolean + fault: boolean + 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. + * 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 */ + timestamp: Date + /** + * 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 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 when status flags are encoded. Per ASHRAE 135 §12.25, + * status flags are optional for ALL log record types. + */ + 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 0255246a..53fbbecc 100644 --- a/test/unit/service-read-range.spec.ts +++ b/test/unit/service-read-range.spec.ts @@ -198,6 +198,225 @@ 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.strictEqual(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.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.strictEqual(result.values?.[2].isLogStatus, undefined) + 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(