Skip to content
Merged
131 changes: 105 additions & 26 deletions src/lib/asn1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
BACNetDateValue,
BACNetEncodableAppData,
BACNetRawDate,
LogRecord,
LogRecordValue,
} from './types'
import {
CharacterStringEncoding,
Expand Down Expand Up @@ -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<any[]> | undefined => {
): Decode<LogRecord[]> | 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 &&
Expand Down Expand Up @@ -2474,58 +2484,106 @@ 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,
maxOffset,
ApplicationTag.REAL,
tag.value,
)
} else if (tag.tagNumber === 4 && tag.value === 1) {
} else if (tag.tagNumber === 3) {
// enum-value
value = bacappDecodeData(
buffer,
offset + len,
maxOffset,
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(),
Expand All @@ -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,
}
Comment thread
robertsLando marked this conversation as resolved.
}

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,
},
})
}
}
Comment thread
robertsLando marked this conversation as resolved.

result.push(record)
}

if (offset + len >= maxOffset) return undefined
Expand Down
8 changes: 6 additions & 2 deletions src/lib/services/WriteProperty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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`)
}
Expand Down
73 changes: 72 additions & 1 deletion src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
robertsLando marked this conversation as resolved.

/**
* 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[]
Comment thread
robertsLando marked this conversation as resolved.
len: number
}

Expand Down
Loading