From 83fc4b351e7fb19d6189db2cbb3bcc66c1db7f04 Mon Sep 17 00:00:00 2001 From: Steve Karg Date: Wed, 1 Apr 2026 16:27:36 -0400 Subject: [PATCH] Implement parsing for BACnetSpecialEvent in bacapp and add unit tests (#1290) --- CHANGELOG.md | 17 +- src/bacnet/bacapp.c | 374 +++++++++++++++++++++++++++++++++- test/bacnet/bacapp/src/main.c | 48 +++++ 3 files changed, 437 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f643588..370d8a4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ The git repositories are hosted at the following sites: * * -## [Unreleased] - 2026-03-27 +## [Unreleased] - 2026-04-01 ### Security @@ -53,6 +53,15 @@ The git repositories are hosted at the following sites: ### Added +* Added parsing for BACnetSpecialEvent in bacapp for use in apps/writeproperty + and add unit tests. (#1290) +* Added multi-device support for BACnet gateway routing. Expanded Object_List + to Object_List[MAX_NUM_DEVICES] array to support per-device objects for + virtual remote devices. Added multi-device iteration for COV handler, + device timer, and intrinsic reporting. Added apps/gateway2 demo application. + When MAX_NUM_DEVICES == 1, behavior is identical to the original + gateway implementation. Conditional compilation with macros ensures + no impact on non-gateway applications. (#1279) * Added structured-view object subordinate-list add, remove, exist, same API for interfacing as a list. Added purge API for unit testing. (#1283) * Added ports/pico for Raspberry Pi Pico port of the BACnet stack @@ -204,6 +213,12 @@ The git repositories are hosted at the following sites: ### Fixed +* Fixed CMakeLists.txt by replacing BIG_ENDIAN definition with + BACNET_BIG_ENDIAN to fix missing function in builds. (#1284) +* Fixed CMakeLists.txt by adding INTRINSIC_REPORTING CMake option + to enable intrinsic reporting at build time. (#1275) +* Fixed missing keylist.h include in objects.h for OS_Keylist type + dependency (#1277) * Fixed lighting output and lighting command low and high trim fade. (#1268) * Fixed FQDN hostname size in minimal hostnport implementation. (#1263) * Fixed segmentation fault in Schedule_Recalculate_PV() during application diff --git a/src/bacnet/bacapp.c b/src/bacnet/bacapp.c index bd41e9cd..6e11c767 100644 --- a/src/bacnet/bacapp.c +++ b/src/bacnet/bacapp.c @@ -4424,6 +4424,378 @@ static bool object_property_reference_from_ascii( } #endif +#if defined(BACAPP_SPECIAL_EVENT) +/** + * @brief Find the next occurrence of sep_char at brace/paren/bracket depth 0 + * @param p - pointer into the string to search from + * @param sep_char - the separator character to find at depth 0 + * @return pointer to sep_char, or pointer to NUL terminator if not found + */ +static char *bacapp_find_depth0(char *p, char sep_char) +{ + int depth = 0; + + while (*p) { + if (*p == '(' || *p == '{' || *p == '[') { + depth++; + } else if (*p == ')' || *p == '}' || *p == ']') { + if (depth > 0) { + depth--; + } + } else if (*p == sep_char && depth == 0) { + return p; + } + p++; + } + return p; /* points to NUL when not found */ +} + +/** + * @brief Parse a BACnetWeekNDay from ASCII + * + * Format: month,week,day or {month,week,day} + * + * Each field may be numeric, a text name (resolved via bactext), or '*' + * (maps to 255). + * Special month names: "odd" (13), "even" (14). + * Special week name: "last" (6). + * + * @param wnd - output BACnetWeekNDay value + * @param str - input string (will be modified) + * @return true on parse success + */ +static bool weeknday_from_ascii(BACNET_WEEKNDAY *wnd, char *str) +{ + char *p, *tok[3]; + uint32_t found = 0; + int i; + + p = bacnet_trim(str, "{ }"); + if (!p || p[0] == '\0') { + return false; + } + tok[0] = strtok(p, ","); + tok[1] = strtok(NULL, ","); + tok[2] = strtok(NULL, ","); + if (!tok[0] || !tok[1] || !tok[2]) { + return false; + } + for (i = 0; i < 3; i++) { + tok[i] = bacnet_trim(tok[i], " "); + } + /* month: 1-12, 13=odd, 14=even, 255=any '*' */ + if (tok[0][0] == '*') { + wnd->month = 255; + } else if (bacnet_stricmp(tok[0], "odd") == 0) { + wnd->month = 13; + } else if (bacnet_stricmp(tok[0], "even") == 0) { + wnd->month = 14; + } else if (bactext_month_strtol(tok[0], &found)) { + wnd->month = (uint8_t)found; + } else { + wnd->month = (uint8_t)strtoul(tok[0], NULL, 10); + } + /* week-of-month: 1-5, 6=last, 255=any '*' */ + if (tok[1][0] == '*') { + wnd->weekofmonth = 255; + } else if (bacnet_stricmp(tok[1], "last") == 0) { + wnd->weekofmonth = 6; + } else if (bactext_week_of_month_strtol(tok[1], &found)) { + wnd->weekofmonth = (uint8_t)found; + } else { + wnd->weekofmonth = (uint8_t)strtoul(tok[1], NULL, 10); + } + /* day-of-week: 1=Monday..7=Sunday, 255=any '*' */ + if (tok[2][0] == '*') { + wnd->dayofweek = 255; + } else if (bactext_day_of_week_strtol(tok[2], &found)) { + wnd->dayofweek = (uint8_t)found; + } else { + wnd->dayofweek = (uint8_t)strtoul(tok[2], NULL, 10); + } + return true; +} + +/** + * @brief Parse a BACnetCalendarEntry from ASCII + * + * Pattern-based detection (no keyword required): + * starts with '{' weekNDay: {month,week,day} + * contains ".." date-range: YYYY/MM/DD..YYYY/MM/DD + * contains '/' date: YYYY/MM/DD or YYYY/MM/DD:wday + * + * @param entry - output BACnetCalendarEntry value + * @param str - input string (will be modified) + * @return true on parse success + */ +static bool calendar_entry_from_ascii(BACNET_CALENDAR_ENTRY *entry, char *str) +{ + char *dotdot; + + if (str[0] == '{') { + entry->tag = BACNET_CALENDAR_WEEK_N_DAY; + return weeknday_from_ascii(&entry->type.WeekNDay, str); + } + dotdot = strstr(str, ".."); + if (dotdot) { + entry->tag = BACNET_CALENDAR_DATE_RANGE; + *dotdot = '\0'; + if (!datetime_date_init_ascii(&entry->type.DateRange.startdate, str)) { + return false; + } + return datetime_date_init_ascii( + &entry->type.DateRange.enddate, dotdot + 2); + } + if (strchr(str, '/')) { + entry->tag = BACNET_CALENDAR_DATE; + return datetime_date_init_ascii(&entry->type.Date, str); + } + return false; +} + +/** + * @brief Parse a primitive schedule value from an ASCII token + * + * Tag is inferred from the token content: + * "null" BACNET_APPLICATION_TAG_NULL + * "true" / "active" BACNET_APPLICATION_TAG_BOOLEAN true + * "false" / "inactive" BACNET_APPLICATION_TAG_BOOLEAN false + * contains '.' BACNET_APPLICATION_TAG_REAL + * leading '-' BACNET_APPLICATION_TAG_SIGNED_INT + * pure digits BACNET_APPLICATION_TAG_UNSIGNED_INT + * + * @param prim - output primitive data value + * @param str - null-terminated value token (not modified) + * @return true on parse success + */ +static bool +primitive_from_ascii(BACNET_PRIMITIVE_DATA_VALUE *prim, const char *str) +{ + BACNET_APPLICATION_DATA_VALUE app_val = { 0 }; + + if (bacnet_stricmp(str, "null") == 0) { + prim->tag = BACNET_APPLICATION_TAG_NULL; + return true; + } + if (bacnet_stricmp(str, "true") == 0 || + bacnet_stricmp(str, "active") == 0) { + prim->tag = BACNET_APPLICATION_TAG_BOOLEAN; + prim->type.Boolean = true; + return true; + } + if (bacnet_stricmp(str, "false") == 0 || + bacnet_stricmp(str, "inactive") == 0) { + prim->tag = BACNET_APPLICATION_TAG_BOOLEAN; + prim->type.Boolean = false; + return true; + } + /* real: must contain a decimal point */ + if (strchr(str, '.')) { + if (bacapp_parse_application_data( + BACNET_APPLICATION_TAG_REAL, (char *)str, &app_val)) { + if (BACNET_STATUS_OK == + bacnet_application_to_primitive_data_value(prim, &app_val)) { + return true; + } + } + } + /* signed integer: starts with '-' */ + if (str[0] == '-') { + if (bacapp_parse_application_data( + BACNET_APPLICATION_TAG_SIGNED_INT, (char *)str, &app_val)) { + if (BACNET_STATUS_OK == + bacnet_application_to_primitive_data_value(prim, &app_val)) { + return true; + } + } + } + /* unsigned integer */ + if (bacapp_parse_application_data( + BACNET_APPLICATION_TAG_UNSIGNED_INT, (char *)str, &app_val)) { + if (BACNET_STATUS_OK == + bacnet_application_to_primitive_data_value(prim, &app_val)) { + return true; + } + } + return false; +} + +/** + * @brief Parse a BACnetDailySchedule from ASCII + * + * Format: {HH:MM:SS.FF,value,HH:MM:SS.FF,value,...} + * An empty schedule is {}. + * + * Tokens alternate: time, value, time, value ... + * The time token is always in HH:MM:SS.FF format. + * The value tag is auto-inferred from the token content. + * + * @param sched - output BACnetDailySchedule value + * @param str - input string (will be modified) + * @return true on parse success + */ +static bool daily_schedule_from_ascii(BACNET_DAILY_SCHEDULE *sched, char *str) +{ + char *p, *comma; + int tvnum = 0; + bool is_time = true; + BACNET_APPLICATION_DATA_VALUE tval = { 0 }; + + p = bacnet_trim(str, "{ }"); + sched->TV_Count = 0; + if (!p || p[0] == '\0') { + return true; /* empty schedule is valid */ + } + do { + comma = strchr(p, ','); + if (comma) { + *comma = '\0'; + } + p = bacnet_trim(p, " "); + if (is_time) { + if (tvnum >= BACNET_DAILY_SCHEDULE_TIME_VALUES_SIZE) { + return false; + } + if (!bacapp_parse_application_data( + BACNET_APPLICATION_TAG_TIME, p, &tval)) { + return false; + } + sched->Time_Values[tvnum].Time = tval.type.Time; + } else { + if (!primitive_from_ascii(&sched->Time_Values[tvnum].Value, p)) { + return false; + } + tvnum++; + } + is_time = !is_time; + p = comma ? comma + 1 : NULL; + } while (p && *p); + if (!is_time) { + /* dangling time token with no corresponding value */ + return false; + } + sched->TV_Count = (uint16_t)tvnum; + return true; +} + +/** + * @brief Parse a BACnetSpecialEvent from a single no-space ASCII argument + * + * Top-level format (three depth-0 comma-separated segments): + * period,{schedule},priority + * + * Period variants detected by pattern, no keyword required: + * starts with '{' weekNDay calendar entry: {month,week,day} + * contains ".." date-range entry: YYYY/MM/DD..YYYY/MM/DD + * contains '/' date entry: YYYY/MM/DD or YYYY/MM/DD:wday + * object-type-name:inst calendar reference (text via bactext) + * object-type-num:inst calendar reference (numeric object type) + * + * Priority is stored as-is without range validation, allowing deliberate + * out-of-range values for protocol-level testing. + * + * Examples (no spaces): + * "2023/10/24,{12:30:00.00,100,14:00:00.00,50},5" + * "2023/01/01..2023/12/31,{},8" + * "{10,1,Monday},{08:00:00.00,active},3" + * "calendar:5,{12:30:00.00,15,16:01:02.03,0},5" + * "6:5,{},10" + * + * @param value - output BACNET_APPLICATION_DATA_VALUE + * @param str - input string (will be modified) + * @return true on parse success + */ +static bool +special_event_from_ascii(BACNET_APPLICATION_DATA_VALUE *value, char *str) +{ + char *sep1, *sep2; + char *period_str, *sched_str, *prio_str; + BACNET_SPECIAL_EVENT *ev = &value->type.Special_Event; + BACNET_UNSIGNED_INTEGER prio = 0; + uint32_t obj_type = 0, obj_instance = 0; + char type_buf[64]; + char *colon; + size_t tlen; + int count; + + if (!str || !value) { + return false; + } + /* split into three top-level tokens at depth-0 commas */ + sep1 = bacapp_find_depth0(str, ','); + if (*sep1 == '\0') { + return false; + } + *sep1 = '\0'; + period_str = bacnet_trim(str, " "); + sep2 = bacapp_find_depth0(sep1 + 1, ','); + if (*sep2 == '\0') { + return false; + } + *sep2 = '\0'; + sched_str = bacnet_trim(sep1 + 1, " "); + prio_str = bacnet_trim(sep2 + 1, " "); + /* priority - no range check, allows out-of-range testing */ + if (!bacnet_string_to_unsigned(prio_str, &prio)) { + return false; + } + ev->priority = (uint8_t)prio; + /* schedule */ + if (!daily_schedule_from_ascii(&ev->timeValues, sched_str)) { + return false; + } + /* period: pattern-based detection */ + if (period_str[0] == '{') { + /* weekNDay calendar entry */ + ev->periodTag = BACNET_SPECIAL_EVENT_PERIOD_CALENDAR_ENTRY; + if (!calendar_entry_from_ascii(&ev->period.calendarEntry, period_str)) { + return false; + } + } else if (strstr(period_str, "..")) { + /* date-range calendar entry */ + ev->periodTag = BACNET_SPECIAL_EVENT_PERIOD_CALENDAR_ENTRY; + if (!calendar_entry_from_ascii(&ev->period.calendarEntry, period_str)) { + return false; + } + } else if (strchr(period_str, '/')) { + /* date calendar entry */ + ev->periodTag = BACNET_SPECIAL_EVENT_PERIOD_CALENDAR_ENTRY; + if (!calendar_entry_from_ascii(&ev->period.calendarEntry, period_str)) { + return false; + } + } else { + /* calendar reference: object-type-name:instance or numeric:instance */ + ev->periodTag = BACNET_SPECIAL_EVENT_PERIOD_CALENDAR_REFERENCE; + colon = strchr(period_str, ':'); + if (!colon) { + return false; + } + tlen = (size_t)(colon - period_str); + if (tlen >= sizeof(type_buf)) { + tlen = sizeof(type_buf) - 1; + } + memcpy(type_buf, period_str, tlen); + type_buf[tlen] = '\0'; + count = sscanf(colon + 1, "%7u", &obj_instance); + if (count != 1) { + return false; + } + ev->period.calendarReference.instance = obj_instance; + if (bactext_object_type_strtol(type_buf, &obj_type)) { + ev->period.calendarReference.type = (BACNET_OBJECT_TYPE)obj_type; + } else { + count = sscanf(type_buf, "%4u", &obj_type); + if (count != 1) { + return false; + } + ev->period.calendarReference.type = (BACNET_OBJECT_TYPE)obj_type; + } + } + value->tag = BACNET_APPLICATION_TAG_SPECIAL_EVENT; + return true; +} +#endif /* BACAPP_SPECIAL_EVENT */ + /* used to load the app data struct with the proper data converted from a command line argument. "argv" is not const to allow using strtok internally. It MAY be modified. */ @@ -4632,7 +5004,7 @@ bool bacapp_parse_application_data( #endif #if defined(BACAPP_SPECIAL_EVENT) case BACNET_APPLICATION_TAG_SPECIAL_EVENT: - /* FIXME: add parsing for BACnetSpecialEvent */ + status = special_event_from_ascii(value, argv); break; #endif #if defined(BACAPP_CALENDAR_ENTRY) diff --git a/test/bacnet/bacapp/src/main.c b/test/bacnet/bacapp/src/main.c index 2461a466..76af4fd5 100644 --- a/test/bacnet/bacapp/src/main.c +++ b/test/bacnet/bacapp/src/main.c @@ -1235,6 +1235,54 @@ static void testBACnetApplicationData(void) verifyBACnetComplexDataValue( &value, OBJECT_NETWORK_PORT, PROP_BBMD_BROADCAST_DISTRIBUTION_TABLE); + char special_event_calendar[] = + "calendar:5,{12:30:15.05,15,16:01:02.03,0},5"; + status = bacapp_parse_application_data( + BACNET_APPLICATION_TAG_SPECIAL_EVENT, special_event_calendar, &value); + zassert_true(status, NULL); + zassert_equal( + value.type.Special_Event.periodTag, + BACNET_SPECIAL_EVENT_PERIOD_CALENDAR_REFERENCE, NULL); + zassert_equal( + value.type.Special_Event.period.calendarReference.type, OBJECT_CALENDAR, + NULL); + zassert_equal( + value.type.Special_Event.period.calendarReference.instance, 5, NULL); + zassert_equal(value.type.Special_Event.timeValues.TV_Count, 2, NULL); + zassert_equal(value.type.Special_Event.priority, 5, NULL); + verifyBACnetComplexDataValue( + &value, OBJECT_SCHEDULE, PROP_EXCEPTION_SCHEDULE); + + char special_event_date[] = "2023/10/24,{08:00:00.00,active},3"; + status = bacapp_parse_application_data( + BACNET_APPLICATION_TAG_SPECIAL_EVENT, special_event_date, &value); + zassert_true(status, NULL); + zassert_equal( + value.type.Special_Event.periodTag, + BACNET_SPECIAL_EVENT_PERIOD_CALENDAR_ENTRY, NULL); + zassert_equal( + value.type.Special_Event.period.calendarEntry.tag, BACNET_CALENDAR_DATE, + NULL); + zassert_equal(value.type.Special_Event.timeValues.TV_Count, 1, NULL); + zassert_equal(value.type.Special_Event.priority, 3, NULL); + verifyBACnetComplexDataValue( + &value, OBJECT_SCHEDULE, PROP_EXCEPTION_SCHEDULE); + + char special_event_empty[] = "2023/10/24,{},255"; + status = bacapp_parse_application_data( + BACNET_APPLICATION_TAG_SPECIAL_EVENT, special_event_empty, &value); + zassert_true(status, NULL); + zassert_equal(value.type.Special_Event.priority, 255, NULL); + zassert_equal(value.type.Special_Event.timeValues.TV_Count, 0, NULL); + { + uint8_t apdu_special[480] = { 0 }; + int special_len = bacapp_encode_application_data(NULL, &value); + zassert_true(special_len > 0, NULL); + zassert_equal( + bacapp_encode_application_data(apdu_special, &value), special_len, + NULL); + } + return; }