Skip to content

Commit 29f2f8f

Browse files
EveGunrobertsLando
andauthored
fix(asn1): preserve raw date bytes for wildcard/partial date decode (#70)
## Summary This PR fixes BACnet DATE wildcard handling by preserving raw DATE fields during decode. I initially suspected the bug was in the encoder, but investigation showed the write payload was encoded correctly. The actual issue was in decode/normalization, where partial wildcard DATE values were being normalized through JS `Date`, causing readback drift/misinterpretation. ## Root Cause - Wildcard/partial DATE values (e.g. `17.*.*.*`, `*.*.*.Fri`, `28.*.2021 -> 30.*.2032`) were valid on write. - On read, decoder/normalization converted these to JS `Date` semantics, which cannot represent BACnet wildcard bytes faithfully. - This produced incorrect values when rendering/roundtripping. ## Changes - Preserve raw DATE byte components (`year`, `month`, `day`, `wday`) from decoder output. - Avoid JS Date normalization for wildcard/partial DATE patterns. - Ensure downstream consumers can prefer `raw` when reconstructing BACnet DATE patterns. ## Impact - Fixes incorrect readback of wildcard/partial DATE values. - Restores roundtrip consistency for scheduler/calendar exception use-cases. - No behavior change for fully specified dates. ## Validation Tested with real-device and gateway flows, including: - Single date wildcard patterns (`17.*.*.*`, `*.*.*.Friday`, `*.*.*.*`) - Date range with partial wildcards (`28.*.2021 -> 30.*.2032`) - Verified write succeeds where device allows pattern - Verified readback now matches original BACnet wildcard intent ## Compatibility Backward compatible. No API break; change is in decode fidelity and normalization behavior. --------- Co-authored-by: Daniel Lando <daniel.sorridi@gmail.com>
1 parent a8539bd commit 29f2f8f

4 files changed

Lines changed: 303 additions & 11 deletions

File tree

src/lib/asn1.ts

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1298,33 +1298,79 @@ export const decodeBitstring = (
12981298
}
12991299
}
13001300

1301-
export const decodeDate = (buffer: Buffer, offset: number): Decode<Date> => {
1301+
const isWildcardRawDate = (raw: {
1302+
year: number
1303+
month: number
1304+
day: number
1305+
wday: number
1306+
}): boolean =>
1307+
raw.year === 0xff &&
1308+
raw.month === 0xff &&
1309+
raw.day === 0xff &&
1310+
raw.wday === 0xff
1311+
1312+
const hasPartialWildcardRawDate = (raw: {
1313+
year: number
1314+
month: number
1315+
day: number
1316+
wday: number
1317+
}): boolean =>
1318+
!isWildcardRawDate(raw) &&
1319+
(raw.year === 0xff ||
1320+
raw.month === 0xff ||
1321+
raw.day === 0xff ||
1322+
raw.wday === 0xff)
1323+
1324+
const isInvalidConcreteRawDate = (raw: {
1325+
year: number
1326+
month: number
1327+
day: number
1328+
}): boolean => raw.month < 1 || raw.month > 12 || raw.day < 1 || raw.day > 31
1329+
1330+
export const decodeDate = (
1331+
buffer: Buffer,
1332+
offset: number,
1333+
): Decode<Date> & {
1334+
raw: { year: number; month: number; day: number; wday: number }
1335+
} => {
1336+
const raw = {
1337+
year: buffer[offset],
1338+
month: buffer[offset + 1],
1339+
day: buffer[offset + 2],
1340+
wday: buffer[offset + 3],
1341+
}
1342+
13021343
let date: Date
1303-
const year = buffer[offset] + 1900
1304-
const month = buffer[offset + 1]
1305-
const day = buffer[offset + 2]
1306-
const wday = buffer[offset + 3]
13071344
if (
1308-
month === 0xff &&
1309-
day === 0xff &&
1310-
wday === 0xff &&
1311-
year - 1900 === 0xff
1345+
isWildcardRawDate(raw) ||
1346+
hasPartialWildcardRawDate(raw) ||
1347+
isInvalidConcreteRawDate(raw)
13121348
) {
13131349
date = ZERO_DATE
13141350
} else {
1315-
date = new Date(year, month - 1, day)
1351+
const year = raw.year + 1900
1352+
const candidate = new Date(year, raw.month - 1, raw.day)
1353+
const normalized =
1354+
candidate.getFullYear() === year &&
1355+
candidate.getMonth() === raw.month - 1 &&
1356+
candidate.getDate() === raw.day
1357+
date = normalized ? candidate : ZERO_DATE
13161358
}
1359+
13171360
return {
13181361
len: 4,
13191362
value: date,
1363+
raw,
13201364
}
13211365
}
13221366

13231367
const decodeDateSafe = (
13241368
buffer: Buffer,
13251369
offset: number,
13261370
lenValue: number,
1327-
): Decode<Date> => {
1371+
): Decode<Date> & {
1372+
raw?: { year: number; month: number; day: number; wday: number }
1373+
} => {
13281374
if (lenValue !== 4) {
13291375
return {
13301376
len: lenValue,
@@ -1499,6 +1545,9 @@ const bacappDecodeData = (
14991545
result = decodeDateSafe(buffer, offset, lenValueType)
15001546
value.len += result.len
15011547
value.value = result.value
1548+
if (result.raw) {
1549+
value.raw = result.raw
1550+
}
15021551
break
15031552
case ApplicationTag.TIME:
15041553
result = decodeBacnetTimeSafe(buffer, offset, lenValueType)

src/lib/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ export interface BACNetAppData<
190190
> {
191191
type: Tag
192192
value: Type
193+
raw?: unknown
193194
encoding?: number
194195
}
195196

test/unit/asn1.spec.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,76 @@ test.describe('bacnet - ASN1 layer', () => {
138138
})
139139
})
140140

141+
test.describe('decodeDate', () => {
142+
test('should decode full wildcard date to ZERO_DATE and preserve raw', () => {
143+
const result = baAsn1.decodeDate(
144+
Buffer.from([0xff, 0xff, 0xff, 0xff]),
145+
0,
146+
)
147+
assert.equal(result.len, 4)
148+
assert.equal(result.value.getTime(), baAsn1.ZERO_DATE.getTime())
149+
assert.deepStrictEqual(result.raw, {
150+
year: 0xff,
151+
month: 0xff,
152+
day: 0xff,
153+
wday: 0xff,
154+
})
155+
})
156+
157+
test('should decode partial wildcard date to ZERO_DATE and preserve raw', () => {
158+
const result = baAsn1.decodeDate(
159+
Buffer.from([0xff, 0xff, 17, 0xff]),
160+
0,
161+
)
162+
assert.equal(result.len, 4)
163+
assert.equal(result.value.getTime(), baAsn1.ZERO_DATE.getTime())
164+
assert.deepStrictEqual(result.raw, {
165+
year: 0xff,
166+
month: 0xff,
167+
day: 17,
168+
wday: 0xff,
169+
})
170+
})
171+
172+
test('should decode invalid concrete date to ZERO_DATE and preserve raw', () => {
173+
const result = baAsn1.decodeDate(Buffer.from([124, 0, 32, 2]), 0)
174+
assert.equal(result.len, 4)
175+
assert.equal(result.value.getTime(), baAsn1.ZERO_DATE.getTime())
176+
assert.deepStrictEqual(result.raw, {
177+
year: 124,
178+
month: 0,
179+
day: 32,
180+
wday: 2,
181+
})
182+
})
183+
184+
test('should decode non-normalized concrete date to ZERO_DATE', () => {
185+
const result = baAsn1.decodeDate(Buffer.from([124, 2, 31, 5]), 0)
186+
assert.equal(result.len, 4)
187+
assert.equal(result.value.getTime(), baAsn1.ZERO_DATE.getTime())
188+
assert.deepStrictEqual(result.raw, {
189+
year: 124,
190+
month: 2,
191+
day: 31,
192+
wday: 5,
193+
})
194+
})
195+
196+
test('should decode valid concrete date and preserve raw', () => {
197+
const result = baAsn1.decodeDate(Buffer.from([124, 12, 31, 2]), 0)
198+
assert.equal(result.len, 4)
199+
assert.equal(result.value.getFullYear(), 2024)
200+
assert.equal(result.value.getMonth(), 11)
201+
assert.equal(result.value.getDate(), 31)
202+
assert.deepStrictEqual(result.raw, {
203+
year: 124,
204+
month: 12,
205+
day: 31,
206+
wday: 2,
207+
})
208+
})
209+
})
210+
141211
test.describe('decodeWeekNDay', () => {
142212
test('should decode valid WEEKNDAY payload', () => {
143213
const buffer = { buffer: Buffer.alloc(8), offset: 0 }

test/unit/service-read-property.spec.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ const encodeRawDate = (buffer: any, value: Date) => {
1818
buffer.buffer[buffer.offset++] = value.getDay() || 7
1919
}
2020

21+
const encodeRawDateParts = (
22+
buffer: any,
23+
value: { year: number; month: number; day: number; wday: number },
24+
) => {
25+
buffer.buffer[buffer.offset++] = value.year
26+
buffer.buffer[buffer.offset++] = value.month
27+
buffer.buffer[buffer.offset++] = value.day
28+
buffer.buffer[buffer.offset++] = value.wday
29+
}
30+
2131
const encodeReadPropertyAckHeader = (
2232
buffer: any,
2333
objectType: number,
@@ -185,6 +195,115 @@ test.describe('ReadPropertyAcknowledge schedule/calendar compatibility', () => {
185195
assert.equal(weekdayEntry.date.value.wday, 1)
186196
})
187197

198+
test('should preserve raw date for partial wildcard single date in exception schedule', () => {
199+
const buffer = utils.getBuffer()
200+
encodeReadPropertyAckHeader(
201+
buffer,
202+
ObjectType.SCHEDULE,
203+
17,
204+
PropertyIdentifier.EXCEPTION_SCHEDULE,
205+
)
206+
207+
baAsn1.encodeOpeningTag(buffer, 0)
208+
baAsn1.encodeTag(buffer, 0, true, 4)
209+
encodeRawDateParts(buffer, {
210+
year: 0xff,
211+
month: 0xff,
212+
day: 17,
213+
wday: 0xff,
214+
})
215+
baAsn1.encodeClosingTag(buffer, 0)
216+
baAsn1.encodeOpeningTag(buffer, 2)
217+
baAsn1.bacappEncodeApplicationData(buffer, {
218+
type: ApplicationTag.TIME,
219+
value: new Date(2024, 0, 2, 6, 30, 0, 0),
220+
})
221+
baAsn1.bacappEncodeApplicationData(buffer, {
222+
type: ApplicationTag.ENUMERATED,
223+
value: 1,
224+
})
225+
baAsn1.encodeClosingTag(buffer, 2)
226+
baAsn1.bacappEncodeApplicationData(buffer, {
227+
type: ApplicationTag.UNSIGNED_INTEGER,
228+
value: 8,
229+
})
230+
baAsn1.encodeClosingTag(buffer, 3)
231+
232+
const result = ReadProperty.decodeAcknowledge(
233+
buffer.buffer,
234+
0,
235+
buffer.offset,
236+
)
237+
assert.ok(result)
238+
const specialEvent = result.values[0]
239+
const values = specialEvent.value as any[]
240+
assert.equal(values.length, 1)
241+
const date = values[0].date
242+
assert.equal(date.type, ApplicationTag.DATE)
243+
// Partial wildcard raw dates normalize to ZERO_DATE in value while raw preserves source bytes.
244+
assert.equal(date.value.getTime(), baAsn1.ZERO_DATE.getTime())
245+
assert.deepStrictEqual(date.raw, {
246+
year: 0xff,
247+
month: 0xff,
248+
day: 17,
249+
wday: 0xff,
250+
})
251+
})
252+
253+
test('should fall back to ZERO_DATE for invalid concrete raw date in exception schedule', () => {
254+
const buffer = utils.getBuffer()
255+
encodeReadPropertyAckHeader(
256+
buffer,
257+
ObjectType.SCHEDULE,
258+
17,
259+
PropertyIdentifier.EXCEPTION_SCHEDULE,
260+
)
261+
262+
baAsn1.encodeOpeningTag(buffer, 0)
263+
baAsn1.encodeTag(buffer, 0, true, 4)
264+
encodeRawDateParts(buffer, {
265+
year: 124,
266+
month: 0,
267+
day: 32,
268+
wday: 2,
269+
})
270+
baAsn1.encodeClosingTag(buffer, 0)
271+
baAsn1.encodeOpeningTag(buffer, 2)
272+
baAsn1.bacappEncodeApplicationData(buffer, {
273+
type: ApplicationTag.TIME,
274+
value: new Date(2024, 0, 2, 6, 30, 0, 0),
275+
})
276+
baAsn1.bacappEncodeApplicationData(buffer, {
277+
type: ApplicationTag.ENUMERATED,
278+
value: 1,
279+
})
280+
baAsn1.encodeClosingTag(buffer, 2)
281+
baAsn1.bacappEncodeApplicationData(buffer, {
282+
type: ApplicationTag.UNSIGNED_INTEGER,
283+
value: 8,
284+
})
285+
baAsn1.encodeClosingTag(buffer, 3)
286+
287+
const result = ReadProperty.decodeAcknowledge(
288+
buffer.buffer,
289+
0,
290+
buffer.offset,
291+
)
292+
assert.ok(result)
293+
const specialEvent = result.values[0]
294+
const values = specialEvent.value as any[]
295+
assert.equal(values.length, 1)
296+
const date = values[0].date
297+
assert.equal(date.type, ApplicationTag.DATE)
298+
assert.equal(date.value.getTime(), baAsn1.ZERO_DATE.getTime())
299+
assert.deepStrictEqual(date.raw, {
300+
year: 124,
301+
month: 0,
302+
day: 32,
303+
wday: 2,
304+
})
305+
})
306+
188307
test('should decode schedule effective period payload', () => {
189308
const buffer = utils.getBuffer()
190309
encodeReadPropertyAckHeader(
@@ -218,6 +337,59 @@ test.describe('ReadPropertyAcknowledge schedule/calendar compatibility', () => {
218337
assert.ok(values[1].value instanceof Date)
219338
})
220339

340+
test('should preserve raw date range with partial wildcards in effective period', () => {
341+
const buffer = utils.getBuffer()
342+
encodeReadPropertyAckHeader(
343+
buffer,
344+
ObjectType.SCHEDULE,
345+
17,
346+
PropertyIdentifier.EFFECTIVE_PERIOD,
347+
)
348+
349+
baAsn1.encodeTag(buffer, ApplicationTag.DATE, true, 4)
350+
encodeRawDateParts(buffer, {
351+
year: 121, // 2021
352+
month: 0xff,
353+
day: 28,
354+
wday: 0xff,
355+
})
356+
baAsn1.encodeTag(buffer, ApplicationTag.DATE, true, 4)
357+
encodeRawDateParts(buffer, {
358+
year: 132, // 2032
359+
month: 0xff,
360+
day: 30,
361+
wday: 0xff,
362+
})
363+
baAsn1.encodeClosingTag(buffer, 3)
364+
365+
const result = ReadProperty.decodeAcknowledge(
366+
buffer.buffer,
367+
0,
368+
buffer.offset,
369+
)
370+
assert.ok(result)
371+
const dateRange = result.values[0]
372+
assert.equal(dateRange.type, ApplicationTag.DATERANGE)
373+
const values = dateRange.value as any[]
374+
assert.equal(values.length, 2)
375+
// Partial wildcard raw dates (e.g. month=0xff) cannot be represented as concrete JS Date values.
376+
// The decoder therefore normalizes value to ZERO_DATE while preserving original raw bytes.
377+
assert.equal(values[0].value.getTime(), baAsn1.ZERO_DATE.getTime())
378+
assert.equal(values[1].value.getTime(), baAsn1.ZERO_DATE.getTime())
379+
assert.deepStrictEqual(values[0].raw, {
380+
year: 121,
381+
month: 0xff,
382+
day: 28,
383+
wday: 0xff,
384+
})
385+
assert.deepStrictEqual(values[1].raw, {
386+
year: 132,
387+
month: 0xff,
388+
day: 30,
389+
wday: 0xff,
390+
})
391+
})
392+
221393
test('should decode calendar date list payload', () => {
222394
const buffer = utils.getBuffer()
223395
encodeReadPropertyAckHeader(

0 commit comments

Comments
 (0)