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;
}