Implement parsing for BACnetSpecialEvent in bacapp and add unit tests (#1290)

This commit is contained in:
Steve Karg
2026-04-01 16:27:36 -04:00
committed by GitHub
parent 236250492e
commit 83fc4b351e
3 changed files with 437 additions and 2 deletions
+16 -1
View File
@@ -13,7 +13,7 @@ The git repositories are hosted at the following sites:
* <https://bacnet.sourceforge.net/> * <https://bacnet.sourceforge.net/>
* <https://github.com/bacnet-stack/bacnet-stack/> * <https://github.com/bacnet-stack/bacnet-stack/>
## [Unreleased] - 2026-03-27 ## [Unreleased] - 2026-04-01
### Security ### Security
@@ -53,6 +53,15 @@ The git repositories are hosted at the following sites:
### Added ### 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 * Added structured-view object subordinate-list add, remove, exist, same
API for interfacing as a list. Added purge API for unit testing. (#1283) 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 * 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
* 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 lighting output and lighting command low and high trim fade. (#1268)
* Fixed FQDN hostname size in minimal hostnport implementation. (#1263) * Fixed FQDN hostname size in minimal hostnport implementation. (#1263)
* Fixed segmentation fault in Schedule_Recalculate_PV() during application * Fixed segmentation fault in Schedule_Recalculate_PV() during application
+373 -1
View File
@@ -4424,6 +4424,378 @@ static bool object_property_reference_from_ascii(
} }
#endif #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 /* used to load the app data struct with the proper data
converted from a command line argument. converted from a command line argument.
"argv" is not const to allow using strtok internally. It MAY be modified. */ "argv" is not const to allow using strtok internally. It MAY be modified. */
@@ -4632,7 +5004,7 @@ bool bacapp_parse_application_data(
#endif #endif
#if defined(BACAPP_SPECIAL_EVENT) #if defined(BACAPP_SPECIAL_EVENT)
case BACNET_APPLICATION_TAG_SPECIAL_EVENT: case BACNET_APPLICATION_TAG_SPECIAL_EVENT:
/* FIXME: add parsing for BACnetSpecialEvent */ status = special_event_from_ascii(value, argv);
break; break;
#endif #endif
#if defined(BACAPP_CALENDAR_ENTRY) #if defined(BACAPP_CALENDAR_ENTRY)
+48
View File
@@ -1235,6 +1235,54 @@ static void testBACnetApplicationData(void)
verifyBACnetComplexDataValue( verifyBACnetComplexDataValue(
&value, OBJECT_NETWORK_PORT, PROP_BBMD_BROADCAST_DISTRIBUTION_TABLE); &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; return;
} }