From d3d33b452f8f259d6be97c934ed0c1846c064ccb Mon Sep 17 00:00:00 2001
From: s-hadinger <49731213+s-hadinger@users.noreply.github.com>
Date: Fri, 29 May 2026 13:41:31 +0200
Subject: [PATCH] Trigger events to Berry when 'USE_RULES' is not enabled
(#24796)
* Trigger events to Berry when 'USE_RULES' is not enabled
* replace long with int32_t
* Fix SystemEventsSetNewPower signature
---
CHANGELOG.md | 1 +
tasmota/tasmota_xdrv_driver/xdrv_10_rules.ino | 194 ++-----
.../xdrv_10_system_events.ino | 474 ++++++++++++++++++
.../tasmota_xx2c_global/xdrv_interface.ino | 11 +-
4 files changed, 524 insertions(+), 156 deletions(-)
create mode 100644 tasmota/tasmota_xdrv_driver/xdrv_10_system_events.ino
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ada025c28..86a33a156 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ All notable changes to this project will be documented in this file.
- ESP32 VID6608 hardware RMT support for Automotive gauge driver (#24759)
- Support for uDisplay ST7305 (#24738)
- Support for hosted MCU different from esp32c6
+- Trigger events to Berry when `USE_RULES` is not enabled
### Breaking Changed
diff --git a/tasmota/tasmota_xdrv_driver/xdrv_10_rules.ino b/tasmota/tasmota_xdrv_driver/xdrv_10_rules.ino
index 1db5ad24b..b8f6a68a9 100644
--- a/tasmota/tasmota_xdrv_driver/xdrv_10_rules.ino
+++ b/tasmota/tasmota_xdrv_driver/xdrv_10_rules.ino
@@ -140,6 +140,29 @@ const uint8_t kExpressionOperatorsPriorities[] PROGMEM = {1, 1, 2, 2, 3, 4};
#define BERRY_RULES 0
#endif
+// Forward declarations for the shared System Events helpers defined in
+// xdrv_10_system_events.ino. These symbols are always compiled (independent of
+// USE_RULES / USE_SCRIPT / USE_BERRY) and are linked into every build. The
+// Rules dispatcher (Xdrv10) calls Init/Every50ms/EverySecond/SaveBeforeRestart;
+// the kRulesCommands table below resolves &CmndEvent to the externally-defined
+// CmndEvent in xdrv_10_system_events.ino.
+//
+// SystemEventsSetTeleperiod / SystemEventsSetNewPower are thin setters exposed because
+// struct SYSTEM_EVENTS is file-local to xdrv_10_system_events.ino and not visible from
+// this translation unit. The Rules Xdrv10 dispatcher calls SystemEventsSetTeleperiod
+// around FUNC_TELEPERIOD_RULES_PROCESS to mirror Rules.teleperiod onto
+// SystemEvents.teleperiod; RulesSetPower (FUNC_SET_POWER) calls SystemEventsSetNewPower
+// to snapshot the current power mask for the next 50ms tick.
+extern void SystemEventsInit(void);
+extern bool SystemEventsEvery50msPowerDimmerEvent(void);
+extern void SystemEventsEvery50msRulesFlag(void);
+extern void SystemEventsEvery50ms(void);
+extern void SystemEventsEverySecond(void);
+extern void SystemEventsSaveBeforeRestart(void);
+extern void SystemEventsSetTeleperiod(bool v);
+extern void SystemEventsSetNewPower(int32_t v);
+extern void CmndEvent(void);
+
const char kRulesCommands[] PROGMEM = "|" // No prefix
D_CMND_RULE "|" D_CMND_RULETIMER "|" D_CMND_EVENT "|" D_CMND_VAR "|" D_CMND_MEM "|"
D_CMND_ADD "|" D_CMND_SUB "|" D_CMND_MULT "|" D_CMND_SCALE "|" D_CMND_CALC_RESOLUTION
@@ -164,18 +187,11 @@ struct RULES {
uint32_t triggers[MAX_RULE_SETS] = { 0 };
uint8_t trigger_count[MAX_RULE_SETS] = { 0 };
- long new_power = -1;
- long old_power = -1;
- long old_dimm = -1;
-
- uint16_t last_minute = 60;
uint16_t vars_event = 0; // Bitmask supporting MAX_RULE_VARS bits
uint16_t mems_event = 0; // Bitmask supporting MAX_RULE_MEMS bits
bool teleperiod = false;
bool busy = false;
bool no_execute = false; // Don't actually execute rule commands
-
- char event_data[RULE_MAX_EVENTSZ];
} Rules;
char rules_vars[MAX_RULE_VARS][33] = {{ 0 }};
@@ -940,63 +956,12 @@ void RulesEvery50ms(void)
if ((Settings->rule_enabled || BERRY_RULES) && !Rules.busy) { // Any rule enabled
char json_event[RULE_MAX_EVENTSZ +16]; // Add 16 chars for {"Event": .. }
- if (-1 == Rules.new_power) { Rules.new_power = TasmotaGlobal.power; }
- if (Rules.new_power != Rules.old_power) {
- if (Rules.old_power != -1) {
- for (uint32_t i = 0; i < TasmotaGlobal.devices_present; i++) {
- uint8_t new_state = (Rules.new_power >> i) &1;
- if (new_state != ((Rules.old_power >> i) &1)) {
- snprintf_P(json_event, sizeof(json_event), PSTR("{\"Power%d\":{\"State\":%d}}"), i +1, new_state);
- RulesProcessEvent(json_event);
- }
- }
- } else {
- // Boot time POWER OUTPUTS (Relays) Status
- for (uint32_t i = 0; i < TasmotaGlobal.devices_present; i++) {
- uint8_t new_state = (Rules.new_power >> i) &1;
- snprintf_P(json_event, sizeof(json_event), PSTR("{\"Power%d\":{\"Boot\":%d}}"), i +1, new_state);
- RulesProcessEvent(json_event);
- }
- // Boot time SWITCHES Status
- for (uint32_t i = 0; i < MAX_SWITCHES_SET; i++) {
- if (SwitchUsed(i)) {
- snprintf_P(json_event, sizeof(json_event), PSTR("{\"%s\":{\"Boot\":%d}}"), GetSwitchText(i).c_str(), (SwitchState(i)));
- RulesProcessEvent(json_event);
- }
- }
- }
- Rules.old_power = Rules.new_power;
- }
- else if (Rules.old_dimm != Settings->light_dimmer) {
- if (Rules.old_dimm != -1) {
- snprintf_P(json_event, sizeof(json_event), PSTR("{\"Dimmer\":{\"State\":%d}}"), Settings->light_dimmer);
- } else {
- // Boot time DIMMER VALUE
- snprintf_P(json_event, sizeof(json_event), PSTR("{\"Dimmer\":{\"Boot\":%d}}"), Settings->light_dimmer);
- }
- RulesProcessEvent(json_event);
- Rules.old_dimm = Settings->light_dimmer;
- }
- else if (Rules.event_data[0]) {
- char *event;
- char *parameter;
- event = strtok_r(Rules.event_data, "=", ¶meter); // Rules.event_data = fanspeed=10
- if (event) {
- event = Trim(event);
- if (parameter) {
- parameter = Trim(parameter);
- } else {
- parameter = event + strlen(event); // '\0'
- }
- bool quotes = (parameter[0] != '{');
- snprintf_P(json_event, sizeof(json_event), PSTR("{\"Event\":{\"%s\":%s%s%s}}"), event, (quotes)?"\"":"", parameter, (quotes)?"\"":"");
- Rules.event_data[0] ='\0';
- RulesProcessEvent(json_event);
- } else {
- Rules.event_data[0] ='\0';
- }
- }
- else if (Rules.vars_event || Rules.mems_event){
+ // Strict-priority drain to match pre-migration semantics:
+ // Power -> Dimmer -> Event (System Events) -> Vars/Mems (Rules) -> rules_flag (System Events)
+ // Each higher-priority branch yields the tick when it emits.
+ if (SystemEventsEvery50msPowerDimmerEvent()) { return; }
+
+ if (Rules.vars_event || Rules.mems_event) {
if (Rules.vars_event) {
for (uint32_t i = 0; i < MAX_RULE_VARS; i++) {
if (bitRead(Rules.vars_event, i)) {
@@ -1017,70 +982,10 @@ void RulesEvery50ms(void)
}
}
}
+ return;
}
- else if (TasmotaGlobal.rules_flag.data) {
- json_event[0] = '\0';
- if (TasmotaGlobal.rules_flag.system_init) {
- TasmotaGlobal.rules_flag.system_init = 0;
- strncpy_P(json_event, PSTR("{\"System\":{\"Init\":1}}"), sizeof(json_event));
- }
- else if (TasmotaGlobal.rules_flag.system_boot) {
- TasmotaGlobal.rules_flag.system_boot = 0;
- strncpy_P(json_event, PSTR("{\"System\":{\"Boot\":1}}"), sizeof(json_event));
- }
- else if (TasmotaGlobal.rules_flag.time_init) {
- TasmotaGlobal.rules_flag.time_init = 0;
- snprintf_P(json_event, sizeof(json_event), PSTR("{\"Time\":{\"Initialized\":%d}}"), MinutesPastMidnight());
- }
- else if (TasmotaGlobal.rules_flag.time_set) {
- TasmotaGlobal.rules_flag.time_set = 0;
- snprintf_P(json_event, sizeof(json_event), PSTR("{\"Time\":{\"Set\":%d}}"), MinutesPastMidnight());
- }
- else if (TasmotaGlobal.rules_flag.mqtt_connected) {
- TasmotaGlobal.rules_flag.mqtt_connected = 0;
- strncpy_P(json_event, PSTR("{\"MQTT\":{\"Connected\":1}}"), sizeof(json_event));
- }
- else if (TasmotaGlobal.rules_flag.mqtt_disconnected) {
- TasmotaGlobal.rules_flag.mqtt_disconnected = 0;
- strncpy_P(json_event, PSTR("{\"MQTT\":{\"Disconnected\":1}}"), sizeof(json_event));
- }
- else if (TasmotaGlobal.rules_flag.wifi_connected) {
- TasmotaGlobal.rules_flag.wifi_connected = 0;
- strncpy_P(json_event, PSTR("{\"WIFI\":{\"Connected\":1}}"), sizeof(json_event));
- }
- else if (TasmotaGlobal.rules_flag.wifi_disconnected) {
- TasmotaGlobal.rules_flag.wifi_disconnected = 0;
- strncpy_P(json_event, PSTR("{\"WIFI\":{\"Disconnected\":1}}"), sizeof(json_event));
- }
-//#if defined(ESP32) && CONFIG_IDF_TARGET_ESP32 && defined(USE_ETHERNET)
-#if defined(ESP32) && defined(USE_ETHERNET)
- else if (TasmotaGlobal.rules_flag.eth_connected) {
- TasmotaGlobal.rules_flag.eth_connected = 0;
- strncpy_P(json_event, PSTR("{\"ETH\":{\"Connected\":1}}"), sizeof(json_event));
- }
- else if (TasmotaGlobal.rules_flag.eth_disconnected) {
- TasmotaGlobal.rules_flag.eth_disconnected = 0;
- strncpy_P(json_event, PSTR("{\"ETH\":{\"Disconnected\":1}}"), sizeof(json_event));
- }
-#endif // USE_ETHERNET
- else if (TasmotaGlobal.rules_flag.http_init) {
- TasmotaGlobal.rules_flag.http_init = 0;
- strncpy_P(json_event, PSTR("{\"HTTP\":{\"Initialized\":1}}"), sizeof(json_event));
- }
-#ifdef USE_SHUTTER
- else if (TasmotaGlobal.rules_flag.shutter_moved) {
- TasmotaGlobal.rules_flag.shutter_moved = 0;
- strncpy_P(json_event, PSTR("{\"SHUTTER\":{\"Moved\":1}}"), sizeof(json_event));
- }
- else if (TasmotaGlobal.rules_flag.shutter_moving) {
- TasmotaGlobal.rules_flag.shutter_moving = 0;
- strncpy_P(json_event, PSTR("{\"SHUTTER\":{\"Moving\":1}}"), sizeof(json_event));
- }
-#endif // USE_SHUTTER
- if (json_event[0]) {
- RulesProcessEvent(json_event); // Only service one event within 50mS
- }
- }
+
+ SystemEventsEvery50msRulesFlag();
}
}
@@ -1096,13 +1001,7 @@ void RulesEverySecond(void)
{
char json_event[120];
if ((Settings->rule_enabled || BERRY_RULES) && !Rules.busy) { // Any rule enabled
- if (RtcTime.valid) {
- if ((TasmotaGlobal.uptime > 60) && (RtcTime.minute != Rules.last_minute)) { // Execute from one minute after restart every minute only once
- Rules.last_minute = RtcTime.minute;
- snprintf_P(json_event, sizeof(json_event), PSTR("{\"Time\":{\"Minute\":%d}}"), MinutesPastMidnight());
- RulesProcessEvent(json_event);
- }
- }
+ SystemEventsEverySecond(); // Time#Minute (shared producer, gated to preserve pre-migration semantics)
}
for (uint32_t i = 0; i < MAX_RULE_TIMERS; i++) {
if (Rules.timer[i] != 0L) { // Timer active?
@@ -1120,10 +1019,7 @@ void RulesEverySecond(void)
void RulesSaveBeforeRestart(void)
{
if ((Settings->rule_enabled || BERRY_RULES) && !Rules.busy) { // Any rule enabled
- char json_event[32];
-
- strncpy_P(json_event, PSTR("{\"System\":{\"Save\":1}}"), sizeof(json_event));
- RulesProcessEvent(json_event);
+ SystemEventsSaveBeforeRestart(); // System#Save (shared producer, gated to preserve pre-migration semantics)
}
}
@@ -1147,7 +1043,7 @@ void RulesShow() {
void RulesSetPower(void)
{
- Rules.new_power = XdrvMailbox.index;
+ SystemEventsSetNewPower(XdrvMailbox.index);
}
#ifdef SUPPORT_MQTT_EVENT
@@ -2352,17 +2248,6 @@ void CmndRuleTimer(void)
ResponseJsonEnd();
}
-void CmndEvent(void)
-{
- if (XdrvMailbox.data_len > 0) {
- strlcpy(Rules.event_data, XdrvMailbox.data, sizeof(Rules.event_data));
-#ifdef USE_DEVICE_GROUPS
- if (!XdrvMailbox.grpflg) SendDeviceGroupMessage(1, DGR_MSGTYP_UPDATE, DGR_ITEM_EVENT, XdrvMailbox.data);
-#endif // USE_DEVICE_GROUPS
- }
- if (XdrvMailbox.command) ResponseCmndDone();
-}
-
void CmndVariable(void)
{
if ((XdrvMailbox.index > 0) && (XdrvMailbox.index <= MAX_RULE_VARS)) {
@@ -2484,13 +2369,13 @@ bool Xdrv10(uint32_t function)
switch (function) {
case FUNC_EVERY_50_MSECOND:
- RulesEvery50ms();
+ RulesEvery50ms(); // drives full priority ladder: Power/Dimmer/Event -> Vars/Mems -> rules_flag
break;
case FUNC_EVERY_100_MSECOND:
RulesEvery100ms();
break;
case FUNC_EVERY_SECOND:
- RulesEverySecond();
+ RulesEverySecond(); // drives Time#Minute (gated) + Rules.timer[] (RuleTimer)
break;
case FUNC_SET_POWER:
RulesSetPower();
@@ -2503,11 +2388,13 @@ bool Xdrv10(uint32_t function)
break;
case FUNC_TELEPERIOD_RULES_PROCESS:
Rules.teleperiod = true;
+ SystemEventsSetTeleperiod(true); // mirror, since SystemEventsEvery50ms reads it
result = RulesProcess();
Rules.teleperiod = false;
+ SystemEventsSetTeleperiod(false);
break;
case FUNC_SAVE_BEFORE_RESTART:
- RulesSaveBeforeRestart();
+ RulesSaveBeforeRestart(); // gated wrapper around SystemEventsSaveBeforeRestart
break;
#ifdef SUPPORT_MQTT_EVENT
case FUNC_MQTT_DATA:
@@ -2516,6 +2403,7 @@ bool Xdrv10(uint32_t function)
#endif // SUPPORT_MQTT_EVENT
case FUNC_PRE_INIT:
RulesInit();
+ SystemEventsInit();
break;
#ifdef USE_WEBSERVER
#ifdef USE_VIEW_RULE_MEMS_AND_VARS
diff --git a/tasmota/tasmota_xdrv_driver/xdrv_10_system_events.ino b/tasmota/tasmota_xdrv_driver/xdrv_10_system_events.ino
new file mode 100644
index 000000000..63bdb353b
--- /dev/null
+++ b/tasmota/tasmota_xdrv_driver/xdrv_10_system_events.ino
@@ -0,0 +1,474 @@
+/*
+ xdrv_10_system_events.ino - system events producer for Tasmota
+
+ Copyright (C) 2021 ESP Easy Group and Theo Arends
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+/*********************************************************************************************\
+ * System events producer
+ *
+ * This file is the single source of truth for emitting Tasmota's "system" rules-events:
+ * - Power#State / Power#Boot, #Boot
+ * - Dimmer#State / Dimmer#Boot
+ * - Time#Minute, Time#Initialized, Time#Set
+ * - System#Init, System#Boot, System#Save
+ * - MQTT#Connected, MQTT#Disconnected
+ * - WIFI#Connected, WIFI#Disconnected
+ * - ETH#Connected, ETH#Disconnected (ESP32 + USE_ETHERNET only)
+ * - HTTP#Initialized
+ * - SHUTTER#Moved, SHUTTER#Moving (USE_SHUTTER only)
+ * - Event# (user-driven `Event` command)
+ *
+ * The emission helpers (SystemEventsInit, SystemEventsEvery50ms, SystemEventsEverySecond,
+ * SystemEventsSaveBeforeRestart, CmndEvent) are always compiled, regardless of USE_RULES,
+ * USE_SCRIPT, or USE_BERRY. They are defined exactly once across the codebase, in this file.
+ *
+ * Slot-10 mutual exclusion
+ * ------------------------
+ * Exactly one of the three slot-10 files registers Xdrv10 / XDRV_10 per build:
+ *
+ * USE_RULES defined -> xdrv_10_rules.ino owns Xdrv10 (calls helpers from here)
+ * USE_SCRIPT defined (no USE_RULES) -> xdrv_10_scripter.ino owns Xdrv10 (helpers here unused)
+ * neither defined -> this file owns Xdrv10 (calls helpers locally)
+ *
+ * The conditional Xdrv10 / RulesProcessEvent / RulesProcess stub for the "neither" build is
+ * gated by `#ifndef USE_RULES` / `#ifndef USE_SCRIPT` and added in a separate sub-task.
+ *
+ * Maintenance hazard: RulesBitfield layout
+ * ----------------------------------------
+ * The strict-priority drainage in SystemEventsEvery50ms and the inline `rules_flag.*` polls
+ * in xdrv_10_scripter.ino both depend on the bit layout of RulesBitfield in
+ * tasmota/include/tasmota_types.h. Any change to RulesBitfield (adding, removing, or
+ * reordering bits) requires matching updates here AND in xdrv_10_rules.ino AND in
+ * xdrv_10_scripter.ino. The comment next to the RulesBitfield definition in
+ * tasmota_types.h is the canonical reminder for this contract.
+\*********************************************************************************************/
+
+#ifndef RULE_MAX_EVENTSZ
+#define RULE_MAX_EVENTSZ 100
+#endif
+
+struct SYSTEM_EVENTS {
+ int32_t new_power = -1; // current TasmotaGlobal.power snapshot
+ int32_t old_power = -1; // last seen power mask; -1 == "never sampled" (boot path)
+ int32_t old_dimm = -1; // last seen Settings->light_dimmer; -1 == boot path
+ uint16_t last_minute = 60; // last RtcTime.minute that fired Time#Minute (60 = invalid sentinel)
+ bool teleperiod = false; // set by FUNC_TELEPERIOD_RULES_PROCESS in the active dispatcher
+ bool busy = false; // re-entry guard for the local RulesProcessEvent stub
+ char event_data[RULE_MAX_EVENTSZ]; // single-slot queue for the `Event` command
+} SystemEvents;
+
+/*********************************************************************************************\
+ * Producer initialization
+\*********************************************************************************************/
+
+void SystemEventsInit(void) {
+ // Start with all rules-flag bits clear so the first 50ms drainage tick has a clean slate.
+ // Settings->rule_enabled and Settings->rule_once are intentionally NOT touched here; they
+ // are part of the Rules-engine domain and remain owned by xdrv_10_rules.ino.
+ TasmotaGlobal.rules_flag.data = 0;
+ SystemEvents.teleperiod = false;
+}
+
+/*********************************************************************************************\
+ * Cross-translation-unit setters
+ *
+ * `xdrv_10_rules.ino` (in USE_RULES builds) needs to write a couple of fields in
+ * `struct SYSTEM_EVENTS`, but the struct definition above is file-scoped and not visible
+ * from `xdrv_10_rules.ino` (each .ino is a separate translation unit). Rather than promote
+ * the struct definition to a shared header just to expose two fields, these tiny
+ * inline-style setters are exposed as `extern` from the rules dispatcher. The cost is one
+ * extra function call per access; the benefit is keeping `struct SYSTEM_EVENTS` private to
+ * this producer.
+ *
+ * - `SystemEventsSetTeleperiod` mirrors `Rules.teleperiod` around
+ * `FUNC_TELEPERIOD_RULES_PROCESS` so `SystemEventsEvery50ms` sees the right value.
+ * - `SystemEventsSetNewPower` is called from `RulesSetPower` (Rules' `FUNC_SET_POWER` hook)
+ * to update the snapshot of the current power mask so the next 50ms tick can detect the
+ * transition and emit `Power#State`.
+\*********************************************************************************************/
+
+void SystemEventsSetTeleperiod(bool v) {
+ SystemEvents.teleperiod = v;
+}
+
+void SystemEventsSetNewPower(int32_t v) {
+ SystemEvents.new_power = v;
+}
+
+/*********************************************************************************************\
+ * 50ms producer tick
+ *
+ * Verbatim port of the Power/Dimmer/Event/rules_flag branches of RulesEvery50ms() from
+ * xdrv_10_rules.ino, MINUS the Vars/Mems-events branch (which stays in Rules) and MINUS the
+ * `Settings->rule_enabled` outer gate (this producer always runs whenever its dispatcher
+ * calls it; the active RulesProcessEvent owns its own re-entry guard).
+ *
+ * The producer is split into two halves so the Rules dispatcher can interleave its
+ * Rules-engine-only Vars/Mems drain between them, restoring the original strict-priority
+ * else-if ladder:
+ *
+ * 1. SystemEventsEvery50msPowerDimmerEvent() — Power, Dimmer, queued Event (returns true
+ * if any branch emitted, telling the caller to yield this tick).
+ * 2. (Rules dispatcher) Vars/Mems drain.
+ * 3. SystemEventsEvery50msRulesFlag() — rules_flag bitfield drain (one bit per tick).
+ *
+ * The combined SystemEventsEvery50ms() wrapper below preserves the legacy single-call API
+ * and is used by the standalone dispatcher (neither USE_RULES nor USE_SCRIPT) where there
+ * is no Vars/Mems drain to interleave.
+ *
+ * At-most-one-event-per-tick discipline: each branch that emits MUST `return` before
+ * subsequent branches are evaluated. Branch 1 (boot fan-out) is the documented exception
+ * during the very first tick, where multiple Power#Boot and switch Boot events are emitted
+ * back-to-back.
+ *
+ * Strict-priority drain inside the rules_flag bitfield mirrors today's ordering exactly:
+ * system_init > system_boot > time_init > time_set
+ * > mqtt_connected > mqtt_disconnected
+ * > wifi_connected > wifi_disconnected
+ * > eth_connected > eth_disconnected (ESP32 + USE_ETHERNET only)
+ * > http_init
+ * > shutter_moved > shutter_moving (USE_SHUTTER only)
+\*********************************************************************************************/
+
+// Returns true if a Power/Dimmer/Event branch emitted (caller should yield this tick).
+bool SystemEventsEvery50msPowerDimmerEvent(void) {
+ char json_event[RULE_MAX_EVENTSZ + 16]; // Add 16 chars for {"Event": .. }
+
+ if (-1 == SystemEvents.new_power) { SystemEvents.new_power = TasmotaGlobal.power; }
+
+ // Branch 1: Power state / boot
+ if (SystemEvents.new_power != SystemEvents.old_power) {
+ if (SystemEvents.old_power != -1) {
+ for (uint32_t i = 0; i < TasmotaGlobal.devices_present; i++) {
+ uint8_t new_state = (SystemEvents.new_power >> i) & 1;
+ if (new_state != ((SystemEvents.old_power >> i) & 1)) {
+ snprintf_P(json_event, sizeof(json_event), PSTR("{\"Power%d\":{\"State\":%d}}"), i + 1, new_state);
+ RulesProcessEvent(json_event);
+ }
+ }
+ } else {
+ // Boot time POWER OUTPUTS (Relays) Status
+ for (uint32_t i = 0; i < TasmotaGlobal.devices_present; i++) {
+ uint8_t new_state = (SystemEvents.new_power >> i) & 1;
+ snprintf_P(json_event, sizeof(json_event), PSTR("{\"Power%d\":{\"Boot\":%d}}"), i + 1, new_state);
+ RulesProcessEvent(json_event);
+ }
+ // Boot time SWITCHES Status
+ for (uint32_t i = 0; i < MAX_SWITCHES_SET; i++) {
+ if (SwitchUsed(i)) {
+ snprintf_P(json_event, sizeof(json_event), PSTR("{\"%s\":{\"Boot\":%d}}"), GetSwitchText(i).c_str(), (SwitchState(i)));
+ RulesProcessEvent(json_event);
+ }
+ }
+ }
+ SystemEvents.old_power = SystemEvents.new_power;
+ return true;
+ }
+
+ // Branch 2: Dimmer state / boot
+ if (SystemEvents.old_dimm != Settings->light_dimmer) {
+ if (SystemEvents.old_dimm != -1) {
+ snprintf_P(json_event, sizeof(json_event), PSTR("{\"Dimmer\":{\"State\":%d}}"), Settings->light_dimmer);
+ } else {
+ // Boot time DIMMER VALUE
+ snprintf_P(json_event, sizeof(json_event), PSTR("{\"Dimmer\":{\"Boot\":%d}}"), Settings->light_dimmer);
+ }
+ RulesProcessEvent(json_event);
+ SystemEvents.old_dimm = Settings->light_dimmer;
+ return true;
+ }
+
+ // Branch 3: queued `Event` command (single-slot queue)
+ if (SystemEvents.event_data[0]) {
+ char *event;
+ char *parameter;
+ event = strtok_r(SystemEvents.event_data, "=", ¶meter); // SystemEvents.event_data = fanspeed=10
+ if (event) {
+ event = Trim(event);
+ if (parameter) {
+ parameter = Trim(parameter);
+ } else {
+ parameter = event + strlen(event); // '\0'
+ }
+ bool quotes = (parameter[0] != '{');
+ snprintf_P(json_event, sizeof(json_event), PSTR("{\"Event\":{\"%s\":%s%s%s}}"), event, (quotes) ? "\"" : "", parameter, (quotes) ? "\"" : "");
+ SystemEvents.event_data[0] = '\0';
+ RulesProcessEvent(json_event);
+ } else {
+ SystemEvents.event_data[0] = '\0';
+ }
+ return true;
+ }
+
+ return false;
+}
+
+// rules_flag bitfield drainage — strict priority, one bit per tick.
+void SystemEventsEvery50msRulesFlag(void) {
+ if (TasmotaGlobal.rules_flag.data) {
+ char json_event[RULE_MAX_EVENTSZ + 16];
+ json_event[0] = '\0';
+ if (TasmotaGlobal.rules_flag.system_init) {
+ TasmotaGlobal.rules_flag.system_init = 0;
+ strncpy_P(json_event, PSTR("{\"System\":{\"Init\":1}}"), sizeof(json_event));
+ }
+ else if (TasmotaGlobal.rules_flag.system_boot) {
+ TasmotaGlobal.rules_flag.system_boot = 0;
+ strncpy_P(json_event, PSTR("{\"System\":{\"Boot\":1}}"), sizeof(json_event));
+ }
+ else if (TasmotaGlobal.rules_flag.time_init) {
+ TasmotaGlobal.rules_flag.time_init = 0;
+ snprintf_P(json_event, sizeof(json_event), PSTR("{\"Time\":{\"Initialized\":%d}}"), MinutesPastMidnight());
+ }
+ else if (TasmotaGlobal.rules_flag.time_set) {
+ TasmotaGlobal.rules_flag.time_set = 0;
+ snprintf_P(json_event, sizeof(json_event), PSTR("{\"Time\":{\"Set\":%d}}"), MinutesPastMidnight());
+ }
+ else if (TasmotaGlobal.rules_flag.mqtt_connected) {
+ TasmotaGlobal.rules_flag.mqtt_connected = 0;
+ strncpy_P(json_event, PSTR("{\"MQTT\":{\"Connected\":1}}"), sizeof(json_event));
+ }
+ else if (TasmotaGlobal.rules_flag.mqtt_disconnected) {
+ TasmotaGlobal.rules_flag.mqtt_disconnected = 0;
+ strncpy_P(json_event, PSTR("{\"MQTT\":{\"Disconnected\":1}}"), sizeof(json_event));
+ }
+ else if (TasmotaGlobal.rules_flag.wifi_connected) {
+ TasmotaGlobal.rules_flag.wifi_connected = 0;
+ strncpy_P(json_event, PSTR("{\"WIFI\":{\"Connected\":1}}"), sizeof(json_event));
+ }
+ else if (TasmotaGlobal.rules_flag.wifi_disconnected) {
+ TasmotaGlobal.rules_flag.wifi_disconnected = 0;
+ strncpy_P(json_event, PSTR("{\"WIFI\":{\"Disconnected\":1}}"), sizeof(json_event));
+ }
+#if defined(ESP32) && defined(USE_ETHERNET)
+ else if (TasmotaGlobal.rules_flag.eth_connected) {
+ TasmotaGlobal.rules_flag.eth_connected = 0;
+ strncpy_P(json_event, PSTR("{\"ETH\":{\"Connected\":1}}"), sizeof(json_event));
+ }
+ else if (TasmotaGlobal.rules_flag.eth_disconnected) {
+ TasmotaGlobal.rules_flag.eth_disconnected = 0;
+ strncpy_P(json_event, PSTR("{\"ETH\":{\"Disconnected\":1}}"), sizeof(json_event));
+ }
+#endif // ESP32 && USE_ETHERNET
+ else if (TasmotaGlobal.rules_flag.http_init) {
+ TasmotaGlobal.rules_flag.http_init = 0;
+ strncpy_P(json_event, PSTR("{\"HTTP\":{\"Initialized\":1}}"), sizeof(json_event));
+ }
+#ifdef USE_SHUTTER
+ else if (TasmotaGlobal.rules_flag.shutter_moved) {
+ TasmotaGlobal.rules_flag.shutter_moved = 0;
+ strncpy_P(json_event, PSTR("{\"SHUTTER\":{\"Moved\":1}}"), sizeof(json_event));
+ }
+ else if (TasmotaGlobal.rules_flag.shutter_moving) {
+ TasmotaGlobal.rules_flag.shutter_moving = 0;
+ strncpy_P(json_event, PSTR("{\"SHUTTER\":{\"Moving\":1}}"), sizeof(json_event));
+ }
+#endif // USE_SHUTTER
+ if (json_event[0]) {
+ RulesProcessEvent(json_event); // Only service one event within 50ms
+ }
+ }
+}
+
+// Legacy single-call wrapper. Used by the standalone Xdrv10 dispatcher in the
+// "neither USE_RULES nor USE_SCRIPT" build, where there is no Vars/Mems drain to interleave.
+void SystemEventsEvery50ms(void) {
+ if (SystemEventsEvery50msPowerDimmerEvent()) { return; }
+ SystemEventsEvery50msRulesFlag();
+}
+
+/*********************************************************************************************\
+ * Per-second producer tick
+ *
+ * Emits Time#Minute once per RTC minute, but only after the device has been up for at least
+ * 60 seconds AND the RTC reports a valid time. The 60-second guard suppresses a spurious
+ * Time#Minute emission during boot when last_minute is still the sentinel (60).
+ *
+ * Note: this function does NOT emit Rules#Timer. The RuleTimer[] machinery and its
+ * {"Rules":{"Timer":}} emission remain a Rules-engine concern in xdrv_10_rules.ino;
+ * Rules timers are not part of the system-event surface that we expose to Scripter or to
+ * the "neither" build mode.
+\*********************************************************************************************/
+
+void SystemEventsEverySecond(void) {
+ if (RtcTime.valid && (TasmotaGlobal.uptime > 60) && (RtcTime.minute != SystemEvents.last_minute)) {
+ // Execute from one minute after restart every minute only once
+ SystemEvents.last_minute = RtcTime.minute;
+ char json_event[120];
+ snprintf_P(json_event, sizeof(json_event), PSTR("{\"Time\":{\"Minute\":%d}}"), MinutesPastMidnight());
+ RulesProcessEvent(json_event);
+ }
+}
+
+/*********************************************************************************************\
+ * Save-before-restart producer hook
+ *
+ * Emits System#Save right before the device persists its config and restarts, giving rules
+ * (or Berry handlers in the "neither" build) a final opportunity to react. Gating against
+ * Settings->rule_enabled / Rules.busy is the responsibility of the active RulesProcessEvent
+ * implementation, not of this producer.
+\*********************************************************************************************/
+
+void SystemEventsSaveBeforeRestart(void) {
+ RulesProcessEvent("{\"System\":{\"Save\":1}}");
+}
+
+/*********************************************************************************************\
+ * `Event` command handler
+ *
+ * Queues a user-supplied `key=value` payload into the single-slot `SystemEvents.event_data`
+ * buffer. The next `SystemEventsEvery50ms` tick will tokenize and emit it as a
+ * `{"Event":{"":}}` JSON event (Branch 3 of the 50ms producer tick).
+ *
+ * This is the SINGLE definition of `CmndEvent` for every build mode. Rules' `kRulesCommands`
+ * table points at this externally-defined symbol via an `extern void CmndEvent(void);` in
+ * `xdrv_10_rules.ino`. Scripter does not register the `Event` command and is unaffected by
+ * this symbol.
+ *
+ * In `USE_SCRIPT`-only builds, this function is still linked (always-compiled) but no
+ * command table references it; it is dead code in that build mode. Acceptable cost for
+ * keeping Scripter untouched.
+\*********************************************************************************************/
+
+void CmndEvent(void) {
+ if (XdrvMailbox.data_len > 0) {
+ strlcpy(SystemEvents.event_data, XdrvMailbox.data, sizeof(SystemEvents.event_data));
+#ifdef USE_DEVICE_GROUPS
+ if (!XdrvMailbox.grpflg) SendDeviceGroupMessage(1, DGR_MSGTYP_UPDATE, DGR_ITEM_EVENT, XdrvMailbox.data);
+#endif // USE_DEVICE_GROUPS
+ }
+ if (XdrvMailbox.command) ResponseCmndDone();
+}
+
+/*********************************************************************************************\
+ * Conditional Xdrv10 / RulesProcessEvent / RulesProcess stub
+ *
+ * Compiled ONLY when neither USE_RULES nor USE_SCRIPT is defined ("neither" build mode).
+ * In this mode, this file owns slot 10 and provides minimal local definitions of the
+ * symbols that the rest of Tasmota expects: `RulesProcessEvent`, `RulesProcess`, and the
+ * `Xdrv10` dispatcher. In USE_RULES builds, xdrv_10_rules.ino owns these symbols; in
+ * USE_SCRIPT-only builds, xdrv_10_scripter.ino owns them.
+ *
+ * The local `RulesProcessEvent` stub provides:
+ * - Berry forwarding (under USE_BERRY) BEFORE the busy guard, matching Rules' ordering.
+ * - A re-entry guard via `SystemEvents.busy` (independent of `Rules.busy`).
+ * - The legacy `{"k":"v"}` -> `{"k":{"Data":"v"}}` rewrite on a local String copy when
+ * the event contains exactly one `:`. The rewritten string is not consumed by anyone
+ * in this build (no rule sets, no Scripter), but is computed for parity so the
+ * observable side-effects (String allocation, toUpperCase) match Rules' behavior.
+\*********************************************************************************************/
+
+#ifndef USE_RULES
+#ifndef USE_SCRIPT
+
+bool RulesProcessEvent(const char *json_event) {
+#ifdef USE_BERRY
+ // Events are passed to Berry before any local processing, mirroring Rules' ordering.
+ // Berry has its own busy/re-entry handling internally.
+ callBerryRule(json_event, SystemEvents.teleperiod);
+#endif // USE_BERRY
+
+ if (SystemEvents.busy) { return false; }
+ if (!strlen(json_event)) { return true; }
+
+ SystemEvents.busy = true;
+
+ // Data: rewrite — preserved bit-for-bit from xdrv_10_rules.ino's RulesProcessEvent.
+ // json_event = {"INA219":{"Voltage":4.494,"Current":0.020,"Power":0.089}} -> unchanged (two colons)
+ // json_event = {"System":{"Boot":1}} -> unchanged (two colons)
+ // json_event = {"SerialReceived":"on"} -> {"SerialReceived":{"Data":"on"}}
+ String event_saved = json_event;
+ char *p = strchr(json_event, ':');
+ if ((p != NULL) && !(strchr(++p, ':'))) { // Find second colon
+ event_saved.replace(F(":"), F(":{\"Data\":"));
+ event_saved += F("}");
+ }
+ event_saved.toUpperCase();
+ // event_saved is intentionally not consumed further: there are no rule sets to evaluate
+ // in this build mode and Scripter is absent. Computing it preserves observable behavior
+ // (allocation/toUpperCase) and keeps a documented hook for future consumers.
+
+ SystemEvents.busy = false;
+ return true;
+}
+
+// Local `RulesProcess` stub — mirrors xdrv_10_rules.ino's `RulesProcess` shape so the
+// `Xdrv10` dispatcher (added in task 2.3) can route `FUNC_RULES_PROCESS` and
+// `FUNC_TELEPERIOD_RULES_PROCESS` here. The busy guard is checked against
+// `SystemEvents.busy` (independent of `Rules.busy`, which does not exist in this build).
+bool RulesProcess(void) {
+ if (!SystemEvents.busy) {
+ return RulesProcessEvent(XdrvMailbox.data);
+ }
+ return false;
+}
+
+/*********************************************************************************************\
+ * Slot-10 ownership: Xdrv10 dispatcher and command table
+ *
+ * In this "neither" build mode (no USE_RULES, no USE_SCRIPT), this file owns slot 10. The
+ * dispatcher routes the standard FUNC_* callbacks to the producer helpers defined above,
+ * plus the local `RulesProcess` stub for FUNC_RULES_PROCESS / FUNC_TELEPERIOD_RULES_PROCESS.
+ *
+ * `D_CMND_EVENT` is defined locally because xdrv_10_rules.ino (which normally defines it)
+ * is not compiled in this build mode.
+\*********************************************************************************************/
+
+#define XDRV_10 10
+
+#define D_CMND_EVENT "Event"
+
+const char kSystemEventsCommands[] PROGMEM = "|" D_CMND_EVENT;
+
+void (* const SystemEventsCommand[])(void) PROGMEM = { &CmndEvent };
+
+bool Xdrv10(uint32_t function) {
+ bool result = false;
+
+ switch (function) {
+ case FUNC_PRE_INIT:
+ SystemEventsInit();
+ break;
+ case FUNC_EVERY_50_MSECOND:
+ SystemEventsEvery50ms();
+ break;
+ case FUNC_EVERY_SECOND:
+ SystemEventsEverySecond();
+ break;
+ case FUNC_SAVE_BEFORE_RESTART:
+ SystemEventsSaveBeforeRestart();
+ break;
+ case FUNC_RULES_PROCESS:
+ result = RulesProcess();
+ break;
+ case FUNC_TELEPERIOD_RULES_PROCESS:
+ SystemEvents.teleperiod = true;
+ result = RulesProcess();
+ SystemEvents.teleperiod = false;
+ break;
+ case FUNC_COMMAND:
+ result = DecodeCommand(kSystemEventsCommands, SystemEventsCommand);
+ break;
+ case FUNC_ACTIVE:
+ result = true;
+ break;
+ }
+ return result;
+}
+
+#endif // !USE_SCRIPT
+#endif // !USE_RULES
diff --git a/tasmota/tasmota_xx2c_global/xdrv_interface.ino b/tasmota/tasmota_xx2c_global/xdrv_interface.ino
index fb32682ee..f7d9ea395 100644
--- a/tasmota/tasmota_xx2c_global/xdrv_interface.ino
+++ b/tasmota/tasmota_xx2c_global/xdrv_interface.ino
@@ -1088,11 +1088,16 @@ bool XdrvRulesProcess(bool teleperiod, const char* event) {
char* data_save = XdrvMailbox.data;
XdrvMailbox.data = (char*)event;
bool rule_handled = XdrvCallDriver(10, (teleperiod) ? FUNC_TELEPERIOD_RULES_PROCESS : FUNC_RULES_PROCESS);
-#if defined(USE_BERRY) && !defined(USE_RULES)
- // events are sent to Berry in Rules driver, or here if USE_RULES is not defined (only on a subset)
+#if defined(USE_BERRY) && !defined(USE_RULES) && defined(USE_SCRIPT)
+ // Berry forwarding for events going through XdrvRulesProcess:
+ // - USE_RULES build: xdrv_10_rules.ino's RulesProcessEvent calls callBerryRule itself.
+ // - USE_SCRIPT only: Scripter's RulesProcessEvent does NOT call Berry, so we forward here.
+ // - "neither" build: xdrv_10_system_events.ino's RulesProcessEvent calls callBerryRule itself.
+ // Forwarding is therefore only needed when Scripter owns slot 10, otherwise Berry receives
+ // the event twice (once via slot 10's dispatcher and once via this fallback).
bool berry_handled = XdrvCallDriver(52, FUNC_RULES_PROCESS);
rule_handled |= berry_handled;
-#endif // USE_BERRY and No USE_RULES
+#endif // USE_BERRY && !USE_RULES && USE_SCRIPT
XdrvMailbox.data = data_save;
return rule_handled;
}